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