|
1 | 1 | import { describe, it, expect } from 'vitest'; |
2 | 2 | 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'; |
4 | 4 | import { |
5 | 5 | simpleLayout, |
6 | 6 | blocks, |
@@ -107,6 +107,246 @@ describe('hitTestPage with pageGap', () => { |
107 | 107 | }); |
108 | 108 | }); |
109 | 109 |
|
| 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 | + |
110 | 350 | describe('hitTestTableFragment with rowspan (SD-1626 / IT-22)', () => { |
111 | 351 | // Table is at x:30, y:60, width:300, height:48 |
112 | 352 | // Row 0: y:60-84 (height 24) - has 3 cells |
|
0 commit comments