Skip to content

Commit 6a5a2e6

Browse files
SD-1830 - use fragment.lines for click position in multi-column layouts (#1963)
* fix: use fragment.lines for click position in multi-column layouts * 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. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 48b4210 commit 6a5a2e6

4 files changed

Lines changed: 349 additions & 13 deletions

File tree

packages/layout-engine/layout-bridge/src/index.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,11 @@ export function clickToPosition(
10371037
const { fragment, block, measure, pageIndex, pageY } = fragmentHit;
10381038
// Handle paragraph fragments
10391039
if (fragment.kind === 'para' && measure.kind === 'paragraph' && block.kind === 'paragraph') {
1040-
const lineIndex = findLineIndexAtY(measure, pageY, fragment.fromLine, fragment.toLine);
1040+
// Use fragment-specific lines when available (remeasured for column width),
1041+
// otherwise slice from measure.lines for this fragment's range.
1042+
const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine);
1043+
1044+
const lineIndex = findLineIndexAtY(lines, pageY, 0, lines.length);
10411045
if (lineIndex == null) {
10421046
logClickStage('warn', 'no-line', {
10431047
blockId: fragment.blockId,
@@ -1046,7 +1050,8 @@ export function clickToPosition(
10461050
});
10471051
return null;
10481052
}
1049-
const line = measure.lines[lineIndex];
1053+
1054+
const line = lines[lineIndex];
10501055

10511056
const isRTL = isRtlBlock(block);
10521057
// Type guard: Validate indent structure and ensure numeric values
@@ -1155,7 +1160,7 @@ export function clickToPosition(
11551160
const { cellBlock, cellMeasure, localX, localY, pageIndex } = tableHit;
11561161

11571162
// Find the line at the local Y position within the cell paragraph
1158-
const lineIndex = findLineIndexAtY(cellMeasure, localY, 0, cellMeasure.lines.length);
1163+
const lineIndex = findLineIndexAtY(cellMeasure.lines, localY, 0, cellMeasure.lines.length);
11591164
if (lineIndex != null) {
11601165
const line = cellMeasure.lines[lineIndex];
11611166
const isRTL = isRtlBlock(cellBlock);
@@ -2151,43 +2156,43 @@ const determineColumn = (layout: Layout, fragmentX: number): number => {
21512156
};
21522157

21532158
/**
2154-
* Finds the line index at a given Y offset within a paragraph measure.
2159+
* Finds the line index at a given Y offset within a set of lines.
21552160
*
21562161
* This function searches within a specified range of lines to determine which line
21572162
* contains the given Y coordinate. It validates bounds to prevent out-of-bounds
21582163
* access in case of corrupted layout data.
21592164
*
2160-
* @param measure - The paragraph measure containing line data
2165+
* @param lines - The array of lines to search through
21612166
* @param offsetY - The Y offset in pixels to search for
21622167
* @param fromLine - The starting line index (inclusive)
21632168
* @param toLine - The ending line index (exclusive)
21642169
* @returns The line index containing the Y offset, or null if invalid
21652170
*
21662171
* @throws Never throws - returns null for invalid inputs
21672172
*/
2168-
const findLineIndexAtY = (measure: Measure, offsetY: number, fromLine: number, toLine: number): number | null => {
2169-
if (measure.kind !== 'paragraph') return null;
2173+
const findLineIndexAtY = (lines: Line[], offsetY: number, fromLine: number, toLine: number): number | null => {
2174+
if (!lines || lines.length === 0) return null;
21702175

21712176
// Validate bounds to prevent out-of-bounds access
2172-
const lineCount = measure.lines.length;
2177+
const lineCount = lines.length;
21732178
if (fromLine < 0 || toLine > lineCount || fromLine >= toLine) {
21742179
return null;
21752180
}
21762181

21772182
let cursor = 0;
2178-
// Only search within the fragment's line range
2183+
// Only search within the specified line range
21792184
for (let i = fromLine; i < toLine; i += 1) {
2180-
const line = measure.lines[i];
2185+
const line = lines[i];
21812186
// Guard against undefined lines (defensive check for corrupted data)
21822187
if (!line) return null;
21832188

21842189
const next = cursor + line.lineHeight;
21852190
if (offsetY >= cursor && offsetY < next) {
2186-
return i; // Return absolute line index within measure
2191+
return i; // Return line index within the array
21872192
}
21882193
cursor = next;
21892194
}
2190-
// If beyond all lines, return the last line in the fragment
2195+
// If beyond all lines, return the last line in the range
21912196
return toLine - 1;
21922197
};
21932198

packages/layout-engine/layout-bridge/test/clickToPosition.test.ts

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import { clickToPosition, hitTestPage, hitTestTableFragment } from '../src/index.ts';
3-
import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';
3+
import type { Layout, FlowBlock, Measure, Line, ParaFragment } from '@superdoc/contracts';
44
import {
55
simpleLayout,
66
blocks,
@@ -107,6 +107,246 @@ describe('hitTestPage with pageGap', () => {
107107
});
108108
});
109109

110+
describe('clickToPosition with fragment.lines', () => {
111+
// Tests for multi-column documents where fragments have remeasured lines
112+
// that differ from measure.lines.
113+
//
114+
// Example scenario - paragraph "Hello world" in a two-column layout:
115+
//
116+
// Original measure (full page width): Remeasured for column width:
117+
// ┌────────────────────────────────┐ ┌──────────────┐
118+
// │ Hello world │ │ Hello │ ← line 0
119+
// └────────────────────────────────┘ │ world │ ← line 1
120+
// (1 line) └──────────────┘
121+
// (2 lines)
122+
//
123+
// measure.lines = [line0] fragment.lines = [line0, line1]
124+
//
125+
// The bug: using measure.lines with fragment.fromLine/toLine indices
126+
// caused out-of-bounds access when the fragment had more lines than measure.
127+
128+
// ─────────────────────────────────────────────────────────────────────────────
129+
// REMEASURED LINES
130+
// ─────────────────────────────────────────────────────────────────────────────
131+
// These represent the line breaks after remeasuring at column width.
132+
// The paragraph "Hello world" wraps into two lines:
133+
//
134+
// remeasuredLine1: "Hello " (run 0, chars 0-5)
135+
// remeasuredLine2: "world" (run 0 char 5 → run 1 char 5)
136+
//
137+
// ┌──────────────┐
138+
// │ H e l l o │ ← remeasuredLine1 (y: 0-20)
139+
// │ w o r l d │ ← remeasuredLine2 (y: 20-40)
140+
// └──────────────┘
141+
//
142+
const remeasuredLine1: Line = {
143+
fromRun: 0,
144+
fromChar: 0,
145+
toRun: 0,
146+
toChar: 5, // "Hello" (5 chars, space trimmed)
147+
width: 100,
148+
ascent: 12,
149+
descent: 4,
150+
lineHeight: 20,
151+
};
152+
153+
const remeasuredLine2: Line = {
154+
fromRun: 0,
155+
fromChar: 5, // continues from end of line 1
156+
toRun: 1,
157+
toChar: 5, // "world" (5 chars)
158+
width: 100,
159+
ascent: 12,
160+
descent: 4,
161+
lineHeight: 20,
162+
};
163+
164+
// ─────────────────────────────────────────────────────────────────────────────
165+
// FLOW BLOCK (ProseMirror content)
166+
// ─────────────────────────────────────────────────────────────────────────────
167+
// The source paragraph content with two runs:
168+
//
169+
// run 0: "Hello " (pmStart: 1, pmEnd: 7)
170+
// run 1: "world" (pmStart: 7, pmEnd: 12)
171+
//
172+
// PM positions: 1 2 3 4 5 6 7 8 9 10 11 12
173+
// Characters: H e l l o w o r l d
174+
// └─── run 0 ───┘ └─── run 1 ───┘
175+
//
176+
const twoColumnBlock: FlowBlock = {
177+
kind: 'paragraph',
178+
id: 'two-column-para',
179+
runs: [
180+
{ text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 },
181+
{ text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 },
182+
],
183+
};
184+
185+
// ─────────────────────────────────────────────────────────────────────────────
186+
// ORIGINAL MEASURE (full page width)
187+
// ─────────────────────────────────────────────────────────────────────────────
188+
// When measured at full page width, the entire paragraph fits on one line:
189+
//
190+
// ┌────────────────────────────────────────┐
191+
// │ H e l l o w o r l d │ ← single line (y: 0-20)
192+
// └────────────────────────────────────────┘
193+
//
194+
// measure.lines.length = 1
195+
//
196+
const originalMeasure: Measure = {
197+
kind: 'paragraph',
198+
lines: [
199+
{
200+
fromRun: 0,
201+
fromChar: 0,
202+
toRun: 1,
203+
toChar: 5, // entire paragraph: "Hello world"
204+
width: 200,
205+
ascent: 12,
206+
descent: 4,
207+
lineHeight: 20,
208+
},
209+
],
210+
totalHeight: 20,
211+
};
212+
213+
// ─────────────────────────────────────────────────────────────────────────────
214+
// FRAGMENT (positioned on page, with remeasured lines)
215+
// ─────────────────────────────────────────────────────────────────────────────
216+
// This fragment is placed in column 2 of a two-column layout.
217+
// It contains `lines` array with the remeasured line breaks.
218+
//
219+
// Page layout (600px wide):
220+
//
221+
// x=0 x=290 x=310 x=600
222+
// ┌──────────┐ ┌──────────┐
223+
// │ Column 1 │ │ Column 2 │
224+
// │ │ │┌────────┐│
225+
// │ │ ││ Hello ││ ← fragment at (300, 40)
226+
// │ │ ││ world ││
227+
// │ │ │└────────┘│
228+
// └──────────┘ └──────────┘
229+
//
230+
// THE BUG: fragment.fromLine=0, fragment.toLine=2 are indices into
231+
// fragment.lines (length 2), but the old code used these to access
232+
// measure.lines (length 1), causing measure.lines[1] → undefined
233+
//
234+
const fragmentWithRemeasuredLines: ParaFragment = {
235+
kind: 'para',
236+
blockId: 'two-column-para',
237+
fromLine: 0, // index into fragment.lines (NOT measure.lines)
238+
toLine: 2, // would be out-of-bounds for measure.lines!
239+
x: 300, // positioned in column 2
240+
y: 40,
241+
width: 150,
242+
pmStart: 1,
243+
pmEnd: 12,
244+
lines: [remeasuredLine1, remeasuredLine2], // the remeasured lines for this fragment
245+
};
246+
247+
const twoColumnLayout: Layout = {
248+
pageSize: { w: 600, h: 800 },
249+
columns: { count: 2, gap: 20 },
250+
pages: [
251+
{
252+
number: 1,
253+
fragments: [fragmentWithRemeasuredLines],
254+
},
255+
],
256+
};
257+
258+
it('uses fragment.lines when available instead of measure.lines', () => {
259+
// ───────────────────────────────────────────────────────────────────────
260+
// Click in the first line of the fragment:
261+
//
262+
// Click point: (350, 50)
263+
//
264+
// Fragment at (300, 40):
265+
// y=40 ┌──────────────┐
266+
// │ Hello ← * │ click y=50 hits line 1 (y: 40-60)
267+
// y=60 │ world │
268+
// y=80 └──────────────┘
269+
// x=350
270+
//
271+
// Without the fix: TypeError because measure.lines[1] is undefined
272+
// With the fix: uses fragment.lines to find line, returns valid position
273+
// ───────────────────────────────────────────────────────────────────────
274+
const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 50 });
275+
276+
expect(result).not.toBeNull();
277+
expect(result?.blockId).toBe('two-column-para');
278+
expect(result?.pos).toBeGreaterThanOrEqual(1);
279+
expect(result?.pos).toBeLessThanOrEqual(12);
280+
});
281+
282+
it('correctly maps click position in second line of fragment with remeasured lines', () => {
283+
// ───────────────────────────────────────────────────────────────────────
284+
// Click in the second line of the fragment:
285+
//
286+
// Click point: (350, 65)
287+
//
288+
// Fragment at (300, 40):
289+
// y=40 ┌──────────────┐
290+
// │ Hello │
291+
// y=60 │ world ← * │ click y=65 hits line 2 (y: 60-80)
292+
// y=80 └──────────────┘
293+
// x=350
294+
//
295+
// This tests that we correctly index into fragment.lines[1] ("world")
296+
// ───────────────────────────────────────────────────────────────────────
297+
const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 65 });
298+
299+
expect(result).not.toBeNull();
300+
expect(result?.blockId).toBe('two-column-para');
301+
// The click should map to a position in the second line's range ("world" starts at position 7)
302+
expect(result?.pos).toBeGreaterThanOrEqual(7);
303+
expect(result?.pos).toBeLessThanOrEqual(12);
304+
});
305+
306+
it('handles fragment without lines array (uses measure.lines)', () => {
307+
// ───────────────────────────────────────────────────────────────────────
308+
// Fallback test: fragment WITHOUT remeasured lines
309+
//
310+
// When fragment.lines is absent, we fall back to measure.lines.
311+
// This is the common case for single-column layouts.
312+
//
313+
// Fragment at (30, 40), width=200 (full width, no remeasure):
314+
// y=40 ┌────────────────────────────────┐
315+
// │ Hello world ← * │ click y=50 hits line 1
316+
// y=60 └────────────────────────────────┘
317+
// x=100
318+
//
319+
// ───────────────────────────────────────────────────────────────────────
320+
const fragmentWithoutLines: ParaFragment = {
321+
kind: 'para',
322+
blockId: 'two-column-para',
323+
fromLine: 0,
324+
toLine: 1,
325+
x: 30,
326+
y: 40,
327+
width: 200,
328+
pmStart: 1,
329+
pmEnd: 12,
330+
// No `lines` property - should fall back to measure.lines
331+
};
332+
333+
const layoutWithoutFragmentLines: Layout = {
334+
pageSize: { w: 400, h: 500 },
335+
pages: [
336+
{
337+
number: 1,
338+
fragments: [fragmentWithoutLines],
339+
},
340+
],
341+
};
342+
343+
const result = clickToPosition(layoutWithoutFragmentLines, [twoColumnBlock], [originalMeasure], { x: 100, y: 50 });
344+
345+
expect(result).not.toBeNull();
346+
expect(result?.blockId).toBe('two-column-para');
347+
});
348+
});
349+
110350
describe('hitTestTableFragment with rowspan (SD-1626 / IT-22)', () => {
111351
// Table is at x:30, y:60, width:300, height:48
112352
// Row 0: y:60-84 (height 24) - has 3 cells
28.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)