diff --git a/src/__tests__/multiline-decoration.test.tsx b/src/__tests__/multiline-decoration.test.tsx
new file mode 100644
index 0000000..593a947
--- /dev/null
+++ b/src/__tests__/multiline-decoration.test.tsx
@@ -0,0 +1,50 @@
+import { describe, expect, it, afterEach } from 'vitest';
+import { createRoot, type Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+import { AtomicCodeMirrorEditor } from '../AtomicCodeMirrorEditor';
+
+type Mounted = { host: HTMLElement; root: Root };
+const mounts: Mounted[] = [];
+
+function mount(markdown: string): Mounted {
+ const host = document.createElement('div');
+ host.style.width = '600px';
+ host.style.height = '400px';
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render();
+ });
+ const m = { host, root };
+ mounts.push(m);
+ return m;
+}
+
+afterEach(() => {
+ for (const m of mounts.splice(0)) {
+ act(() => m.root.unmount());
+ m.host.remove();
+ }
+});
+
+// Regression: lezer's markdown parser emits some nodes whose range
+// legitimately spans a line break — most reproducibly, a link whose
+// title runs across multiple lines:
+//
+// [text](url "title
+// that wraps")
+//
+// The inline-preview plugin hides these tokens via Decoration.replace
+// to get the live-preview effect on inactive lines. But ViewPlugin
+// decorations are forbidden from replacing a line break, so a naive
+// `Decoration.replace({}).range(node.from, node.to)` on such a token
+// throws "Decorations that replace line breaks may not be specified
+// via plugins" when the builder runs.
+describe('multi-line markdown nodes do not crash the inline-preview plugin', () => {
+ it.each([
+ ['multi-line link title', '[label](https://example.com "first line\nsecond line")'],
+ ['multi-line image title', ''],
+ ])('%s', (_name, markdown) => {
+ expect(() => mount(markdown)).not.toThrow();
+ });
+});
diff --git a/src/inline-preview.ts b/src/inline-preview.ts
index a8e32d6..95818c5 100644
--- a/src/inline-preview.ts
+++ b/src/inline-preview.ts
@@ -7,6 +7,7 @@ import {
StateField,
type Extension,
type Range,
+ type Text,
} from '@codemirror/state';
import {
Decoration,
@@ -243,6 +244,47 @@ class TaskCheckboxWidget extends WidgetType {
}
}
+// ViewPlugin-sourced Decoration.replace ranges are forbidden from
+// crossing a line break — CM6 throws "Decorations that replace line
+// breaks may not be specified via plugins" at build time. Lezer
+// happily emits tokens that do cross line breaks (a LinkTitle /
+// Image title "wrapping across\ntwo lines", for instance), so every
+// Decoration.replace we push has to be split into per-line segments
+// first. The newline between segments stays visible — acceptable
+// compromise, and it matches how other markdown editors render these
+// uncommon multi-line forms.
+function pushReplace(
+ ranges: Range[],
+ doc: Text,
+ from: number,
+ to: number,
+ spec: Parameters[0] = {},
+): void {
+ if (from >= to) return;
+ const startLine = doc.lineAt(from);
+ if (to <= startLine.to) {
+ ranges.push(Decoration.replace(spec).range(from, to));
+ return;
+ }
+ // Multi-line: first segment carries the widget (if any) so it
+ // renders in place of the opening token; subsequent segments are
+ // plain hides. Emitting the widget on every segment would stack
+ // duplicates (e.g. a BulletWidget on line 2+ of a wrapped item).
+ let cursor = from;
+ let firstSegment = true;
+ while (cursor < to) {
+ const line = doc.lineAt(cursor);
+ const segEnd = Math.min(to, line.to);
+ if (segEnd > cursor) {
+ ranges.push(
+ Decoration.replace(firstSegment ? spec : {}).range(cursor, segEnd),
+ );
+ firstSegment = false;
+ }
+ cursor = line.to + 1;
+ }
+}
+
function buildInlineDecorations(view: EditorView): DecorationSet {
const { state } = view;
const { doc } = state;
@@ -362,7 +404,7 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
hideTo++;
}
}
- ranges.push(Decoration.replace({}).range(node.from, hideTo));
+ pushReplace(ranges, doc, node.from, hideTo);
}
}
@@ -376,7 +418,7 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
if (node.name === 'Escape' && node.to - node.from >= 2) {
const lineNum = doc.lineAt(node.from).number;
if (!activeLines.has(lineNum)) {
- ranges.push(Decoration.replace({}).range(node.from, node.from + 1));
+ pushReplace(ranges, doc, node.from, node.from + 1);
}
}
@@ -427,19 +469,14 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
if (taskFrom !== undefined) {
// Hide `- ` (ListMark through the space before `[`).
- ranges.push(Decoration.replace({}).range(node.from, taskFrom));
+ pushReplace(ranges, doc, node.from, taskFrom);
} else {
const markText = doc.sliceString(node.from, node.to);
if (markText === '-' || markText === '*' || markText === '+') {
// Bullet: substitute with the fixed-width marker
// widget, swallowing the trailing space so content
// starts precisely at padding-left.
- ranges.push(
- Decoration.replace({ widget: BULLET_WIDGET }).range(
- node.from,
- markEnd,
- ),
- );
+ pushReplace(ranges, doc, node.from, markEnd, { widget: BULLET_WIDGET });
} else {
// Ordered list (or anything else with a non-standard
// mark text like `1.`, `42.`): keep the text visible
@@ -453,7 +490,7 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
),
);
if (hasTrailingSpace) {
- ranges.push(Decoration.replace({}).range(node.to, markEnd));
+ pushReplace(ranges, doc, node.to, markEnd);
}
}
}
@@ -475,7 +512,7 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
const line = doc.lineAt(node.from);
if (!activeLines.has(line.number)) {
ranges.push(Decoration.line({ class: 'cm-atomic-hr' }).range(line.from));
- ranges.push(Decoration.replace({}).range(line.from, line.to));
+ pushReplace(ranges, doc, line.from, line.to);
}
}
@@ -496,7 +533,7 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
// back up." The tradeoff is one line of empty space
// above each rendered image, which actually reads a bit
// cleaner as visual separation anyway.
- ranges.push(Decoration.replace({}).range(node.from, node.to));
+ pushReplace(ranges, doc, node.from, node.to);
}
}
@@ -512,12 +549,9 @@ function buildInlineDecorations(view: EditorView): DecorationSet {
node.to < doc.length &&
doc.sliceString(node.to, node.to + 1) === ' ';
const replaceTo = hasTrailingSpace ? node.to + 1 : node.to;
- ranges.push(
- Decoration.replace({ widget: new TaskCheckboxWidget(checked) }).range(
- node.from,
- replaceTo,
- ),
- );
+ pushReplace(ranges, doc, node.from, replaceTo, {
+ widget: new TaskCheckboxWidget(checked),
+ });
if (checked) {
const lineNum = doc.lineAt(node.from).number;
const line = doc.line(lineNum);