From 70c0816a738a3be1c57ecb4585dc7ca1fae0c065 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 14 Apr 2026 14:58:43 +0200 Subject: [PATCH] fix: render manual line breaks correctly --- .../layout-bridge/src/remeasure.ts | 23 ++++++++++++++--- .../layout-bridge/test/remeasure.test.ts | 25 ++++++++++++++++++- .../measuring/dom/src/index.test.ts | 2 ++ .../layout-engine/measuring/dom/src/index.ts | 15 +++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index 5e7ae01e43..783ddceb64 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -1156,11 +1156,22 @@ export function remeasureParagraph( let endChar = currentChar; let tabStopCursor = 0; let didBreakInThisLine = false; + let explicitLineBreakRun = -1; let resumeRun = -1; let resumeChar = 0; for (let r = currentRun; r < runs.length; r += 1) { const run = runs[r]; + if (isLineBreakRun(run)) { + explicitLineBreakRun = r; + if (startRun === r && startChar === 0 && width === 0) { + // Preserve leading/manual explicit break as an empty line. + endRun = r; + endChar = 0; + } + didBreakInThisLine = true; + break; + } if (run.kind === 'tab') { const absCurrentX = width + effectiveIndent; const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor); @@ -1302,7 +1313,7 @@ export function remeasureParagraph( } // If we didn't consume any chars (e.g., very long single char), force one char - if (startRun === endRun && startChar === endChar) { + if (explicitLineBreakRun < 0 && startRun === endRun && startChar === endChar) { endRun = startRun; endChar = startChar + 1; } @@ -1321,8 +1332,14 @@ export function remeasureParagraph( lines.push(line); // Advance to next line start - currentRun = endRun; - currentChar = endChar; + if (explicitLineBreakRun >= 0) { + // Explicit break consumed as a line boundary, continue from run after break. + currentRun = explicitLineBreakRun + 1; + currentChar = 0; + } else { + currentRun = endRun; + currentChar = endChar; + } if (currentRun >= runs.length) { break; } diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index f7e757e55a..becef13bf7 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -872,7 +872,30 @@ describe('remeasureParagraph', () => { const block = createBlock([textRun('Hello'), { kind: 'lineBreak' } as Run, textRun('World')]); const measure = remeasureParagraph(block, 200); - expect(measure.lines.length).toBeGreaterThanOrEqual(1); + expect(measure.lines).toHaveLength(2); + expect(measure.lines[0].fromRun).toBe(0); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[1].fromRun).toBe(2); + expect(measure.lines[1].toRun).toBe(2); + }); + + it('preserves multiple explicit lineBreak boundaries', () => { + const block = createBlock([ + textRun('One'), + { kind: 'lineBreak' } as Run, + textRun('Two'), + { kind: 'lineBreak' } as Run, + textRun('Three'), + ]); + const measure = remeasureParagraph(block, 200); + + expect(measure.lines).toHaveLength(3); + expect(measure.lines[0].fromRun).toBe(0); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[1].fromRun).toBe(2); + expect(measure.lines[1].toRun).toBe(2); + expect(measure.lines[2].fromRun).toBe(4); + expect(measure.lines[2].toRun).toBe(4); }); it('handles tabs followed immediately by line break', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c4d26ea236..6a5083091b 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -542,6 +542,8 @@ describe('measureBlock', () => { expect(measure.lines).toHaveLength(2); expect(measure.lines[0].width).toBeGreaterThan(0); expect(measure.lines[1].width).toBeGreaterThan(0); + expect(measure.lines[1].fromRun).toBe(2); + expect(measure.lines[1].toRun).toBe(2); }); it('creates an empty line for leading lineBreak at start of paragraph', async () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index ded7439f9e..f893c58fc6 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -1397,6 +1397,21 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P continue; } + // When a text/tab/atomic run follows an explicit lineBreak, currentLine is a + // placeholder line seeded with the break run index. Re-anchor it so line ranges + // start at the first visible run on the new line. + if ( + currentLine && + currentLine.width === 0 && + currentLine.fromRun === currentLine.toRun && + currentLine.fromChar === 0 && + currentLine.toChar === 0 && + isLineBreakRun(runsToProcess[currentLine.fromRun] as Run) + ) { + currentLine.fromRun = runIndex; + currentLine.toRun = runIndex; + } + // Handle tab runs specially if (isTabRun(run)) { // Clear any previous tab group when we encounter a new tab