From 7825d326bb05efc5d23aa4647c281c0e57b5cd1f Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Wed, 15 Apr 2026 17:14:16 -0300 Subject: [PATCH 1/3] fix: document API tracked changes not fully rendered on document --- .../painters/dom/src/index.test.ts | 81 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 41 +++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6508983203..5bc88b8eab 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -10920,6 +10920,87 @@ describe('applyRunDataAttributes', () => { }).not.toThrow(); }); + it('renders all lines when measure indices come from inline-newline expansion', () => { + const inlineNewlineBlock: FlowBlock = { + kind: 'paragraph', + id: 'inline-newline-slice', + runs: [{ text: 'first\nsecond\nthird', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 18 }], + }; + + // Measurer expands inline '\n' into: text, break, text, break, text. + const inlineNewlineMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 40, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 2, + fromChar: 0, + toRun: 2, + toChar: 6, + width: 50, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 4, + fromChar: 0, + toRun: 4, + toChar: 5, + width: 40, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 60, + }; + + const inlineNewlineLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-newline-slice', + fromLine: 0, + toLine: 3, + x: 20, + y: 20, + width: 300, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [inlineNewlineBlock], + measures: [inlineNewlineMeasure], + }); + + expect(() => { + painter.paint(inlineNewlineLayout, mount); + }).not.toThrow(); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment).toBeTruthy(); + expect(fragment.textContent).toContain('first'); + expect(fragment.textContent).toContain('second'); + expect(fragment.textContent).toContain('third'); + }); + it('preserves PM positions for lineBreak runs', () => { const lineBreakBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c0c85996fa..ee65ae629a 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7696,11 +7696,50 @@ const stripListIndent = (attrs?: ParagraphAttrs): ParagraphAttrs | undefined => * // Returns runs or run slices that fall within the specified character range * ``` */ +const expandRunsForInlineNewlines = (runs: Run[]): Run[] => { + const expanded: Run[] = []; + + for (const run of runs) { + if ((run as TextRun).text && typeof (run as TextRun).text === 'string' && (run as TextRun).text.includes('\n')) { + const textRun = run as TextRun; + const segments = textRun.text.split('\n'); + let cursor = textRun.pmStart ?? 0; + + segments.forEach((segment, idx) => { + expanded.push({ + ...textRun, + text: segment, + pmStart: cursor, + pmEnd: cursor + segment.length, + }); + cursor += segment.length; + + if (idx !== segments.length - 1) { + expanded.push({ + kind: 'break', + breakType: 'line', + pmStart: cursor, + pmEnd: cursor + 1, + sdt: textRun.sdt, + }); + cursor += 1; + } + }); + continue; + } + + expanded.push(run); + } + + return expanded; +}; + export const sliceRunsForLine = (block: ParagraphBlock, line: Line): Run[] => { + const runs = expandRunsForInlineNewlines(block.runs as Run[]); const result: Run[] = []; for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { - const run = block.runs[runIndex]; + const run = runs[runIndex]; if (!run) continue; // FIXED: ImageRun handling - images are atomic units, no slicing needed From 363336cb37193f1cdec93d155a069f2b837f657e Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Wed, 15 Apr 2026 17:26:40 -0300 Subject: [PATCH 2/3] fix: tests --- .../plan-engine/executor.test.ts | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index be63a3fda6..1a49d8d001 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -108,6 +108,33 @@ function makeEditor(text = 'Hello'): { }; dispatch: ReturnType; } { + const sliceTextBetween = (from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }; + + // Mutations run against `tr.doc` (same as PM); keep it aligned with `state.doc` so + // executeTextRewrite / charOffsetToDocPos / assert helpers see consistent text APIs. + const sharedDoc = { + textContent: text, + textBetween: vi.fn((from: number, to: number) => sliceTextBetween(from, to)), + resolve: () => ({ marks: () => [] }), + nodesBetween: ( + rangeFrom: number, + rangeTo: number, + f: (node: { isText: boolean; text: string; nodeSize: number }, pos: number) => boolean | void, + ) => { + const textPos = 1; + if (rangeTo <= textPos || rangeFrom >= textPos + text.length) return; + const node = { isText: true, text, nodeSize: text.length }; + f(node, textPos); + }, + descendants: (_f: (node: unknown, pos: number) => boolean | void) => { + // Default tests resolve text asserts via textBetween / textContent; node walks can override per test. + }, + }; + const tr = { replace: vi.fn(), replaceWith: vi.fn(), @@ -118,10 +145,7 @@ function makeEditor(text = 'Hello'): { setMeta: vi.fn(), mapping: { map: (pos: number) => pos }, docChanged: true, - doc: { - resolve: () => ({ marks: () => [] }), - textContent: text, - }, + doc: sharedDoc, }; tr.replace.mockReturnValue(tr); tr.replaceWith.mockReturnValue(tr); @@ -138,15 +162,7 @@ function makeEditor(text = 'Hello'): { const editor = { state: { - doc: { - textContent: text, - textBetween: vi.fn((from: number, to: number) => { - const start = Math.max(0, from - 1); - const end = Math.max(start, to - 1); - return text.slice(start, end); - }), - nodesBetween: vi.fn(), - }, + doc: sharedDoc, tr, schema: { marks: { @@ -2759,11 +2775,6 @@ describe('executeCompiledPlan: atomic rollback on failure', () => { mockedDeps.resolveInlineStyle.mockReturnValue([]); mockedDeps.getRevision.mockReturnValue('0'); - // Patch tr.doc with descendants so buildAssertIndex can run - const tr = editor.state.tr as any; - tr.doc.descendants = vi.fn(); - tr.doc.textBetween = vi.fn(() => ''); - const mutationStep: TextRewriteStep = { id: 'step-1', op: 'text.rewrite', From 9781989a3ef836161fe1fbbf3ad688a594bc7403 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Wed, 15 Apr 2026 17:34:30 -0300 Subject: [PATCH 3/3] Revert "fix: tests" This reverts commit 363336cb37193f1cdec93d155a069f2b837f657e. --- .../plan-engine/executor.test.ts | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index 1a49d8d001..be63a3fda6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -108,33 +108,6 @@ function makeEditor(text = 'Hello'): { }; dispatch: ReturnType; } { - const sliceTextBetween = (from: number, to: number) => { - const start = Math.max(0, from - 1); - const end = Math.max(start, to - 1); - return text.slice(start, end); - }; - - // Mutations run against `tr.doc` (same as PM); keep it aligned with `state.doc` so - // executeTextRewrite / charOffsetToDocPos / assert helpers see consistent text APIs. - const sharedDoc = { - textContent: text, - textBetween: vi.fn((from: number, to: number) => sliceTextBetween(from, to)), - resolve: () => ({ marks: () => [] }), - nodesBetween: ( - rangeFrom: number, - rangeTo: number, - f: (node: { isText: boolean; text: string; nodeSize: number }, pos: number) => boolean | void, - ) => { - const textPos = 1; - if (rangeTo <= textPos || rangeFrom >= textPos + text.length) return; - const node = { isText: true, text, nodeSize: text.length }; - f(node, textPos); - }, - descendants: (_f: (node: unknown, pos: number) => boolean | void) => { - // Default tests resolve text asserts via textBetween / textContent; node walks can override per test. - }, - }; - const tr = { replace: vi.fn(), replaceWith: vi.fn(), @@ -145,7 +118,10 @@ function makeEditor(text = 'Hello'): { setMeta: vi.fn(), mapping: { map: (pos: number) => pos }, docChanged: true, - doc: sharedDoc, + doc: { + resolve: () => ({ marks: () => [] }), + textContent: text, + }, }; tr.replace.mockReturnValue(tr); tr.replaceWith.mockReturnValue(tr); @@ -162,7 +138,15 @@ function makeEditor(text = 'Hello'): { const editor = { state: { - doc: sharedDoc, + doc: { + textContent: text, + textBetween: vi.fn((from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }), + nodesBetween: vi.fn(), + }, tr, schema: { marks: { @@ -2775,6 +2759,11 @@ describe('executeCompiledPlan: atomic rollback on failure', () => { mockedDeps.resolveInlineStyle.mockReturnValue([]); mockedDeps.getRevision.mockReturnValue('0'); + // Patch tr.doc with descendants so buildAssertIndex can run + const tr = editor.state.tr as any; + tr.doc.descendants = vi.fn(); + tr.doc.textBetween = vi.fn(() => ''); + const mutationStep: TextRewriteStep = { id: 'step-1', op: 'text.rewrite',