@embedpdf/plugin-selection 3.0.0-next.14 → 3.0.0-next.15

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.
Files changed (41) hide show
  1. package/dist/contract/host.cjs +16 -2
  2. package/dist/contract/host.cjs.map +1 -0
  3. package/dist/contract/host.d.cts +3 -2
  4. package/dist/contract/host.d.ts +3 -2
  5. package/dist/contract/host.js +16 -2
  6. package/dist/contract/host.js.map +1 -0
  7. package/dist/contract-BUiw1d8F.cjs +36 -0
  8. package/dist/contract-BUiw1d8F.cjs.map +1 -0
  9. package/dist/contract-DBXA64ep.js +26 -0
  10. package/dist/contract-DBXA64ep.js.map +1 -0
  11. package/dist/contract-DLs4vXVt.d.cts +252 -0
  12. package/dist/contract-DLs4vXVt.d.ts +252 -0
  13. package/dist/contract.cjs +3 -6
  14. package/dist/contract.d.cts +2 -7
  15. package/dist/contract.d.ts +2 -7
  16. package/dist/contract.js +2 -7
  17. package/dist/host-contract-DO2aLU2e.d.ts +34 -0
  18. package/dist/host-contract-Ddo4PcJJ.d.cts +34 -0
  19. package/dist/index.cjs +777 -51
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +97 -29
  22. package/dist/index.d.ts +97 -29
  23. package/dist/index.js +772 -51
  24. package/dist/index.js.map +1 -1
  25. package/package.json +5 -9
  26. package/dist/contract.cjs.map +0 -1
  27. package/dist/contract.js.map +0 -1
  28. package/dist/internal.cjs +0 -13
  29. package/dist/internal.d.cts +0 -63
  30. package/dist/internal.d.ts +0 -63
  31. package/dist/internal.js +0 -4
  32. package/dist/reducer-BAOQ2CJ8.cjs +0 -732
  33. package/dist/reducer-BAOQ2CJ8.cjs.map +0 -1
  34. package/dist/reducer-DbKtk9Xl.js +0 -685
  35. package/dist/reducer-DbKtk9Xl.js.map +0 -1
  36. package/dist/types-BVUcOsCh.js +0 -14
  37. package/dist/types-BVUcOsCh.js.map +0 -1
  38. package/dist/types-CUrUVl6K.d.cts +0 -292
  39. package/dist/types-CUrUVl6K.d.ts +0 -292
  40. package/dist/types-D9kEBMFj.cjs +0 -18
  41. package/dist/types-D9kEBMFj.cjs.map +0 -1
