From 7da7cbf6ce7729754159eeaa20b31cc3580fab9a Mon Sep 17 00:00:00 2001 From: Kenny Bergquist Date: Thu, 23 Apr 2026 08:11:47 -0400 Subject: [PATCH] Fix RangeError on multi-line link/image titles Lezer emits LinkTitle / URL nodes that span newlines when CommonMark allows (multi-line titles, wrapped autolinks). The inline-preview ViewPlugin was pushing Decoration.replace for those nodes directly, which throws "Decorations that replace line breaks may not be specified via plugins" at build time. Route every replace in the plugin through a pushReplace helper that per-line-splits the range (first segment carries any widget, rest are plain hides). Single-line ranges hit the early-return and push the original spec unchanged, so common-case output is byte-identical. Covered by a new multiline-decoration.test.tsx regression test; the Playwright harness still passes 50/50 with no CLS deltas. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/multiline-decoration.test.tsx | 50 +++++++++++++++ src/inline-preview.ts | 70 +++++++++++++++------ 2 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/multiline-decoration.test.tsx 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', '![alt](https://example.com/x.png "first\nsecond")'], + ])('%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);