From cca8b544d8c0550d0fbdd90a130609c7bedb8cc6 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Fri, 6 Feb 2026 20:53:23 -0500 Subject: [PATCH 1/2] fix: use fragment.lines for click position in multi-column layouts --- .../layout-engine/layout-bridge/src/index.ts | 29 ++- .../test/clickToPosition.test.ts | 242 +++++++++++++++++- 2 files changed, 258 insertions(+), 13 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index edc8deff28..698d71ade6 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -959,7 +959,11 @@ export function clickToPosition( const { fragment, block, measure, pageIndex, pageY } = fragmentHit; // Handle paragraph fragments if (fragment.kind === 'para' && measure.kind === 'paragraph' && block.kind === 'paragraph') { - const lineIndex = findLineIndexAtY(measure, pageY, fragment.fromLine, fragment.toLine); + // Use fragment-specific lines when available (remeasured for column width), + // otherwise slice from measure.lines for this fragment's range. + const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); + + const lineIndex = findLineIndexAtY(lines, pageY, 0, lines.length); if (lineIndex == null) { logClickStage('warn', 'no-line', { blockId: fragment.blockId, @@ -968,7 +972,8 @@ export function clickToPosition( }); return null; } - const line = measure.lines[lineIndex]; + + const line = lines[lineIndex]; const isRTL = isRtlBlock(block); // Type guard: Validate indent structure and ensure numeric values @@ -1077,7 +1082,7 @@ export function clickToPosition( const { cellBlock, cellMeasure, localX, localY, pageIndex } = tableHit; // Find the line at the local Y position within the cell paragraph - const lineIndex = findLineIndexAtY(cellMeasure, localY, 0, cellMeasure.lines.length); + const lineIndex = findLineIndexAtY(cellMeasure.lines, localY, 0, cellMeasure.lines.length); if (lineIndex != null) { const line = cellMeasure.lines[lineIndex]; const isRTL = isRtlBlock(cellBlock); @@ -2064,13 +2069,13 @@ const determineColumn = (layout: Layout, fragmentX: number): number => { }; /** - * Finds the line index at a given Y offset within a paragraph measure. + * Finds the line index at a given Y offset within a set of lines. * * This function searches within a specified range of lines to determine which line * contains the given Y coordinate. It validates bounds to prevent out-of-bounds * access in case of corrupted layout data. * - * @param measure - The paragraph measure containing line data + * @param lines - The array of lines to search through * @param offsetY - The Y offset in pixels to search for * @param fromLine - The starting line index (inclusive) * @param toLine - The ending line index (exclusive) @@ -2078,29 +2083,29 @@ const determineColumn = (layout: Layout, fragmentX: number): number => { * * @throws Never throws - returns null for invalid inputs */ -const findLineIndexAtY = (measure: Measure, offsetY: number, fromLine: number, toLine: number): number | null => { - if (measure.kind !== 'paragraph') return null; +const findLineIndexAtY = (lines: Line[], offsetY: number, fromLine: number, toLine: number): number | null => { + if (!lines || lines.length === 0) return null; // Validate bounds to prevent out-of-bounds access - const lineCount = measure.lines.length; + const lineCount = lines.length; if (fromLine < 0 || toLine > lineCount || fromLine >= toLine) { return null; } let cursor = 0; - // Only search within the fragment's line range + // Only search within the specified line range for (let i = fromLine; i < toLine; i += 1) { - const line = measure.lines[i]; + const line = lines[i]; // Guard against undefined lines (defensive check for corrupted data) if (!line) return null; const next = cursor + line.lineHeight; if (offsetY >= cursor && offsetY < next) { - return i; // Return absolute line index within measure + return i; // Return line index within the array } cursor = next; } - // If beyond all lines, return the last line in the fragment + // If beyond all lines, return the last line in the range return toLine - 1; }; diff --git a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts index 9de2840e91..833a30ba4d 100644 --- a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts +++ b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { clickToPosition, hitTestPage } from '../src/index.ts'; -import type { Layout } from '@superdoc/contracts'; +import type { Layout, FlowBlock, Measure, Line, ParaFragment } from '@superdoc/contracts'; import { simpleLayout, blocks, @@ -100,3 +100,243 @@ describe('hitTestPage with pageGap', () => { expect(result?.pageIndex).toBe(1); }); }); + +describe('clickToPosition with fragment.lines', () => { + // Tests for multi-column documents where fragments have remeasured lines + // that differ from measure.lines. + // + // Example scenario - paragraph "Hello world" in a two-column layout: + // + // Original measure (full page width): Remeasured for column width: + // ┌────────────────────────────────┐ ┌──────────────┐ + // │ Hello world │ │ Hello │ ← line 0 + // └────────────────────────────────┘ │ world │ ← line 1 + // (1 line) └──────────────┘ + // (2 lines) + // + // measure.lines = [line0] fragment.lines = [line0, line1] + // + // The bug: using measure.lines with fragment.fromLine/toLine indices + // caused out-of-bounds access when the fragment had more lines than measure. + + // ───────────────────────────────────────────────────────────────────────────── + // REMEASURED LINES + // ───────────────────────────────────────────────────────────────────────────── + // These represent the line breaks after remeasuring at column width. + // The paragraph "Hello world" wraps into two lines: + // + // remeasuredLine1: "Hello " (run 0, chars 0-5) + // remeasuredLine2: "world" (run 0 char 5 → run 1 char 5) + // + // ┌──────────────┐ + // │ H e l l o │ ← remeasuredLine1 (y: 0-20) + // │ w o r l d │ ← remeasuredLine2 (y: 20-40) + // └──────────────┘ + // + const remeasuredLine1: Line = { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, // "Hello" (5 chars, space trimmed) + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }; + + const remeasuredLine2: Line = { + fromRun: 0, + fromChar: 5, // continues from end of line 1 + toRun: 1, + toChar: 5, // "world" (5 chars) + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }; + + // ───────────────────────────────────────────────────────────────────────────── + // FLOW BLOCK (ProseMirror content) + // ───────────────────────────────────────────────────────────────────────────── + // The source paragraph content with two runs: + // + // run 0: "Hello " (pmStart: 1, pmEnd: 7) + // run 1: "world" (pmStart: 7, pmEnd: 12) + // + // PM positions: 1 2 3 4 5 6 7 8 9 10 11 12 + // Characters: H e l l o w o r l d + // └─── run 0 ───┘ └─── run 1 ───┘ + // + const twoColumnBlock: FlowBlock = { + kind: 'paragraph', + id: 'two-column-para', + runs: [ + { text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 }, + ], + }; + + // ───────────────────────────────────────────────────────────────────────────── + // ORIGINAL MEASURE (full page width) + // ───────────────────────────────────────────────────────────────────────────── + // When measured at full page width, the entire paragraph fits on one line: + // + // ┌────────────────────────────────────────┐ + // │ H e l l o w o r l d │ ← single line (y: 0-20) + // └────────────────────────────────────────┘ + // + // measure.lines.length = 1 + // + const originalMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: 5, // entire paragraph: "Hello world" + width: 200, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + // ───────────────────────────────────────────────────────────────────────────── + // FRAGMENT (positioned on page, with remeasured lines) + // ───────────────────────────────────────────────────────────────────────────── + // This fragment is placed in column 2 of a two-column layout. + // It contains `lines` array with the remeasured line breaks. + // + // Page layout (600px wide): + // + // x=0 x=290 x=310 x=600 + // ┌──────────┐ ┌──────────┐ + // │ Column 1 │ │ Column 2 │ + // │ │ │┌────────┐│ + // │ │ ││ Hello ││ ← fragment at (300, 40) + // │ │ ││ world ││ + // │ │ │└────────┘│ + // └──────────┘ └──────────┘ + // + // THE BUG: fragment.fromLine=0, fragment.toLine=2 are indices into + // fragment.lines (length 2), but the old code used these to access + // measure.lines (length 1), causing measure.lines[1] → undefined + // + const fragmentWithRemeasuredLines: ParaFragment = { + kind: 'para', + blockId: 'two-column-para', + fromLine: 0, // index into fragment.lines (NOT measure.lines) + toLine: 2, // would be out-of-bounds for measure.lines! + x: 300, // positioned in column 2 + y: 40, + width: 150, + pmStart: 1, + pmEnd: 12, + lines: [remeasuredLine1, remeasuredLine2], // the remeasured lines for this fragment + }; + + const twoColumnLayout: Layout = { + pageSize: { w: 600, h: 800 }, + columns: { count: 2, gap: 20 }, + pages: [ + { + number: 1, + fragments: [fragmentWithRemeasuredLines], + }, + ], + }; + + it('uses fragment.lines when available instead of measure.lines', () => { + // ─────────────────────────────────────────────────────────────────────── + // Click in the first line of the fragment: + // + // Click point: (350, 50) + // + // Fragment at (300, 40): + // y=40 ┌──────────────┐ + // │ Hello ← * │ click y=50 hits line 1 (y: 40-60) + // y=60 │ world │ + // y=80 └──────────────┘ + // x=350 + // + // Without the fix: TypeError because measure.lines[1] is undefined + // With the fix: uses fragment.lines to find line, returns valid position + // ─────────────────────────────────────────────────────────────────────── + const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 50 }); + + expect(result).not.toBeNull(); + expect(result?.blockId).toBe('two-column-para'); + expect(result?.pos).toBeGreaterThanOrEqual(1); + expect(result?.pos).toBeLessThanOrEqual(12); + }); + + it('correctly maps click position in second line of fragment with remeasured lines', () => { + // ─────────────────────────────────────────────────────────────────────── + // Click in the second line of the fragment: + // + // Click point: (350, 65) + // + // Fragment at (300, 40): + // y=40 ┌──────────────┐ + // │ Hello │ + // y=60 │ world ← * │ click y=65 hits line 2 (y: 60-80) + // y=80 └──────────────┘ + // x=350 + // + // This tests that we correctly index into fragment.lines[1] ("world") + // ─────────────────────────────────────────────────────────────────────── + const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 65 }); + + expect(result).not.toBeNull(); + expect(result?.blockId).toBe('two-column-para'); + // The click should map to a position in the second line's range ("world" starts at position 7) + expect(result?.pos).toBeGreaterThanOrEqual(7); + expect(result?.pos).toBeLessThanOrEqual(12); + }); + + it('handles fragment without lines array (uses measure.lines)', () => { + // ─────────────────────────────────────────────────────────────────────── + // Fallback test: fragment WITHOUT remeasured lines + // + // When fragment.lines is absent, we fall back to measure.lines. + // This is the common case for single-column layouts. + // + // Fragment at (30, 40), width=200 (full width, no remeasure): + // y=40 ┌────────────────────────────────┐ + // │ Hello world ← * │ click y=50 hits line 1 + // y=60 └────────────────────────────────┘ + // x=100 + // + // ─────────────────────────────────────────────────────────────────────── + const fragmentWithoutLines: ParaFragment = { + kind: 'para', + blockId: 'two-column-para', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 200, + pmStart: 1, + pmEnd: 12, + // No `lines` property - should fall back to measure.lines + }; + + const layoutWithoutFragmentLines: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [fragmentWithoutLines], + }, + ], + }; + + const result = clickToPosition(layoutWithoutFragmentLines, [twoColumnBlock], [originalMeasure], { x: 100, y: 50 }); + + expect(result).not.toBeNull(); + expect(result?.blockId).toBe('two-column-para'); + }); +}); From 84bfee31387ff22c5193cfb822af5f25e3755d73 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 27 Feb 2026 13:50:07 -0300 Subject: [PATCH 2/2] test: add behavior test for multi-column click positioning (SD-1830) End-to-end Playwright tests that verify clicking in two-column documents works correctly across Chromium, Firefox, and WebKit. Covers the customer-reported TypeError from IT-407. --- .../selection/fixtures/two-column-simple.docx | Bin 0 -> 28755 bytes .../multi-column-click-positioning.spec.ts | 91 ++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/behavior/tests/selection/fixtures/two-column-simple.docx create mode 100644 tests/behavior/tests/selection/multi-column-click-positioning.spec.ts diff --git a/tests/behavior/tests/selection/fixtures/two-column-simple.docx b/tests/behavior/tests/selection/fixtures/two-column-simple.docx new file mode 100644 index 0000000000000000000000000000000000000000..cd7b9abbf9b63a6ee2a6af1f7166a34fa0afb639 GIT binary patch literal 28755 zcmeFYRdgK7vMnlRSerJsL zeQtl`sP4)c5p!llW@S~ioFq5|ItUa939k7*#F^7_k5U`}{9fpgwWTqK^q#_#x;uV5YHOqLr$serQLy$1qc;Bi*@jCN=47HnIukkJ%YQ)?jeeYU=iLSmyR9`+~{+4jH`8m2lh z?xm!Hcbz?4G&^GU7$DIivE%aalSo93+@yQkh=ctajvulzOiE(j?od%6UidwG(52ws z7MPgmB&$V5WK-&QwHR1#ˏ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
%Z<3;IvL!?I%iJAhhcRuGc5wiwh9jOAy#KWK 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 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/selection/multi-column-click-positioning.spec.ts b/tests/behavior/tests/selection/multi-column-click-positioning.spec.ts new file mode 100644 index 0000000000..ff1580b496 --- /dev/null +++ b/tests/behavior/tests/selection/multi-column-click-positioning.spec.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/two-column-simple.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Two-column fixture not available'); + +test.use({ config: { toolbar: 'none', showCaret: true, showSelection: true } }); + +test('clicking in second column does not crash (SD-1830 / IT-407)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Get the first page bounding box to compute column positions + const page = superdoc.page.locator('.superdoc-page').first(); + const pageBox = await page.boundingBox(); + if (!pageBox) throw new Error('Page not visible'); + + // Click near the top of the second column (right half of page). + // This is exactly where the customer reported the crash (IT-407): + // "the first paragraph at the top of the second column" + const secondColumnX = pageBox.x + pageBox.width * 0.75; + const topOfColumnY = pageBox.y + 40; + + // Without the fix this throws: TypeError: can't access property "fromRun", line is undefined + await superdoc.page.mouse.click(secondColumnX, topOfColumnY); + await superdoc.waitForStable(); + + // Cursor should be at a valid position (not crashed, not zero) + const sel = await superdoc.getSelection(); + expect(sel.from).toBeGreaterThan(0); +}); + +test('clicking in both columns places cursor at different positions', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const page = superdoc.page.locator('.superdoc-page').first(); + const pageBox = await page.boundingBox(); + if (!pageBox) throw new Error('Page not visible'); + + // Click well into the text area of the first column (left quarter, middle of page height) + const firstColumnX = pageBox.x + pageBox.width * 0.25; + const clickY = pageBox.y + pageBox.height * 0.5; + + await superdoc.page.mouse.click(firstColumnX, clickY); + await superdoc.waitForStable(); + const selLeft = await superdoc.getSelection(); + + // Click at the same Y in the second column (right quarter) + const secondColumnX = pageBox.x + pageBox.width * 0.75; + + await superdoc.page.mouse.click(secondColumnX, clickY); + await superdoc.waitForStable(); + const selRight = await superdoc.getSelection(); + + // Both should be valid positions + expect(selLeft.from).toBeGreaterThan(0); + expect(selRight.from).toBeGreaterThan(0); + + // Cursor should land at different positions — text flows left column then right column, + // so the right column position should be further into the document. + expect(selRight.from).not.toBe(selLeft.from); +}); + +test('typing after clicking in second column inserts text (SD-1830)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const page = superdoc.page.locator('.superdoc-page').first(); + const pageBox = await page.boundingBox(); + if (!pageBox) throw new Error('Page not visible'); + + // Click in the second column + const secondColumnX = pageBox.x + pageBox.width * 0.75; + const topOfColumnY = pageBox.y + 40; + + await superdoc.page.mouse.click(secondColumnX, topOfColumnY); + await superdoc.waitForStable(); + + // Type a unique marker to prove the cursor is functional + await superdoc.type('MARKER'); + await superdoc.waitForStable(); + + // The marker should appear in the document + const text = await superdoc.getTextContent(); + expect(text).toContain('MARKER'); +});