@aseity/idocs-preview 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,841 @@
1
+ import { lineCaretX, locateLineSource, } from '../core/index.js';
2
+ import { glyphOffset, glyphX } from '../core/wire.js';
3
+ import { turnedToPage } from './selection.js';
4
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
5
+ function lineVerticalBounds(line) {
6
+ const middle = line.baseline + (line.descent - line.ascent) / 2;
7
+ return {
8
+ top: middle - line.height / 2,
9
+ bottom: middle + line.height / 2,
10
+ };
11
+ }
12
+ function caretXForDisplayOffset(line, target) {
13
+ const glyphs = line.glyphs;
14
+ const count = glyphs.length;
15
+ if (count === 0)
16
+ return line.x;
17
+ for (let i = 0; i < count; i += 1) {
18
+ const gOff = glyphOffset(glyphs, i);
19
+ const gX = glyphX(glyphs, i);
20
+ if (gOff === target || (gOff > target && i === 0))
21
+ return gX;
22
+ if (gOff > target) {
23
+ const prevOff = glyphOffset(glyphs, i - 1);
24
+ const prevX = glyphX(glyphs, i - 1);
25
+ const span = Math.max(0, gOff - prevOff);
26
+ if (span === 0)
27
+ return gX;
28
+ const t = Math.max(0, target - prevOff) / span;
29
+ return prevX + (gX - prevX) * t;
30
+ }
31
+ }
32
+ return line.x + line.width;
33
+ }
34
+ function localCoords(line, x, y) {
35
+ if (!line.turn)
36
+ return [x, y];
37
+ const [ox, oy] = line.turn;
38
+ return [y - oy, ox - x];
39
+ }
40
+ function pageBoxForLine(line) {
41
+ const { top, bottom } = lineVerticalBounds(line);
42
+ if (!line.turn) {
43
+ return { left: line.x, right: line.x + line.width, top, bottom };
44
+ }
45
+ const [x1, y1] = turnedToPage(line.turn, line.x, top);
46
+ const [x2, y2] = turnedToPage(line.turn, line.x + line.width, bottom);
47
+ return {
48
+ left: Math.min(x1, x2),
49
+ right: Math.max(x1, x2),
50
+ top: Math.min(y1, y2),
51
+ bottom: Math.max(y1, y2),
52
+ };
53
+ }
54
+ function distanceToLine(placed, x, y) {
55
+ const dx = Math.max(placed.pageBox.left - x, 0, x - placed.pageBox.right);
56
+ const dy = y < placed.pageBox.top ? placed.pageBox.top - y : y >= placed.pageBox.bottom ? y - placed.pageBox.bottom + 1e-6 : 0;
57
+ return dy * 1e6 + dx;
58
+ }
59
+ function caretBoxFromX(placed, x) {
60
+ const { line, page, top, bottom } = placed;
61
+ return {
62
+ page,
63
+ x,
64
+ top,
65
+ bottom,
66
+ baseline: line.baseline,
67
+ height: line.height,
68
+ ...(line.turn ? { turn: line.turn } : {}),
69
+ };
70
+ }
71
+ function sameAnchor(a, b) {
72
+ return (a.session === b.session &&
73
+ a.flow === b.flow &&
74
+ a.offset === b.offset &&
75
+ a.affinity === b.affinity);
76
+ }
77
+ function extractLineStops(line) {
78
+ const segments = line.sourceSegments;
79
+ if (!segments || segments.length === 0)
80
+ return { flow: undefined, stops: [] };
81
+ let flow;
82
+ const stops = [];
83
+ for (const seg of segments) {
84
+ if (seg.source.kind === 'editable') {
85
+ for (const stop of seg.source.stops) {
86
+ if (!flow)
87
+ flow = stop.anchor.flow;
88
+ stops.push({
89
+ displayOffset: stop.displayOffset,
90
+ x: caretXForDisplayOffset(line, stop.displayOffset),
91
+ anchor: stop.anchor,
92
+ });
93
+ }
94
+ }
95
+ else {
96
+ if (seg.source.before) {
97
+ if (!flow)
98
+ flow = seg.source.before.flow;
99
+ stops.push({
100
+ displayOffset: seg.start,
101
+ x: caretXForDisplayOffset(line, seg.start),
102
+ anchor: seg.source.before,
103
+ });
104
+ }
105
+ if (seg.source.after) {
106
+ if (!flow)
107
+ flow = seg.source.after.flow;
108
+ stops.push({
109
+ displayOffset: seg.end,
110
+ x: caretXForDisplayOffset(line, seg.end),
111
+ anchor: seg.source.after,
112
+ });
113
+ }
114
+ }
115
+ }
116
+ stops.sort((a, b) => a.anchor.offset - b.anchor.offset || a.displayOffset - b.displayOffset);
117
+ return { flow, stops };
118
+ }
119
+ function chooseSegmentAtX(line, xPt) {
120
+ const segments = line.sourceSegments;
121
+ if (!segments || segments.length === 0)
122
+ return undefined;
123
+ let positive = [];
124
+ for (let i = 0; i < segments.length; i += 1) {
125
+ const seg = segments[i];
126
+ if (seg.start >= seg.end)
127
+ continue;
128
+ const left = caretXForDisplayOffset(line, seg.start);
129
+ const right = caretXForDisplayOffset(line, seg.end);
130
+ if (right - left > 1e-9)
131
+ positive.push({ idx: i, left, right });
132
+ }
133
+ if (positive.length === 0) {
134
+ for (let i = 0; i < segments.length; i += 1) {
135
+ const seg = segments[i];
136
+ if (seg.start < seg.end) {
137
+ positive.push({
138
+ idx: i,
139
+ left: caretXForDisplayOffset(line, seg.start),
140
+ right: caretXForDisplayOffset(line, seg.end),
141
+ });
142
+ }
143
+ }
144
+ }
145
+ if (positive.length === 0)
146
+ return segments[segments.length - 1];
147
+ if (xPt <= positive[0].left)
148
+ return segments[positive[0].idx];
149
+ if (xPt >= positive[positive.length - 1].right) {
150
+ const lastSeg = segments[segments.length - 1];
151
+ return lastSeg.start === lastSeg.end && lastSeg.source.kind === 'editable'
152
+ ? lastSeg
153
+ : segments[positive[positive.length - 1].idx];
154
+ }
155
+ let containing;
156
+ for (const item of positive) {
157
+ if (xPt >= item.left && xPt <= item.right) {
158
+ if (segments[item.idx].source.kind === 'editable')
159
+ return segments[item.idx];
160
+ if (!containing)
161
+ containing = segments[item.idx];
162
+ }
163
+ }
164
+ if (containing)
165
+ return containing;
166
+ let best = segments[positive[0].idx];
167
+ let bestDist = Infinity;
168
+ for (const item of positive) {
169
+ const dist = xPt < item.left ? item.left - xPt : Math.max(0, xPt - item.right);
170
+ if (dist < bestDist) {
171
+ bestDist = dist;
172
+ best = segments[item.idx];
173
+ }
174
+ }
175
+ return best;
176
+ }
177
+ function segmentHighlightBox(placed, seg) {
178
+ const { line, top, bottom } = placed;
179
+ let left = seg ? caretXForDisplayOffset(line, seg.start) : line.x;
180
+ let right = seg ? caretXForDisplayOffset(line, seg.end) : line.x + line.width;
181
+ if (right <= left) {
182
+ left = line.x;
183
+ right = Math.max(line.x + line.width, line.x + line.height * 0.25);
184
+ }
185
+ return {
186
+ left,
187
+ right,
188
+ top,
189
+ bottom,
190
+ ...(line.turn ? { turn: line.turn } : {}),
191
+ };
192
+ }
193
+ function buildFlowWords(lines) {
194
+ const pieces = [];
195
+ let flowText = '';
196
+ for (const placed of lines) {
197
+ const segments = placed.line.sourceSegments ?? [];
198
+ for (const seg of segments) {
199
+ if (seg.source.kind === 'editable') {
200
+ const stops = seg.source.stops;
201
+ for (let i = 0; i + 1 < stops.length; i += 1) {
202
+ const piece = placed.line.text.slice(stops[i].displayOffset, stops[i + 1].displayOffset);
203
+ if (piece.length === 0)
204
+ continue;
205
+ pieces.push({
206
+ textStart: flowText.length,
207
+ textEnd: flowText.length + piece.length,
208
+ flowStart: stops[i].anchor.offset,
209
+ flowEnd: stops[i + 1].anchor.offset,
210
+ startAnchor: stops[i].anchor,
211
+ endAnchor: stops[i + 1].anchor,
212
+ });
213
+ flowText += piece;
214
+ }
215
+ }
216
+ else if (seg.source.before && seg.source.after) {
217
+ const raw = placed.line.text.slice(seg.start, seg.end);
218
+ const piece = raw.length > 0 ? raw : '\ufffc';
219
+ pieces.push({
220
+ textStart: flowText.length,
221
+ textEnd: flowText.length + piece.length,
222
+ flowStart: seg.source.before.offset,
223
+ flowEnd: seg.source.after.offset,
224
+ startAnchor: seg.source.before,
225
+ endAnchor: seg.source.after,
226
+ });
227
+ flowText += piece;
228
+ }
229
+ }
230
+ }
231
+ if (pieces.length === 0 || flowText.length === 0)
232
+ return [];
233
+ const anchorAtTextPos = (pos) => {
234
+ for (const p of pieces) {
235
+ if (pos <= p.textStart)
236
+ return p.startAnchor;
237
+ if (pos >= p.textEnd)
238
+ continue;
239
+ const delta = pos - p.textStart;
240
+ if (p.flowEnd - p.flowStart === p.textEnd - p.textStart) {
241
+ return {
242
+ ...p.startAnchor,
243
+ offset: (p.flowStart + delta),
244
+ affinity: 'downstream',
245
+ };
246
+ }
247
+ return p.startAnchor;
248
+ }
249
+ return pieces[pieces.length - 1].endAnchor;
250
+ };
251
+ const words = [];
252
+ for (const seg of wordSegmenter.segment(flowText)) {
253
+ const startPos = seg.index;
254
+ const endPos = seg.index + seg.segment.length;
255
+ words.push({
256
+ start: anchorAtTextPos(startPos),
257
+ end: anchorAtTextPos(endPos),
258
+ isWordLike: seg.isWordLike ?? false,
259
+ });
260
+ }
261
+ return words;
262
+ }
263
+ function stopXAtOffset(stops, targetOffset) {
264
+ for (const stop of stops) {
265
+ if (stop.anchor.offset === targetOffset)
266
+ return stop.x;
267
+ }
268
+ return undefined;
269
+ }
270
+ function displayOffsetAtFlowOffset(stops, targetOffset, clampEnd) {
271
+ if (stops.length === 0)
272
+ return undefined;
273
+ if (!clampEnd) {
274
+ for (const stop of stops) {
275
+ if (stop.anchor.offset >= targetOffset)
276
+ return stop.displayOffset;
277
+ }
278
+ return stops[stops.length - 1].displayOffset;
279
+ }
280
+ let best;
281
+ for (const stop of stops) {
282
+ if (stop.anchor.offset <= targetOffset)
283
+ best = stop;
284
+ }
285
+ return best?.displayOffset ?? stops[0].displayOffset;
286
+ }
287
+ export class EditDocumentIndex {
288
+ #pages;
289
+ #editableLines = [];
290
+ #flows = new Map();
291
+ constructor(pages) {
292
+ const flowBuckets = new Map();
293
+ this.#pages = pages.map((page, pageIndex) => {
294
+ const list = [];
295
+ for (const line of page.lines) {
296
+ if (!line.sourceSegments && !line.occurrence)
297
+ continue;
298
+ const occurrenceKind = line.occurrence?.kind;
299
+ const isRepeated = occurrenceKind === 'repeatedHeader';
300
+ const isReadOnlyStory = occurrenceKind === 'readOnlyStory';
301
+ const hasEditableSegment = line.sourceSegments?.some((seg) => seg.source.kind === 'editable') ?? false;
302
+ const primary = !isRepeated && !isReadOnlyStory && hasEditableSegment;
303
+ const { top, bottom } = lineVerticalBounds(line);
304
+ const { flow, stops } = primary ? extractLineStops(line) : { flow: undefined, stops: [] };
305
+ const indexed = {
306
+ line,
307
+ page: pageIndex,
308
+ flow,
309
+ top,
310
+ bottom,
311
+ pageBox: pageBoxForLine(line),
312
+ stops,
313
+ };
314
+ list.push(indexed);
315
+ if (primary && flow !== undefined && stops.length > 0) {
316
+ this.#editableLines.push(indexed);
317
+ let bucket = flowBuckets.get(flow);
318
+ if (!bucket) {
319
+ bucket = { path: line.path, lines: [] };
320
+ flowBuckets.set(flow, bucket);
321
+ }
322
+ bucket.lines.push(indexed);
323
+ }
324
+ }
325
+ return list;
326
+ });
327
+ for (const [id, bucket] of flowBuckets) {
328
+ const seenOffsets = new Map();
329
+ const orderedStops = [];
330
+ for (const placed of bucket.lines) {
331
+ for (const stop of placed.stops) {
332
+ if (!seenOffsets.has(stop.anchor.offset)) {
333
+ seenOffsets.set(stop.anchor.offset, stop.anchor);
334
+ orderedStops.push(stop.anchor);
335
+ }
336
+ }
337
+ }
338
+ orderedStops.sort((a, b) => a.offset - b.offset);
339
+ const stopIndexByOffset = new Map();
340
+ orderedStops.forEach((anchor, i) => stopIndexByOffset.set(anchor.offset, i));
341
+ this.#flows.set(id, {
342
+ path: bucket.path,
343
+ lines: bucket.lines,
344
+ stops: orderedStops,
345
+ stopIndexByOffset,
346
+ words: buildFlowWords(bucket.lines),
347
+ });
348
+ }
349
+ }
350
+ flowPath(flow) {
351
+ return this.#flows.get(flow)?.path;
352
+ }
353
+ firstFlow() {
354
+ return this.#editableLines[0]?.flow;
355
+ }
356
+ flowBounds(flow) {
357
+ const entry = this.#flows.get(flow);
358
+ if (!entry || entry.stops.length === 0)
359
+ return undefined;
360
+ return {
361
+ start: entry.stops[0],
362
+ end: entry.stops[entry.stops.length - 1],
363
+ };
364
+ }
365
+ locatePoint(point) {
366
+ const lines = this.#pages[point.page];
367
+ if (!lines || lines.length === 0)
368
+ return { kind: 'empty' };
369
+ let best = lines[0];
370
+ let bestDist = Infinity;
371
+ for (const candidate of lines) {
372
+ const d = distanceToLine(candidate, point.x, point.y);
373
+ if (d < bestDist) {
374
+ best = candidate;
375
+ bestDist = d;
376
+ }
377
+ }
378
+ const occ = best.line.occurrence;
379
+ if (occ?.kind === 'repeatedHeader') {
380
+ return {
381
+ kind: 'readOnly',
382
+ target: {
383
+ page: point.page,
384
+ path: occ.canonicalPath || best.line.path,
385
+ reason: 'repeatedHeader',
386
+ box: segmentHighlightBox(best, undefined),
387
+ },
388
+ };
389
+ }
390
+ if (occ?.kind === 'readOnlyStory') {
391
+ return {
392
+ kind: 'readOnly',
393
+ target: {
394
+ page: point.page,
395
+ path: best.line.path,
396
+ reason: 'unsupportedStory',
397
+ box: segmentHighlightBox(best, undefined),
398
+ },
399
+ };
400
+ }
401
+ const [localX] = localCoords(best.line, point.x, point.y);
402
+ const hit = locateLineSource(best.line, localX);
403
+ if (hit.kind === 'editable') {
404
+ return {
405
+ kind: 'editable',
406
+ anchor: hit.anchor,
407
+ caret: caretBoxFromX(best, hit.x),
408
+ page: point.page,
409
+ path: best.line.path,
410
+ };
411
+ }
412
+ if (hit.kind === 'atomic') {
413
+ const seg = chooseSegmentAtX(best.line, localX);
414
+ const segPath = seg?.source.kind === 'atomic' && seg.source.path ? seg.source.path : best.line.path;
415
+ return {
416
+ kind: 'readOnly',
417
+ target: {
418
+ page: point.page,
419
+ path: segPath,
420
+ reason: hit.reason,
421
+ ...(hit.role !== undefined ? { role: hit.role } : {}),
422
+ box: segmentHighlightBox(best, seg),
423
+ ...(hit.before ? { before: hit.before } : {}),
424
+ ...(hit.after ? { after: hit.after } : {}),
425
+ },
426
+ };
427
+ }
428
+ return { kind: 'empty' };
429
+ }
430
+ locatePointInFlow(flow, point) {
431
+ const entry = this.#flows.get(flow);
432
+ if (!entry || entry.lines.length === 0 || entry.stops.length === 0)
433
+ return undefined;
434
+ const firstLine = entry.lines[0];
435
+ const lastLine = entry.lines[entry.lines.length - 1];
436
+ if (point.page < firstLine.page)
437
+ return entry.stops[0];
438
+ if (point.page > lastLine.page)
439
+ return entry.stops[entry.stops.length - 1];
440
+ const pageLines = entry.lines.filter((line) => line.page === point.page);
441
+ if (pageLines.length === 0) {
442
+ return point.page <= firstLine.page ? entry.stops[0] : entry.stops[entry.stops.length - 1];
443
+ }
444
+ const minTop = Math.min(...pageLines.map((l) => l.pageBox.top));
445
+ const maxBottom = Math.max(...pageLines.map((l) => l.pageBox.bottom));
446
+ if (point.y < minTop) {
447
+ return pageLines[0].stops[0]?.anchor ?? entry.stops[0];
448
+ }
449
+ if (point.y >= maxBottom) {
450
+ const lastOnPage = pageLines[pageLines.length - 1];
451
+ return lastOnPage.stops[lastOnPage.stops.length - 1]?.anchor ?? entry.stops[entry.stops.length - 1];
452
+ }
453
+ let best = pageLines[0];
454
+ let bestDist = Infinity;
455
+ for (const candidate of pageLines) {
456
+ const d = distanceToLine(candidate, point.x, point.y);
457
+ if (d < bestDist) {
458
+ best = candidate;
459
+ bestDist = d;
460
+ }
461
+ }
462
+ const [localX] = localCoords(best.line, point.x, point.y);
463
+ return this.#closestStopOnLine(best, localX)?.anchor;
464
+ }
465
+ wordRangeInFlow(flow, point) {
466
+ const entry = this.#flows.get(flow);
467
+ if (!entry || entry.words.length === 0)
468
+ return undefined;
469
+ const anchor = this.locatePointInFlow(flow, point);
470
+ if (!anchor)
471
+ return undefined;
472
+ let probeOffset = anchor.offset;
473
+ const line = this.#findLineForAnchor(anchor);
474
+ if (line) {
475
+ const [localX] = localCoords(line.line, point.x, point.y);
476
+ const caret = this.caretForAnchor(anchor);
477
+ if (caret && localX < caret.x) {
478
+ const prev = this.neighborStop(anchor, 'backward');
479
+ if (prev)
480
+ probeOffset = prev.offset;
481
+ }
482
+ }
483
+ if (probeOffset <= entry.words[0].start.offset) {
484
+ return { start: entry.words[0].start, end: entry.words[0].end };
485
+ }
486
+ for (const word of entry.words) {
487
+ if (probeOffset >= word.start.offset && probeOffset < word.end.offset) {
488
+ return { start: word.start, end: word.end };
489
+ }
490
+ }
491
+ const last = entry.words[entry.words.length - 1];
492
+ return { start: last.start, end: last.end };
493
+ }
494
+ selectionPageBounds(range) {
495
+ if (range.start.flow !== range.end.flow || range.start.offset >= range.end.offset)
496
+ return undefined;
497
+ const entry = this.#flows.get(range.start.flow);
498
+ if (!entry)
499
+ return undefined;
500
+ let targetPage;
501
+ let minLeft = Infinity;
502
+ let maxRight = -Infinity;
503
+ let minTop = Infinity;
504
+ let maxBottom = -Infinity;
505
+ for (const placed of entry.lines) {
506
+ if (placed.stops.length === 0)
507
+ continue;
508
+ if (targetPage !== undefined && placed.page !== targetPage)
509
+ continue;
510
+ const minOffset = placed.stops[0].anchor.offset;
511
+ const maxOffset = placed.stops[placed.stops.length - 1].anchor.offset;
512
+ const from = Math.max(range.start.offset, minOffset);
513
+ const to = Math.min(range.end.offset, maxOffset);
514
+ if (from >= to)
515
+ continue;
516
+ const left = stopXAtOffset(placed.stops, from);
517
+ const right = stopXAtOffset(placed.stops, to);
518
+ if (left === undefined || right === undefined)
519
+ continue;
520
+ const boxRight = right > left ? right : left + placed.line.height * 0.25;
521
+ targetPage = placed.page;
522
+ if (!placed.line.turn) {
523
+ minLeft = Math.min(minLeft, left);
524
+ maxRight = Math.max(maxRight, boxRight);
525
+ minTop = Math.min(minTop, placed.top);
526
+ maxBottom = Math.max(maxBottom, placed.bottom);
527
+ }
528
+ else {
529
+ const [x1, y1] = turnedToPage(placed.line.turn, left, placed.top);
530
+ const [x2, y2] = turnedToPage(placed.line.turn, boxRight, placed.bottom);
531
+ minLeft = Math.min(minLeft, x1, x2);
532
+ maxRight = Math.max(maxRight, x1, x2);
533
+ minTop = Math.min(minTop, y1, y2);
534
+ maxBottom = Math.max(maxBottom, y1, y2);
535
+ }
536
+ }
537
+ if (targetPage === undefined)
538
+ return undefined;
539
+ return {
540
+ page: targetPage,
541
+ bounds: { left: minLeft, right: maxRight, top: minTop, bottom: maxBottom },
542
+ };
543
+ }
544
+ caretForAnchor(anchor) {
545
+ const entry = this.#flows.get(anchor.flow);
546
+ if (!entry)
547
+ return undefined;
548
+ for (const placed of entry.lines) {
549
+ const x = lineCaretX(placed.line, anchor);
550
+ if (x !== null)
551
+ return caretBoxFromX(placed, x);
552
+ }
553
+ for (const placed of entry.lines) {
554
+ for (const seg of placed.line.sourceSegments ?? []) {
555
+ if (seg.source.kind !== 'atomic')
556
+ continue;
557
+ if (seg.source.before &&
558
+ seg.source.before.session === anchor.session &&
559
+ seg.source.before.flow === anchor.flow &&
560
+ seg.source.before.offset === anchor.offset) {
561
+ return caretBoxFromX(placed, caretXForDisplayOffset(placed.line, seg.start));
562
+ }
563
+ if (seg.source.after &&
564
+ seg.source.after.session === anchor.session &&
565
+ seg.source.after.flow === anchor.flow &&
566
+ seg.source.after.offset === anchor.offset) {
567
+ return caretBoxFromX(placed, caretXForDisplayOffset(placed.line, seg.end));
568
+ }
569
+ }
570
+ }
571
+ return undefined;
572
+ }
573
+ highlightsForRange(range, page) {
574
+ if (range.start.flow !== range.end.flow || range.start.offset >= range.end.offset)
575
+ return [];
576
+ const entry = this.#flows.get(range.start.flow);
577
+ if (!entry)
578
+ return [];
579
+ const boxes = [];
580
+ for (const placed of entry.lines) {
581
+ if (placed.page !== page || placed.stops.length === 0)
582
+ continue;
583
+ const minOffset = placed.stops[0].anchor.offset;
584
+ const maxOffset = placed.stops[placed.stops.length - 1].anchor.offset;
585
+ const from = Math.max(range.start.offset, minOffset);
586
+ const to = Math.min(range.end.offset, maxOffset);
587
+ if (from >= to)
588
+ continue;
589
+ const left = stopXAtOffset(placed.stops, from);
590
+ const right = stopXAtOffset(placed.stops, to);
591
+ if (left === undefined || right === undefined)
592
+ continue;
593
+ const boxRight = right > left ? right : left + placed.line.height * 0.25;
594
+ boxes.push({
595
+ left,
596
+ right: boxRight,
597
+ top: placed.top,
598
+ bottom: placed.bottom,
599
+ ...(placed.line.turn ? { turn: placed.line.turn } : {}),
600
+ });
601
+ }
602
+ return boxes;
603
+ }
604
+ selectedText(range) {
605
+ if (range.start.flow !== range.end.flow || range.start.offset >= range.end.offset)
606
+ return '';
607
+ const entry = this.#flows.get(range.start.flow);
608
+ if (!entry)
609
+ return '';
610
+ let text = '';
611
+ for (const placed of entry.lines) {
612
+ for (const seg of placed.line.sourceSegments ?? []) {
613
+ if (seg.source.kind === 'editable') {
614
+ const stops = seg.source.stops;
615
+ if (stops.length === 0)
616
+ continue;
617
+ const segStart = stops[0].anchor.offset;
618
+ const segEnd = stops[stops.length - 1].anchor.offset;
619
+ if (segEnd <= range.start.offset || segStart >= range.end.offset)
620
+ continue;
621
+ const from = Math.max(range.start.offset, segStart);
622
+ const to = Math.min(range.end.offset, segEnd);
623
+ if (from >= to)
624
+ continue;
625
+ const dStart = displayOffsetAtFlowOffset(stops, from, false);
626
+ const dEnd = displayOffsetAtFlowOffset(stops, to, true);
627
+ if (dStart !== undefined && dEnd !== undefined && dEnd > dStart) {
628
+ text += placed.line.text.slice(dStart, dEnd);
629
+ }
630
+ }
631
+ else if (seg.source.before && seg.source.after) {
632
+ if (seg.source.before.offset >= range.start.offset && seg.source.after.offset <= range.end.offset) {
633
+ text += placed.line.text.slice(seg.start, seg.end);
634
+ }
635
+ }
636
+ }
637
+ }
638
+ return text.replaceAll('\ufffc', '');
639
+ }
640
+ neighborStop(anchor, direction) {
641
+ const entry = this.#flows.get(anchor.flow);
642
+ if (!entry || entry.stops.length === 0)
643
+ return undefined;
644
+ const idx = entry.stopIndexByOffset.get(anchor.offset);
645
+ if (idx === undefined)
646
+ return undefined;
647
+ const nextIdx = direction === 'forward' ? idx + 1 : idx - 1;
648
+ if (nextIdx < 0 || nextIdx >= entry.stops.length)
649
+ return undefined;
650
+ return entry.stops[nextIdx];
651
+ }
652
+ moveHorizontal(anchor, direction, granularity = 'character') {
653
+ const entry = this.#flows.get(anchor.flow);
654
+ if (!entry || entry.stops.length === 0)
655
+ return anchor;
656
+ if (granularity === 'word') {
657
+ if (entry.words.length === 0)
658
+ return anchor;
659
+ if (direction === 'forward') {
660
+ for (const word of entry.words) {
661
+ if (word.isWordLike && word.end.offset > anchor.offset)
662
+ return word.end;
663
+ }
664
+ return entry.stops[entry.stops.length - 1];
665
+ }
666
+ for (let i = entry.words.length - 1; i >= 0; i -= 1) {
667
+ const word = entry.words[i];
668
+ if (word.isWordLike && word.start.offset < anchor.offset)
669
+ return word.start;
670
+ }
671
+ return entry.stops[0];
672
+ }
673
+ const line = this.#findLineForAnchor(anchor);
674
+ if (direction === 'forward') {
675
+ const next = this.neighborStop(anchor, 'forward');
676
+ if (!next)
677
+ return anchor;
678
+ if (line) {
679
+ const lastOnLine = line.stops[line.stops.length - 1]?.anchor;
680
+ if (lastOnLine && lastOnLine.offset === next.offset && lastOnLine.affinity === 'upstream') {
681
+ return lastOnLine;
682
+ }
683
+ }
684
+ return next;
685
+ }
686
+ const prev = this.neighborStop(anchor, 'backward');
687
+ if (!prev)
688
+ return anchor;
689
+ if (line) {
690
+ const firstOnLine = line.stops[0]?.anchor;
691
+ if (firstOnLine && firstOnLine.offset === prev.offset)
692
+ return firstOnLine;
693
+ }
694
+ return prev;
695
+ }
696
+ moveVertical(anchor, direction, preferredX) {
697
+ const caret = this.caretForAnchor(anchor);
698
+ const currentLine = this.#findLineForAnchor(anchor);
699
+ if (!caret || !currentLine) {
700
+ return { anchor, preferredX: preferredX ?? 0 };
701
+ }
702
+ const [caretPageX, caretPageY] = caret.turn
703
+ ? turnedToPage(caret.turn, caret.x, (caret.top + caret.bottom) / 2)
704
+ : [caret.x, (caret.top + caret.bottom) / 2];
705
+ const targetX = preferredX ?? caretPageX;
706
+ const targetLine = this.#findVerticalLine(currentLine, caretPageY, targetX, direction);
707
+ if (!targetLine)
708
+ return { anchor, preferredX: targetX };
709
+ const targetCenterY = (targetLine.pageBox.top + targetLine.pageBox.bottom) / 2;
710
+ const [localX] = localCoords(targetLine.line, targetX, targetCenterY);
711
+ const bestStop = this.#closestStopOnLine(targetLine, localX);
712
+ return {
713
+ anchor: bestStop ? bestStop.anchor : anchor,
714
+ preferredX: targetX,
715
+ };
716
+ }
717
+ moveLineBoundary(anchor, edge) {
718
+ const line = this.#findLineForAnchor(anchor);
719
+ if (!line || line.stops.length === 0)
720
+ return anchor;
721
+ return edge === 'start' ? line.stops[0].anchor : line.stops[line.stops.length - 1].anchor;
722
+ }
723
+ wordRangeAt(point) {
724
+ const hit = this.locatePoint(point);
725
+ if (hit.kind !== 'editable')
726
+ return undefined;
727
+ const entry = this.#flows.get(hit.anchor.flow);
728
+ if (!entry || entry.words.length === 0)
729
+ return undefined;
730
+ const line = this.#findLineForAnchor(hit.anchor);
731
+ let probeOffset = hit.anchor.offset;
732
+ if (line) {
733
+ const [localX] = localCoords(line.line, point.x, point.y);
734
+ if (localX < hit.caret.x) {
735
+ const prev = this.neighborStop(hit.anchor, 'backward');
736
+ if (prev)
737
+ probeOffset = prev.offset;
738
+ }
739
+ }
740
+ for (const word of entry.words) {
741
+ if (probeOffset >= word.start.offset && probeOffset < word.end.offset) {
742
+ return { start: word.start, end: word.end };
743
+ }
744
+ }
745
+ const last = entry.words[entry.words.length - 1];
746
+ return { start: last.start, end: last.end };
747
+ }
748
+ flowRangeAt(point) {
749
+ const hit = this.locatePoint(point);
750
+ if (hit.kind !== 'editable')
751
+ return undefined;
752
+ return this.flowBounds(hit.anchor.flow);
753
+ }
754
+ #findLineForAnchor(anchor) {
755
+ const entry = this.#flows.get(anchor.flow);
756
+ if (!entry)
757
+ return undefined;
758
+ for (const placed of entry.lines) {
759
+ if (placed.stops.some((stop) => sameAnchor(stop.anchor, anchor))) {
760
+ return placed;
761
+ }
762
+ }
763
+ for (const placed of entry.lines) {
764
+ if (lineCaretX(placed.line, anchor) !== null)
765
+ return placed;
766
+ }
767
+ for (const placed of entry.lines) {
768
+ if (placed.stops.some((stop) => stop.anchor.offset === anchor.offset))
769
+ return placed;
770
+ }
771
+ return undefined;
772
+ }
773
+ #findVerticalLine(current, currentY, targetX, direction) {
774
+ const step = direction === 'down' ? 1 : -1;
775
+ const samePageCandidates = this.#editableLines.filter((cand) => {
776
+ if (cand.page !== current.page || cand === current)
777
+ return false;
778
+ const cy = (cand.pageBox.top + cand.pageBox.bottom) / 2;
779
+ if (direction === 'down') {
780
+ return cy > currentY + 1e-3 && cand.pageBox.top >= current.pageBox.top + 1e-3;
781
+ }
782
+ return cy < currentY - 1e-3 && cand.pageBox.bottom <= current.pageBox.bottom - 1e-3;
783
+ });
784
+ if (samePageCandidates.length > 0) {
785
+ let best = samePageCandidates[0];
786
+ let bestScore = Infinity;
787
+ for (const cand of samePageCandidates) {
788
+ const cy = (cand.pageBox.top + cand.pageBox.bottom) / 2;
789
+ const dy = Math.abs(cy - currentY);
790
+ const dx = Math.max(cand.pageBox.left - targetX, 0, targetX - cand.pageBox.right);
791
+ const score = dy * 1e6 + dx;
792
+ if (score < bestScore) {
793
+ bestScore = score;
794
+ best = cand;
795
+ }
796
+ }
797
+ return best;
798
+ }
799
+ for (let p = current.page + step; p >= 0 && p < this.#pages.length; p += step) {
800
+ const pageCandidates = this.#editableLines.filter((cand) => cand.page === p);
801
+ if (pageCandidates.length === 0)
802
+ continue;
803
+ const edgeY = direction === 'down'
804
+ ? Math.min(...pageCandidates.map((c) => (c.pageBox.top + c.pageBox.bottom) / 2))
805
+ : Math.max(...pageCandidates.map((c) => (c.pageBox.top + c.pageBox.bottom) / 2));
806
+ let best = pageCandidates[0];
807
+ let bestScore = Infinity;
808
+ for (const cand of pageCandidates) {
809
+ const cy = (cand.pageBox.top + cand.pageBox.bottom) / 2;
810
+ const dy = Math.abs(cy - edgeY);
811
+ const dx = Math.max(cand.pageBox.left - targetX, 0, targetX - cand.pageBox.right);
812
+ const score = dy * 1e6 + dx;
813
+ if (score < bestScore) {
814
+ bestScore = score;
815
+ best = cand;
816
+ }
817
+ }
818
+ return best;
819
+ }
820
+ return undefined;
821
+ }
822
+ #closestStopOnLine(placed, localX) {
823
+ const stops = placed.stops;
824
+ if (stops.length === 0)
825
+ return undefined;
826
+ let best = stops[0];
827
+ let bestDist = Math.abs(localX - best.x);
828
+ for (let i = 1; i < stops.length; i += 1) {
829
+ const stop = stops[i];
830
+ const dist = Math.abs(localX - stop.x);
831
+ if (dist < bestDist - 1e-9 || (Math.abs(dist - bestDist) <= 1e-9 && localX >= stop.x)) {
832
+ best = stop;
833
+ bestDist = dist;
834
+ }
835
+ }
836
+ return best;
837
+ }
838
+ }
839
+ export function orderedAnchors(a, b) {
840
+ return a.offset <= b.offset ? { start: a, end: b, reversed: false } : { start: b, end: a, reversed: true };
841
+ }