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

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