package/dist/index.js CHANGED
@@ -1,77 +1,784 @@
1
- import { i as createSelectionCapability, n as selectionReducer, r as createTextSelectHandler, t as initialSelectionState } from "./reducer-DbKtk9Xl.js";
2
- import { t as SelectionToken$1 } from "./types-BVUcOsCh.js";
3
- import { SelectionToken } from "./contract.js";
4
- import { definePlugin } from "@embedpdf/core";
5
- import { FeedbackToken, InteractionToken } from "@embedpdf/plugin-interaction/contract";
6
- import { textQuadEdge } from "@embedpdf/core-geometry";
7
- //#region src/selection.plugin.ts
1
+ import { n as SelectionToken, t as SELECTION_DEFAULTS } from "./contract-DBXA64ep.js";
2
+ import { SelectionToken as SelectionToken$1 } from "./contract/host.js";
3
+ import { PluginError, definePlugin, defineState, memo, memoByKey } from "@embedpdf/core";
4
+ import { FeedbackToken, InteractionToken } from "@embedpdf/plugin-interaction/contract/host";
5
+ import { boundsOfRects, quadBounds, quadEdge } from "@embedpdf/core-geometry";
6
+ import { sliceText, toPageRef } from "@embedpdf/engine-core/runtime";
7
+ //#region src/handler.ts
8
+ const CURSOR_TOKEN = "selection-text";
8
9
  /**
9
- * Text selection — document-scoped, requires the interaction hub. In `init` it
10
- * registers ITS pointer handler with the hub; the hub owns the pointer stream
11
- * and arbitration. Works with `<Stage>` or a standalone `<PageView>` — selection
12
- * only needs the page coordinate context + the engine's text geometry.
10
+ * The text-selection pointer handler. It is live only under tools that enable the
11
+ * `'text-select'` tag (the built-in `pointer` tool does; `pan` does not — so
12
+ * switching to pan disables text selection with zero special-casing here).
13
13
  *
14
- * Registry/content invalidation is wired inside the capability itself
15
- * (revision watch + document event subscription), so headless/programmatic
16
- * use gets the same reconciliation as the full viewer.
14
+ * - single click → deselect; records an anchor but selects nothing until
15
+ * the pointer moves past the drag threshold (Chrome/Acrobat feel)
16
+ * - single-click + drag → caret selection from the anchor (extends across pages)
17
+ * - double-click → select the word
18
+ * - triple-click → select the visual line
19
+ * - down off-text → deselect, and don't capture (so it can't block)
20
+ * - hover → I-beam only when over text, else the pointer cursor
21
+ */
22
+ function createTextSelectHandler(selection, interaction, feedback, options = {}) {
23
+ const dragThreshold = options.dragThreshold ?? (() => SELECTION_DEFAULTS.dragThreshold);
24
+ let anchor = null;
25
+ let dragging = false;
26
+ return {
27
+ id: "text-select",
28
+ priority: 60,
29
+ enabledFor: (tool) => tool.enables.has("text-select"),
30
+ onDown: (sample) => {
31
+ anchor = null;
32
+ dragging = false;
33
+ if (!sample.page) return false;
34
+ if (!selection.canSelect()) return false;
35
+ const { ref: page, point } = sample.page;
36
+ const clicks = sample.clickCount ?? 1;
37
+ selection.beginGesture();
38
+ if (clicks >= 3) {
39
+ selection.selectLineAt(page, point);
40
+ return true;
41
+ }
42
+ if (clicks === 2) {
43
+ if (selection.selectWordAt(page, point) && sample.gesture === "long-press") feedback?.selection();
44
+ return true;
45
+ }
46
+ selection.clear();
47
+ if (!selection.isOverText(page, point)) {
48
+ selection.endGesture();
49
+ return false;
50
+ }
51
+ anchor = {
52
+ page,
53
+ point,
54
+ viewportX: sample.viewport.x,
55
+ viewportY: sample.viewport.y
56
+ };
57
+ return true;
58
+ },
59
+ onMove: (sample) => {
60
+ if (!sample.page || !anchor) return;
61
+ if (!dragging) {
62
+ if (Math.hypot(sample.viewport.x - anchor.viewportX, sample.viewport.y - anchor.viewportY) < dragThreshold()) return;
63
+ dragging = true;
64
+ selection.beginGestureAt(anchor.page, anchor.point);
65
+ }
66
+ selection.extendTo(sample.page.ref, sample.page.point);
67
+ },
68
+ onUp: () => {
69
+ anchor = null;
70
+ dragging = false;
71
+ selection.endGesture();
72
+ },
73
+ onCancel: () => {
74
+ anchor = null;
75
+ dragging = false;
76
+ selection.clear();
77
+ selection.endGesture();
78
+ },
79
+ onHover: (sample) => {
80
+ if (!sample.page) {
81
+ interaction.claimCursor(CURSOR_TOKEN, null);
82
+ return;
83
+ }
84
+ selection.ensureLoaded(sample.page.ref);
85
+ const overText = selection.isOverText(sample.page.ref, sample.page.point);
86
+ interaction.claimCursor(CURSOR_TOKEN, overText ? "text" : null, 10);
87
+ }
88
+ };
89
+ }
90
+ //#endregion
91
+ //#region src/connect.ts
92
+ /**
93
+ * Register the text-select pointer handler with the interaction hub, which
94
+ * owns the pointer stream and arbitration. Platform feedback (haptics) is
95
+ * optional: without it (headless setups) the handler never buzzes. The
96
+ * handler reads the drag threshold at each press, so a settings change applies
97
+ * to the next gesture.
98
+ */
99
+ function connectSelection(ctx, selection) {
100
+ const interaction = ctx.get(InteractionToken);
101
+ const feedback = ctx.tryGet(FeedbackToken) ?? void 0;
102
+ const settings = ctx.settings();
103
+ ctx.cleanup(interaction.registerHandler(createTextSelectHandler(selection, interaction, feedback, { dragThreshold: () => settings.get().dragThreshold })));
104
+ }
105
+ //#endregion
106
+ //#region src/model.ts
107
+ const initialSelectionState = () => ({
108
+ selection: null,
109
+ segments: {},
110
+ highlightHidden: false,
111
+ selecting: false
112
+ });
113
+ const samePosition = (left, right) => left.glyph === right.glyph && left.page.objectNumber === right.page.objectNumber;
114
+ const sameRange = (left, right) => left === right || left !== null && right !== null && samePosition(left.anchor, right.anchor) && samePosition(left.focus, right.focus);
115
+ const sameQuad = (left, right) => [
116
+ "upperLeft",
117
+ "upperRight",
118
+ "lowerLeft",
119
+ "lowerRight"
120
+ ].every((corner) => left[corner].x === right[corner].x && left[corner].y === right[corner].y);
121
+ const sameSegments = (left, right) => left === right || left.length === right.length && left.every((segment, index) => segment.advance === right[index].advance && sameQuad(segment.quad, right[index].quad));
122
+ /**
123
+ * Replace the range and its segments. A page whose segments are unchanged
124
+ * keeps its previous array, so per-page reads stay reference-stable, and a
125
+ * recompute that changes nothing returns the same state.
126
+ */
127
+ function setSelection(state, selection, segments) {
128
+ const pages = Object.keys(segments).map(Number);
129
+ let segmentsChanged = pages.length !== Object.keys(state.segments).length;
130
+ const merged = {};
131
+ for (const pageObjectNumber of pages) {
132
+ const previous = state.segments[pageObjectNumber];
133
+ const next = segments[pageObjectNumber];
134
+ if (previous && sameSegments(previous, next)) merged[pageObjectNumber] = previous;
135
+ else {
136
+ merged[pageObjectNumber] = next;
137
+ segmentsChanged = true;
138
+ }
139
+ }
140
+ const rangeChanged = !sameRange(state.selection, selection);
141
+ if (!rangeChanged && !segmentsChanged) return state;
142
+ return {
143
+ ...state,
144
+ selection: rangeChanged ? selection : state.selection,
145
+ segments: segmentsChanged ? merged : state.segments
146
+ };
147
+ }
148
+ function clearSelection(state) {
149
+ return state.selection === null && Object.keys(state.segments).length === 0 ? state : {
150
+ ...state,
151
+ selection: null,
152
+ segments: {}
153
+ };
154
+ }
155
+ const setHighlightHidden = (state, hidden) => state.highlightHidden === hidden ? state : {
156
+ ...state,
157
+ highlightHidden: hidden
158
+ };
159
+ const setSelecting = (state, selecting) => state.selecting === selecting ? state : {
160
+ ...state,
161
+ selecting
162
+ };
163
+ /**
164
+ * The pages whose content (and so character space) a confirmed event
165
+ * changed: applied redactions, flattened pages, and pages an annotation
166
+ * flatten painted into (a free-text note becomes page text). Null for every
167
+ * other event.
168
+ */
169
+ function contentChangedPagesOf(event) {
170
+ if (event.type === "annotations.flattened") return event.results.some((result) => result.status === "applied") ? [event.page] : null;
171
+ if (event.type !== "redaction.applied" && event.type !== "pages.flattened") return null;
172
+ const pages = event.results.filter((result) => result.status === "applied").map((result) => result.page);
173
+ return pages.length > 0 ? pages : null;
174
+ }
175
+ //#endregion
176
+ //#region src/controller.ts
177
+ const SELECT_SCOPE = "doc.text.select";
178
+ const COPY_SCOPE = "doc.text.copy";
179
+ const EMPTY_SEGMENTS = Object.freeze([]);
180
+ const EMPTY_PAGES = Object.freeze([]);
181
+ const EMPTY_RECTS = Object.freeze([]);
182
+ /** How many page-text reads a `readText` keeps in flight at once. */
183
+ const TEXT_READ_CONCURRENCY = 8;
184
+ /** An open-ended focus: settles to the page's real last character once its geometry loads. */
185
+ const OPEN_END = Number.MAX_SAFE_INTEGER;
186
+ /**
187
+ * The selection controller.
188
+ *
189
+ * State holds the range and its derived page-space segments. Engine data
190
+ * lives outside state:
191
+ *
192
+ * - text geometry is a page mirror: PDF-space snapshots loaded on demand,
193
+ * re-read when a confirmed event changes a page's content (redaction
194
+ * apply, flatten) and dropped when a page is deleted. Page-space
195
+ * geometry is derived from a snapshot and the page's current layout, so
196
+ * a view rotation re-derives without asking the engine again.
197
+ * - `textSnapshots` holds per-page text promises for `readText`, dropped
198
+ * on the same content changes and when a page leaves the registry.
199
+ *
200
+ * `recompute` rebuilds the segments of every loaded page in the span and
201
+ * warms the rest, which recompute when their geometry arrives. The change
202
+ * and clear events are derived from state changes in one place.
203
+ *
204
+ * Selection is cross-page: glyphs are ordered globally by (page index,
205
+ * glyph), so a drag from page 2 into page 4 selects the tail of 2, all of 3,
206
+ * and the head of 4. Stored endpoints are clamped once their page's geometry
207
+ * is known, which is how `selectAll`'s open end settles.
208
+ *
209
+ * The settings belong to the plugin registration; the pointer handler reads
210
+ * the drag threshold from them, and the framework layers read the colors.
211
+ */
212
+ function createSelectionController(ctx) {
213
+ const settings = ctx.settings();
214
+ const changed = ctx.events.source();
215
+ const committed = ctx.events.source();
216
+ const cleared = ctx.events.source();
217
+ const textSnapshots = /* @__PURE__ */ new Map();
218
+ const state = () => ctx.state.get();
219
+ const canSelect = () => ctx.allows(SELECT_SCOPE);
220
+ const canCopy = () => ctx.allows(COPY_SCOPE);
221
+ const pageIndexOf = (page) => ctx.getPage(page)?.index ?? -1;
222
+ const pageAtIndex = (index) => ctx.document()?.pages[index]?.ref;
223
+ const geometry = ctx.pageMirror({
224
+ name: "geometry",
225
+ load: (doc, page) => doc.page(page).text.layout(),
226
+ affected: contentChangedPagesOf,
227
+ changed: ({ cause }) => {
228
+ if (cause !== "load") return;
229
+ const current = state().selection;
230
+ if (current) recompute(current);
231
+ }
232
+ });
233
+ /** A page's snapshot while it is loaded and current: a page whose re-read
234
+ * failed has no geometry, exactly as {@link SelectionHostCapability.isLoaded} says. */
235
+ const snapshotOf = (page) => geometry.getStatus(page) === "ready" ? geometry.get(page) : void 0;
236
+ /** A page's text layout while it is loaded and current. */
237
+ const geometryFor = snapshotOf;
238
+ const glyphAt = (layout, point) => layout.charAt(point);
239
+ /** Warm a page's geometry; the returned promise never rejects (see the host contract). */
240
+ function ensureLoaded(page) {
241
+ if (!canSelect() || !ctx.getPage(page)) return Promise.resolve();
242
+ return geometry.ensureLoaded(page).catch(() => {});
243
+ }
244
+ function orderedEnds(selection) {
245
+ const anchorPageIndex = pageIndexOf(selection.anchor.page);
246
+ const focusPageIndex = pageIndexOf(selection.focus.page);
247
+ return anchorPageIndex < focusPageIndex || anchorPageIndex === focusPageIndex && selection.anchor.glyph <= selection.focus.glyph ? {
248
+ start: selection.anchor,
249
+ end: selection.focus,
250
+ direction: "forward"
251
+ } : {
252
+ start: selection.focus,
253
+ end: selection.anchor,
254
+ direction: "backward"
255
+ };
256
+ }
257
+ /** Clamp a position into its page's real character range once geometry is known. */
258
+ function clampPosition(position) {
259
+ const textLayout = geometryFor(position.page);
260
+ if (!textLayout) return position;
261
+ const max = Math.max(textLayout.charCount - 1, 0);
262
+ const glyph = Math.max(0, Math.min(position.glyph, max));
263
+ return glyph === position.glyph ? position : {
264
+ page: position.page,
265
+ glyph
266
+ };
267
+ }
268
+ /** Rebuild merged line segments for every loaded page in the span; warm the
269
+ * rest. Clears when an endpoint's page has left the registry: a range
270
+ * across a structural edit is meaningless. */
271
+ function recompute(selection) {
272
+ const clamped = {
273
+ anchor: clampPosition(selection.anchor),
274
+ focus: clampPosition(selection.focus)
275
+ };
276
+ const { start, end } = orderedEnds(clamped);
277
+ const startPageIndex = pageIndexOf(start.page);
278
+ const endPageIndex = pageIndexOf(end.page);
279
+ if (startPageIndex < 0 || endPageIndex < 0) {
280
+ ctx.state.update(clearSelection);
281
+ return;
282
+ }
283
+ const segments = {};
284
+ for (let i = startPageIndex; i <= endPageIndex; i++) {
285
+ const page = pageAtIndex(i);
286
+ if (!page) continue;
287
+ const textLayout = geometryFor(page);
288
+ if (!textLayout) {
289
+ ensureLoaded(page);
290
+ continue;
291
+ }
292
+ const from = i === startPageIndex ? start.glyph : 0;
293
+ const to = i === endPageIndex ? end.glyph : textLayout.charCount - 1;
294
+ segments[page.objectNumber] = textLayout.segments({
295
+ start: from,
296
+ count: to - from + 1
297
+ });
298
+ }
299
+ ctx.state.update(setSelection, clamped, segments);
300
+ }
301
+ const readModel = memo(() => [
302
+ state().selection,
303
+ state().segments,
304
+ ctx.document()?.revision ?? 0
305
+ ], (selection, segments) => buildReadModel(selection, segments));
306
+ function buildReadModel(selection, segments) {
307
+ const pagesWithSegments = Object.keys(segments).map((key) => toPageRef(Number(key))).filter((page) => (segments[page.objectNumber]?.length ?? 0) > 0).sort((left, right) => pageIndexOf(left) - pageIndexOf(right));
308
+ const snapshotPages = pagesWithSegments.map((page) => ({
309
+ page,
310
+ segments: segments[page.objectNumber]
311
+ }));
312
+ const pages = pagesWithSegments.length ? pagesWithSegments : EMPTY_PAGES;
313
+ if (!selection) return {
314
+ snapshot: {
315
+ pages: snapshotPages,
316
+ start: null,
317
+ end: null,
318
+ direction: "forward",
319
+ range: null
320
+ },
321
+ pages,
322
+ anchor: null
323
+ };
324
+ const { start, end, direction } = orderedEnds(selection);
325
+ const snapshot = {
326
+ pages: snapshotPages,
327
+ start: endpointFor(segments, start, "start"),
328
+ end: endpointFor(segments, end, "end"),
329
+ direction,
330
+ range: {
331
+ start: {
332
+ page: start.page,
333
+ index: start.glyph
334
+ },
335
+ end: {
336
+ page: end.page,
337
+ index: end.glyph + 1
338
+ }
339
+ }
340
+ };
341
+ const endBounds = unionOf(segments[end.page.objectNumber]);
342
+ let anchor = endBounds ? {
343
+ page: end.page,
344
+ bounds: endBounds
345
+ } : null;
346
+ const last = pagesWithSegments[pagesWithSegments.length - 1];
347
+ if (!anchor && last) anchor = {
348
+ page: last,
349
+ bounds: unionOf(segments[last.objectNumber])
350
+ };
351
+ return {
352
+ snapshot,
353
+ pages,
354
+ anchor
355
+ };
356
+ }
357
+ function endpointFor(segmentsByPage, position, which) {
358
+ const segments = segmentsByPage[position.page.objectNumber] ?? EMPTY_SEGMENTS;
359
+ if (!segments.length) return null;
360
+ const segment = which === "start" ? segments[0] : segments[segments.length - 1];
361
+ const textLayout = geometryFor(position.page);
362
+ const cell = textLayout ? textLayout.charQuad(position.glyph) : null;
363
+ if (textLayout && cell) return {
364
+ page: position.page,
365
+ glyphQuad: cell,
366
+ advance: segment.advance,
367
+ rect: quadBounds(cell)
368
+ };
369
+ return {
370
+ page: position.page,
371
+ glyphQuad: segment.quad,
372
+ advance: segment.advance,
373
+ rect: segment.rect
374
+ };
375
+ }
376
+ const unionOf = (segments) => segments ? boundsOfRects(segments.map((segment) => segment.rect)) : null;
377
+ /** A page's segment boxes, the same array until that page's segments change. */
378
+ const rectsOf = memoByKey((pageObjectNumber) => [state().segments[pageObjectNumber]], (_pageObjectNumber, segments) => (segments ?? EMPTY_SEGMENTS).map((segment) => segment.rect));
379
+ ctx.state.onChange(({ previous, next }) => {
380
+ if (previous.selection === next.selection && previous.segments === next.segments) return;
381
+ const model = readModel();
382
+ changed.emit({
383
+ range: model.snapshot.range,
384
+ pages: model.pages
385
+ });
386
+ if (previous.selection !== null && next.selection === null) cleared.emit({});
387
+ });
388
+ function select(input) {
389
+ ctx.assertAllowed(SELECT_SCOPE, "selection.select");
390
+ const range = "page" in input ? {
391
+ start: {
392
+ page: input.page,
393
+ index: input.start
394
+ },
395
+ end: {
396
+ page: input.page,
397
+ index: input.start + input.count
398
+ }
399
+ } : input;
400
+ ctx.assertPageRef(range.start.page);
401
+ ctx.assertPageRef(range.end.page);
402
+ const startPageIndex = pageIndexOf(range.start.page);
403
+ let endPageIndex = pageIndexOf(range.end.page);
404
+ let endCharIndex = range.end.index;
405
+ if (endPageIndex < startPageIndex || endPageIndex === startPageIndex && endCharIndex <= range.start.index) {
406
+ ctx.state.update(clearSelection);
407
+ return;
408
+ }
409
+ if (endCharIndex === 0) {
410
+ endPageIndex -= 1;
411
+ if (endPageIndex < startPageIndex) {
412
+ ctx.state.update(clearSelection);
413
+ return;
414
+ }
415
+ endCharIndex = OPEN_END;
416
+ }
417
+ const focusPage = pageAtIndex(endPageIndex);
418
+ if (!focusPage) return;
419
+ recompute({
420
+ anchor: {
421
+ page: range.start.page,
422
+ glyph: Math.max(0, range.start.index)
423
+ },
424
+ focus: {
425
+ page: focusPage,
426
+ glyph: endCharIndex - 1
427
+ }
428
+ });
429
+ }
430
+ function selectAll() {
431
+ ctx.assertAllowed(SELECT_SCOPE, "selection.selectAll");
432
+ const pages = ctx.document()?.pages ?? [];
433
+ if (pages.length === 0) return;
434
+ recompute({
435
+ anchor: {
436
+ page: pages[0].ref,
437
+ glyph: 0
438
+ },
439
+ focus: {
440
+ page: pages[pages.length - 1].ref,
441
+ glyph: OPEN_END
442
+ }
443
+ });
444
+ }
445
+ function selectPage(target) {
446
+ ctx.assertAllowed(SELECT_SCOPE, "selection.selectPage");
447
+ const page = ctx.pageOf(target).ref;
448
+ recompute({
449
+ anchor: {
450
+ page,
451
+ glyph: 0
452
+ },
453
+ focus: {
454
+ page,
455
+ glyph: OPEN_END
456
+ }
457
+ });
458
+ }
459
+ /** Word / line around a point. Returns whether a span actually engaged
460
+ * (geometry present and a glyph under the point): the fact haptics and
461
+ * other success-gated feedback key on, so nothing buzzes over blank space. */
462
+ function selectSpanAt(target, point, expand) {
463
+ ctx.assertAllowed(SELECT_SCOPE, `selection.select${expand === "word" ? "WordAt" : "LineAt"}`);
464
+ const page = ctx.pageOf(target).ref;
465
+ const textLayout = geometryFor(page);
466
+ if (!textLayout) return false;
467
+ const glyph = glyphAt(textLayout, point);
468
+ if (glyph == null) return false;
469
+ const span = expand === "word" ? textLayout.wordAt(glyph) : textLayout.lineAt(glyph);
470
+ if (!span) return false;
471
+ recompute({
472
+ anchor: {
473
+ page,
474
+ glyph: span.start
475
+ },
476
+ focus: {
477
+ page,
478
+ glyph: span.start + span.count - 1
479
+ }
480
+ });
481
+ return true;
482
+ }
483
+ function extendTo(target, point) {
484
+ ctx.assertAllowed(SELECT_SCOPE, "selection.extendTo");
485
+ const page = ctx.pageOf(target).ref;
486
+ const current = state().selection;
487
+ if (!current) return;
488
+ const textLayout = geometryFor(page);
489
+ if (!textLayout) {
490
+ ensureLoaded(page);
491
+ return;
492
+ }
493
+ const glyph = glyphAt(textLayout, point);
494
+ if (glyph == null) return;
495
+ recompute({
496
+ anchor: current.anchor,
497
+ focus: {
498
+ page,
499
+ glyph
500
+ }
501
+ });
502
+ }
503
+ /** Per-page text snapshot, cached as a promise so concurrent readers share
504
+ * one fetch. Rejections are evicted: a refused or failed read must not
505
+ * poison the cache for a later authorized call. */
506
+ function pageText(page) {
507
+ const key = page.objectNumber;
508
+ const cached = textSnapshots.get(key);
509
+ if (cached) return cached;
510
+ const pending = Promise.resolve(ctx.doc.page(page).text.get());
511
+ textSnapshots.set(key, pending);
512
+ pending.catch(() => {
513
+ if (textSnapshots.get(key) === pending) textSnapshots.delete(key);
514
+ });
515
+ return pending;
516
+ }
517
+ async function readText(options) {
518
+ ctx.assertAllowed(COPY_SCOPE, "selection.readText");
519
+ const range = readModel().snapshot.range;
520
+ return range ? readRange(range, options) : "";
521
+ }
522
+ async function readTextInRange(range, options) {
523
+ ctx.assertAllowed(COPY_SCOPE, "selection.readTextInRange");
524
+ return readRange(range, options);
525
+ }
526
+ /** A range's text, read a few pages at a time. Cancelling stops the wait at
527
+ * once; the page reads it started finish into the cache, which other
528
+ * readers share. */
529
+ async function readRange(range, options) {
530
+ ctx.assertPageRef(range.start.page);
531
+ ctx.assertPageRef(range.end.page);
532
+ const startPageIndex = pageIndexOf(range.start.page);
533
+ let endPageIndex = pageIndexOf(range.end.page);
534
+ let endCharIndex = range.end.index;
535
+ if (endPageIndex < startPageIndex || endPageIndex === startPageIndex && endCharIndex <= range.start.index) return "";
536
+ if (endCharIndex === 0) {
537
+ endPageIndex -= 1;
538
+ endCharIndex = OPEN_END;
539
+ }
540
+ const spans = [];
541
+ for (let i = startPageIndex; i <= endPageIndex; i++) {
542
+ const page = pageAtIndex(i);
543
+ if (!page) continue;
544
+ spans.push({
545
+ page,
546
+ from: i === startPageIndex ? Math.max(0, range.start.index) : 0,
547
+ to: i === endPageIndex ? endCharIndex : OPEN_END
548
+ });
549
+ }
550
+ const signal = options?.signal;
551
+ const parts = new Array(spans.length);
552
+ for (let base = 0; base < spans.length; base += TEXT_READ_CONCURRENCY) {
553
+ if (signal?.aborted) throw new PluginError("operation-cancelled", "selection", "reading the text was cancelled");
554
+ const batch = spans.slice(base, base + TEXT_READ_CONCURRENCY);
555
+ (await ctx.cancellable(signal, Promise.all(batch.map((span) => pageText(span.page))))).forEach((snapshot, index) => {
556
+ const { from, to } = batch[index];
557
+ parts[base + index] = sliceText(snapshot, {
558
+ start: from,
559
+ count: to - from
560
+ });
561
+ });
562
+ }
563
+ return parts.join("\n");
564
+ }
565
+ /** Structural registry change (rotate/move/delete/insert): drop the text of
566
+ * pages that left, then recompute. Rotation re-derives page-space geometry
567
+ * through the layout key; a deleted endpoint page clears through
568
+ * recompute's registry check. */
569
+ function onPagesUpdated() {
570
+ const alive = new Set((ctx.document()?.pages ?? []).map((info) => info.ref.objectNumber));
571
+ for (const pageObjectNumber of [...textSnapshots.keys()]) if (!alive.has(pageObjectNumber)) textSnapshots.delete(pageObjectNumber);
572
+ const current = state().selection;
573
+ if (current) recompute(current);
574
+ }
575
+ /** Page content changed: the pages' text is stale, and a live range points
576
+ * into a character space that no longer exists, so it clears. The
577
+ * geometry mirror re-reads those pages itself. */
578
+ function onContentChanged(pages) {
579
+ for (const page of pages) textSnapshots.delete(page.objectNumber);
580
+ ctx.state.update(clearSelection);
581
+ }
582
+ const setGesture = (active) => ctx.state.update(setSelecting, active);
583
+ /** A page's segments, by its ref or its index: a page that isn't in the document has none. */
584
+ const segmentsOn = (target) => {
585
+ const page = ctx.getPage(target);
586
+ return page ? state().segments[page.ref.objectNumber] ?? EMPTY_SEGMENTS : EMPTY_SEGMENTS;
587
+ };
588
+ const api = {
589
+ ...settings.api,
590
+ canSelect,
591
+ canCopy,
592
+ select,
593
+ selectAll,
594
+ selectPage,
595
+ selectWordAt: (page, point) => selectSpanAt(page, point, "word"),
596
+ selectLineAt: (page, point) => selectSpanAt(page, point, "line"),
597
+ extendTo,
598
+ clear: () => ctx.state.update(clearSelection),
599
+ hasSelection: () => state().selection != null,
600
+ isSelecting: () => state().selecting,
601
+ getSnapshot: () => readModel().snapshot,
602
+ getRange: () => readModel().snapshot.range,
603
+ listSelectedPages: () => readModel().pages,
604
+ listSegments: segmentsOn,
605
+ listRects: (target) => {
606
+ const page = ctx.getPage(target);
607
+ return page ? rectsOf(page.ref.objectNumber) : EMPTY_RECTS;
608
+ },
609
+ getAnchor: () => readModel().anchor,
610
+ readText,
611
+ readTextInRange,
612
+ onChanged: changed.on,
613
+ onCommitted: committed.on,
614
+ onCleared: cleared.on,
615
+ ensureLoaded,
616
+ isLoaded: (page) => geometry.getStatus(page) === "ready",
617
+ isOverText: (page, point) => {
618
+ const textLayout = geometryFor(page);
619
+ return textLayout ? glyphAt(textLayout, point) != null : false;
620
+ },
621
+ beginGesture: () => setGesture(true),
622
+ beginGestureAt: (page, point) => {
623
+ if (!canSelect()) return false;
624
+ const textLayout = geometryFor(page);
625
+ if (!textLayout) return false;
626
+ const glyph = glyphAt(textLayout, point);
627
+ if (glyph == null) return false;
628
+ setGesture(true);
629
+ recompute({
630
+ anchor: {
631
+ page,
632
+ glyph
633
+ },
634
+ focus: {
635
+ page,
636
+ glyph
637
+ }
638
+ });
639
+ return true;
640
+ },
641
+ endGesture: () => {
642
+ setGesture(false);
643
+ const range = readModel().snapshot.range;
644
+ if (range) committed.emit({ range });
645
+ },
646
+ setHighlightVisible: (visible) => ctx.state.update(setHighlightHidden, !visible),
647
+ isHighlightVisible: () => !state().highlightHidden
648
+ };
649
+ return {
650
+ api,
651
+ connect() {
652
+ ctx.watch(() => ctx.document()?.revision ?? 0, onPagesUpdated);
653
+ ctx.listen(ctx.doc.events, (event) => {
654
+ const pages = contentChangedPagesOf(event);
655
+ if (pages) onContentChanged(pages);
656
+ });
657
+ connectSelection(ctx, api);
658
+ }
659
+ };
660
+ }
661
+ //#endregion
662
+ //#region src/selection.plugin.ts
663
+ /**
664
+ * Text selection: document-scoped, requires the interaction hub, whose
665
+ * pointer stream drives the selection's own handler. Works with `<Stage>`
666
+ * or a standalone `<PageView>`: selection only needs the page coordinate
667
+ * context and the engine's text geometry. Platform feedback (haptics) is
668
+ * optional. `config` is the settings the app registers, over
669
+ * {@link SELECTION_DEFAULTS}.
17
670
  */
18
- const selectionPlugin = () => definePlugin({
671
+ const selectionPlugin = (config) => definePlugin({
19
672
  id: "selection",
20
673
  token: SelectionToken$1,
21
674
  scope: "document",
22
675
  requires: [InteractionToken],
23
676
  optional: [FeedbackToken],
24
- initialState: initialSelectionState,
25
- reduce: selectionReducer,
26
- capability: createSelectionCapability,
27
- init: (ctx) => {
28
- const interaction = ctx.get(InteractionToken);
29
- const selection = ctx.get(SelectionToken$1);
30
- const feedback = ctx.tryGet(FeedbackToken);
31
- interaction.registerHandler(createTextSelectHandler(selection, interaction, feedback ?? void 0));
677
+ state: initialSelectionState,
678
+ settings: {
679
+ defaults: SELECTION_DEFAULTS,
680
+ registered: config
681
+ },
682
+ create: createSelectionController
683
+ });
684
+ //#endregion
685
+ //#region src/state.ts
686
+ /**
687
+ * The selection page's State table as code: what `useSelectionState()`
688
+ * returns, and the same fields in every other framework. The geometry is not
689
+ * here: a layer reads its page's segments with `listSegments(page)`, so a
690
+ * toolbar that only shows whether something is selected doesn't re-render for
691
+ * every step of a drag.
692
+ */
693
+ const selectionState = defineState(SelectionToken, {
694
+ read: (selection) => ({
695
+ hasSelection: selection.hasSelection(),
696
+ isSelecting: selection.isSelecting(),
697
+ range: selection.getRange(),
698
+ pages: selection.listSelectedPages()
699
+ }),
700
+ empty: {
701
+ hasSelection: false,
702
+ isSelecting: false,
703
+ range: null,
704
+ pages: []
32
705
  }
33
706
  });
34
707
  //#endregion
35
708
  //#region src/handles.ts
36
709
  /**
37
- * Selection handles — the pure policy half (the `wheel.ts` convention).
710
+ * Selection handles — the pure policy half.
38
711
  *
39
- * A handle IS the boundary glyph's leading (or trailing) edge: a segment
40
- * between two corners of the glyph's ORIENTED cell, with the grab head just
712
+ * A handle is the boundary glyph's leading (or trailing) edge: a segment
713
+ * between two corners of the glyph's oriented cell, with the grab head just
41
714
  * beyond the ascent at the start / past the baseline at the end. Bar length,
42
715
  * angle, and head all derive from that one segment, so rotated text and
43
716
  * rotated pages are carried by construction — the AABB never enters.
44
717
  *
45
718
  * Everything here is DOM-free and framework-free: geometry is pure given a
46
719
  * projector, and the drag session speaks the selection's own gesture verbs
47
- * (`beginAt`/`extendTo`/`end` — the same ones the pointer handler uses; a
48
- * handle drag IS a selection gesture, re-anchored). The framework adapters
720
+ * (`beginGestureAt`/`extendTo`/`endGesture` — the same ones the pointer handler uses; a
721
+ * handle drag is a selection gesture, re-anchored). The framework adapters
49
722
  * keep only subscriptions and markup; the DOM listener mechanics live in
50
723
  * `@embedpdf/web`'s `attachSelectionHandle`.
51
724
  *
52
- * The view dependency is STRUCTURAL (satisfied by five lines over
53
- * `StageCapability`) so this plugin stays stage-free — selection also runs
54
- * in stage-less hosts (`PageView`) — and the math tests run against a fake.
725
+ * The view dependency is structural (`selectionHandleViewOf` builds it from
726
+ * anything shaped like `StageCapability`) so this plugin stays stage-free —
727
+ * selection also runs in stage-less hosts (`PageView`) — and the math tests
728
+ * run against a fake.
55
729
  */
730
+ /**
731
+ * The handles' view over a Stage: point-exact projection in, page lookup out.
732
+ * (The Stage's box projector, `pageRectToViewport`, is for upright overlays
733
+ * and would lose the orientation a handle needs.)
734
+ */
735
+ function selectionHandleViewOf(stage) {
736
+ return {
737
+ toOverlay: (page, point) => stage.pageToViewport(page, point),
738
+ pageAt: (overlay) => stage.getPageAt(overlay),
739
+ pointOnPage: (page, overlay) => stage.viewportToPage(page, overlay)
740
+ };
741
+ }
742
+ /**
743
+ * The two ends of the selection in `snapshot` (`getSnapshot()`), or null
744
+ * while nothing is selected. A new object on every call: compare with
745
+ * `sameSelectionEndpoints` from `@embedpdf/web`.
746
+ */
747
+ function selectionHandleEndpointsOf(snapshot) {
748
+ const { start, end } = snapshot;
749
+ if (!start || !end) return null;
750
+ return {
751
+ start: {
752
+ page: start.page,
753
+ glyphQuad: start.glyphQuad,
754
+ advance: start.advance
755
+ },
756
+ end: {
757
+ page: end.page,
758
+ glyphQuad: end.glyphQuad,
759
+ advance: end.advance
760
+ }
761
+ };
762
+ }
56
763
  const HANDLE_HEAD = 12;
57
764
  const HANDLE_BAR = 2;
58
765
  const HANDLE_PAD = 14;
59
766
  /** Tilts within ~0.05° of upright render untransformed (float-noise guard —
60
767
  * and the dominant case stays pixel-identical to an axis-aligned box). */
61
768
  const HANDLE_ROT_EPSILON = .05;
62
- const midpoint = (a, b) => ({
63
- x: (a.x + b.x) / 2,
64
- y: (a.y + b.y) / 2
769
+ const midpoint = (from, to) => ({
770
+ x: (from.x + to.x) / 2,
771
+ y: (from.y + to.y) / 2
65
772
  });
66
773
  /**
67
774
  * The handle's geometry for one endpoint, in overlay space. Null when the
68
775
  * endpoint's page isn't laid out (or the cell is degenerate) — render nothing.
69
776
  */
70
- function selectionHandleGeom(view, ep, role) {
71
- const leading = role === "start" ? ep.advance > 0 : ep.advance < 0;
72
- const [upperPage, lowerPage] = textQuadEdge(ep.glyphQuad, leading ? "start" : "end");
73
- const upper = view.toOverlay(ep.pon, upperPage);
74
- const lower = view.toOverlay(ep.pon, lowerPage);
777
+ function selectionHandleGeom(view, endpoint, role) {
778
+ const leading = role === "start" ? endpoint.advance > 0 : endpoint.advance < 0;
779
+ const [upperPage, lowerPage] = quadEdge(endpoint.glyphQuad, leading ? "left" : "right");
780
+ const upper = view.toOverlay(endpoint.page, upperPage);
781
+ const lower = view.toOverlay(endpoint.page, lowerPage);
75
782
  if (!upper || !lower) return null;
76
783
  const dx = lower.x - upper.x;
77
784
  const dy = lower.y - upper.y;
@@ -100,7 +807,7 @@ function selectionHandleGeom(view, ep, role) {
100
807
  };
101
808
  }
102
809
  /**
103
- * One armed handle drag. Dragging a handle extends from the OPPOSITE
810
+ * One armed handle drag. Dragging a handle extends from the opposite
104
811
  * endpoint: the first movement re-roots the selection gesture at that
105
812
  * endpoint's cell centre, and every position then extends toward the pointer
106
813
  * — snapping to glyphs and crossing pages exactly like a pointer drag,
@@ -109,32 +816,46 @@ function selectionHandleGeom(view, ep, role) {
109
816
  * freezing at a page edge. `end()` commits only if the drag actually
110
817
  * re-rooted; an untouched press settles nothing.
111
818
  */
112
- function createSelectionHandleDrag(selection, view, opposite, draggedPon) {
113
- const q = opposite.glyphQuad;
114
- const anchorPoint = midpoint(midpoint(q.upperStart, q.lowerEnd), midpoint(q.upperEnd, q.lowerStart));
819
+ function createSelectionHandleDrag(selection, view, opposite, draggedPage) {
820
+ const quad = opposite.glyphQuad;
821
+ const anchorPoint = midpoint(midpoint(quad.upperLeft, quad.lowerRight), midpoint(quad.upperRight, quad.lowerLeft));
115
822
  let begun = false;
116
- let lastPon = draggedPon;
823
+ let lastPage = draggedPage;
117
824
  return {
118
825
  move: (overlay) => {
119
826
  if (!begun) {
120
- if (!selection.beginAt(opposite.pon, anchorPoint)) return;
827
+ if (!selection.beginGestureAt(opposite.page, anchorPoint)) return;
121
828
  begun = true;
122
829
  }
123
830
  const hit = view.pageAt(overlay);
124
831
  if (hit) {
125
- lastPon = hit.pon;
126
- selection.extendTo(hit.pon, hit.point);
832
+ lastPage = hit.ref;
833
+ selection.extendTo(hit.ref, hit.point);
127
834
  } else {
128
- const p = view.pointOnPage(lastPon, overlay);
129
- if (p) selection.extendTo(lastPon, p);
835
+ const point = view.pointOnPage(lastPage, overlay);
836
+ if (point) selection.extendTo(lastPage, point);
130
837
  }
131
838
  },
132
839
  end: () => {
133
- if (begun) selection.end();
840
+ if (begun) selection.endGesture();
134
841
  }
135
842
  };
136
843
  }
844
+ /**
845
+ * Arm a drag of the `role` handle at a press, with the view and the endpoints
846
+ * as they are at that moment: it extends the selection from the opposite end.
847
+ * Null when the handle's page isn't laid out.
848
+ */
849
+ function armSelectionHandle(selection, view, endpoints, role) {
850
+ const geometry = selectionHandleGeom(view, endpoints[role], role);
851
+ if (!geometry) return null;
852
+ const opposite = endpoints[role === "start" ? "end" : "start"];
853
+ return {
854
+ base: midpoint(geometry.bar.from, geometry.bar.to),
855
+ drag: createSelectionHandleDrag(selection, view, opposite, endpoints[role].page)
856
+ };
857
+ }
137
858
  //#endregion
138
- export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SelectionToken, createSelectionHandleDrag, selectionHandleGeom, selectionPlugin };
859
+ export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SELECTION_DEFAULTS, SelectionToken, armSelectionHandle, createSelectionHandleDrag, selectionHandleEndpointsOf, selectionHandleGeom, selectionHandleViewOf, selectionPlugin, selectionState };
139
860
 
140
861
  //# sourceMappingURL=index.js.map