@embedpdf/plugin-selection 2.14.4 → 3.0.0-next.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.
Files changed (90) hide show
  1. package/LICENSE +192 -21
  2. package/dist/index.cjs +575 -2
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +167 -0
  5. package/dist/index.d.ts +167 -1
  6. package/dist/index.js +549 -1375
  7. package/dist/index.js.map +1 -1
  8. package/package.json +21 -62
  9. package/dist/lib/actions.d.ts +0 -103
  10. package/dist/lib/handlers/index.d.ts +0 -2
  11. package/dist/lib/handlers/marquee-selection.handler.d.ts +0 -30
  12. package/dist/lib/handlers/text-selection.handler.d.ts +0 -52
  13. package/dist/lib/index.d.ts +0 -9
  14. package/dist/lib/manifest.d.ts +0 -4
  15. package/dist/lib/reducer.d.ts +0 -5
  16. package/dist/lib/selection-plugin.d.ts +0 -92
  17. package/dist/lib/selectors.d.ts +0 -14
  18. package/dist/lib/types.d.ts +0 -266
  19. package/dist/lib/utils.d.ts +0 -108
  20. package/dist/preact/adapter.d.ts +0 -4
  21. package/dist/preact/core.d.ts +0 -1
  22. package/dist/preact/index.cjs +0 -2
  23. package/dist/preact/index.cjs.map +0 -1
  24. package/dist/preact/index.d.ts +0 -1
  25. package/dist/preact/index.js +0 -218
  26. package/dist/preact/index.js.map +0 -1
  27. package/dist/preact/interaction-manager.d.ts +0 -1
  28. package/dist/preact/utils.d.ts +0 -1
  29. package/dist/react/adapter.d.ts +0 -2
  30. package/dist/react/core.d.ts +0 -1
  31. package/dist/react/index.cjs +0 -2
  32. package/dist/react/index.cjs.map +0 -1
  33. package/dist/react/index.d.ts +0 -1
  34. package/dist/react/index.js +0 -217
  35. package/dist/react/index.js.map +0 -1
  36. package/dist/react/interaction-manager.d.ts +0 -1
  37. package/dist/react/utils.d.ts +0 -1
  38. package/dist/shared/components/copy-to-clipboard.d.ts +0 -1
  39. package/dist/shared/components/index.d.ts +0 -4
  40. package/dist/shared/components/marquee-selection.d.ts +0 -36
  41. package/dist/shared/components/selection-layer.d.ts +0 -29
  42. package/dist/shared/components/text-selection.d.ts +0 -21
  43. package/dist/shared/hooks/index.d.ts +0 -1
  44. package/dist/shared/hooks/use-selection.d.ts +0 -11
  45. package/dist/shared/index.d.ts +0 -5
  46. package/dist/shared/types.d.ts +0 -7
  47. package/dist/shared-preact/components/copy-to-clipboard.d.ts +0 -1
  48. package/dist/shared-preact/components/index.d.ts +0 -4
  49. package/dist/shared-preact/components/marquee-selection.d.ts +0 -36
  50. package/dist/shared-preact/components/selection-layer.d.ts +0 -29
  51. package/dist/shared-preact/components/text-selection.d.ts +0 -21
  52. package/dist/shared-preact/hooks/index.d.ts +0 -1
  53. package/dist/shared-preact/hooks/use-selection.d.ts +0 -11
  54. package/dist/shared-preact/index.d.ts +0 -5
  55. package/dist/shared-preact/types.d.ts +0 -7
  56. package/dist/shared-react/components/copy-to-clipboard.d.ts +0 -1
  57. package/dist/shared-react/components/index.d.ts +0 -4
  58. package/dist/shared-react/components/marquee-selection.d.ts +0 -36
  59. package/dist/shared-react/components/selection-layer.d.ts +0 -29
  60. package/dist/shared-react/components/text-selection.d.ts +0 -21
  61. package/dist/shared-react/hooks/index.d.ts +0 -1
  62. package/dist/shared-react/hooks/use-selection.d.ts +0 -11
  63. package/dist/shared-react/index.d.ts +0 -5
  64. package/dist/shared-react/types.d.ts +0 -7
  65. package/dist/svelte/components/CopyToClipboard.svelte.d.ts +0 -3
  66. package/dist/svelte/components/MarqueeSelection.svelte.d.ts +0 -27
  67. package/dist/svelte/components/SelectionLayer.svelte.d.ts +0 -32
  68. package/dist/svelte/components/TextSelection.svelte.d.ts +0 -22
  69. package/dist/svelte/components/index.d.ts +0 -4
  70. package/dist/svelte/hooks/index.d.ts +0 -1
  71. package/dist/svelte/hooks/use-selection.svelte.d.ts +0 -19
  72. package/dist/svelte/index.cjs +0 -2
  73. package/dist/svelte/index.cjs.map +0 -1
  74. package/dist/svelte/index.d.ts +0 -5
  75. package/dist/svelte/index.js +0 -359
  76. package/dist/svelte/index.js.map +0 -1
  77. package/dist/svelte/types.d.ts +0 -7
  78. package/dist/vue/components/copy-to-clipboard.vue.d.ts +0 -3
  79. package/dist/vue/components/index.d.ts +0 -4
  80. package/dist/vue/components/marquee-selection.vue.d.ts +0 -29
  81. package/dist/vue/components/selection-layer.vue.d.ts +0 -60
  82. package/dist/vue/components/text-selection.vue.d.ts +0 -51
  83. package/dist/vue/hooks/index.d.ts +0 -1
  84. package/dist/vue/hooks/use-selection.d.ts +0 -11
  85. package/dist/vue/index.cjs +0 -2
  86. package/dist/vue/index.cjs.map +0 -1
  87. package/dist/vue/index.d.ts +0 -5
  88. package/dist/vue/index.js +0 -311
  89. package/dist/vue/index.js.map +0 -1
  90. package/dist/vue/types.d.ts +0 -7
package/dist/index.js CHANGED
@@ -1,1390 +1,564 @@
1
- import { clamp, BasePlugin, createScopedEmitter, REFRESH_PAGES } from "@embedpdf/core";
2
- import { boundingRect, Task, ignore, PdfErrorCode, PdfTaskHelper, PdfPermissionFlag } from "@embedpdf/models";
3
- const SELECTION_PLUGIN_ID = "selection";
4
- const manifest = {
5
- id: SELECTION_PLUGIN_ID,
6
- name: "Selection Plugin",
7
- version: "1.0.0",
8
- provides: ["selection"],
9
- requires: ["interaction-manager"],
10
- optional: ["viewport", "scroll"],
11
- defaultConfig: {
12
- menuHeight: 40
13
- }
14
- };
15
- const INIT_SELECTION_STATE = "SELECTION/INIT_STATE";
16
- const CLEANUP_SELECTION_STATE = "SELECTION/CLEANUP_STATE";
17
- const CACHE_PAGE_GEOMETRY = "SELECTION/CACHE_PAGE_GEOMETRY";
18
- const SET_SELECTION = "SELECTION/SET_SELECTION";
19
- const START_SELECTION = "SELECTION/START_SELECTION";
20
- const END_SELECTION = "SELECTION/END_SELECTION";
21
- const CLEAR_SELECTION = "SELECTION/CLEAR_SELECTION";
22
- const SET_RECTS = "SELECTION/SET_RECTS";
23
- const SET_SLICES = "SELECTION/SET_SLICES";
24
- const EVICT_PAGE_GEOMETRY = "SELECTION/EVICT_PAGE_GEOMETRY";
25
- const RESET = "SELECTION/RESET";
26
- const initSelectionState = (documentId, state) => ({
27
- type: INIT_SELECTION_STATE,
28
- payload: { documentId, state }
29
- });
30
- const cleanupSelectionState = (documentId) => ({
31
- type: CLEANUP_SELECTION_STATE,
32
- payload: documentId
33
- });
34
- const cachePageGeometry = (documentId, page, geo) => ({
35
- type: CACHE_PAGE_GEOMETRY,
36
- payload: { documentId, page, geo }
37
- });
38
- const setSelection = (documentId, sel) => ({
39
- type: SET_SELECTION,
40
- payload: { documentId, selection: sel }
41
- });
42
- const startSelection = (documentId) => ({
43
- type: START_SELECTION,
44
- payload: { documentId }
45
- });
46
- const endSelection = (documentId) => ({
47
- type: END_SELECTION,
48
- payload: { documentId }
49
- });
50
- const clearSelection = (documentId) => ({
51
- type: CLEAR_SELECTION,
52
- payload: { documentId }
1
+ import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
+ import { InteractionToken } from "@embedpdf/plugin-interaction";
3
+ import { applyRect, boundsOfRects, pageGeometry } from "@embedpdf/core-geometry";
4
+ //#region src/geometry.ts
5
+ /**
6
+ * The pure selection geometry: engine text geometry (PDF user space, y-up) →
7
+ * viewer content space (y-down), glyph hit-testing, word/line expansion, and the
8
+ * line-merge. No DOM, no engine, no React.
9
+ *
10
+ * The non-trivial bits are ports of battle-tested algorithms, adapted to v3's
11
+ * content-space `Rect {x,y,width,height}`:
12
+ * - `glyphAt` — PDFium `GetIndexAtPos` (exact tight box, then a tolerance pass).
13
+ * - `rectsForRange` — Chromium `pdfium_range.cc` `MergeAdjacentRects` (one rect per line).
14
+ * - `expandToWord/Line` — Chromium `OnMultipleClick` (double = word, triple = line).
15
+ * The PDF↔content y-flip is the geometry package's `pageGeometry` (the one place
16
+ * that math lives), never hand-rolled here.
17
+ */
18
+ const FLAG_EMPTY = 2;
19
+ const isBoundary = (flags) => (flags & 3) !== 0;
20
+ const isEmpty = (flags) => (flags & FLAG_EMPTY) !== 0;
21
+ const toContent = (m, b) => applyRect(m, {
22
+ x: b.left,
23
+ y: b.bottom,
24
+ width: b.right - b.left,
25
+ height: b.top - b.bottom
53
26
  });
54
- const setRects = (documentId, allRects) => ({
55
- type: SET_RECTS,
56
- payload: { documentId, rects: allRects }
57
- });
58
- const setSlices = (documentId, slices) => ({ type: SET_SLICES, payload: { documentId, slices } });
59
- const evictPageGeometry = (documentId, pages) => ({
60
- type: EVICT_PAGE_GEOMETRY,
61
- payload: { documentId, pages }
62
- });
63
- const initialSelectionDocumentState = {
64
- geometry: {},
65
- rects: {},
66
- slices: {},
67
- selection: null,
68
- active: false,
69
- selecting: false
70
- };
71
- const initialState = {
72
- documents: {}
73
- };
74
- const updateDocState = (state, documentId, newDocState) => ({
75
- ...state,
76
- documents: {
77
- ...state.documents,
78
- [documentId]: newDocState
79
- }
80
- });
81
- const selectionReducer = (state = initialState, action) => {
82
- switch (action.type) {
83
- case INIT_SELECTION_STATE: {
84
- const { documentId, state: docState } = action.payload;
85
- return updateDocState(state, documentId, docState);
86
- }
87
- case CLEANUP_SELECTION_STATE: {
88
- const documentId = action.payload;
89
- const { [documentId]: removed, ...remaining } = state.documents;
90
- return {
91
- ...state,
92
- documents: remaining
93
- };
94
- }
95
- case CACHE_PAGE_GEOMETRY: {
96
- const { documentId, page, geo } = action.payload;
97
- const docState = state.documents[documentId];
98
- if (!docState) return state;
99
- return updateDocState(state, documentId, {
100
- ...docState,
101
- geometry: { ...docState.geometry, [page]: geo }
102
- });
103
- }
104
- case SET_SELECTION: {
105
- const { documentId, selection } = action.payload;
106
- const docState = state.documents[documentId];
107
- if (!docState) return state;
108
- return updateDocState(state, documentId, {
109
- ...docState,
110
- selection,
111
- active: true
112
- });
113
- }
114
- case START_SELECTION: {
115
- const { documentId } = action.payload;
116
- const docState = state.documents[documentId];
117
- if (!docState) return state;
118
- return updateDocState(state, documentId, {
119
- ...docState,
120
- selecting: true,
121
- selection: null,
122
- rects: {}
123
- });
124
- }
125
- case END_SELECTION: {
126
- const { documentId } = action.payload;
127
- const docState = state.documents[documentId];
128
- if (!docState) return state;
129
- return updateDocState(state, documentId, { ...docState, selecting: false });
130
- }
131
- case CLEAR_SELECTION: {
132
- const { documentId } = action.payload;
133
- const docState = state.documents[documentId];
134
- if (!docState) return state;
135
- return updateDocState(state, documentId, {
136
- ...docState,
137
- selecting: false,
138
- selection: null,
139
- rects: {},
140
- active: false
141
- });
142
- }
143
- case SET_RECTS: {
144
- const { documentId, rects } = action.payload;
145
- const docState = state.documents[documentId];
146
- if (!docState) return state;
147
- return updateDocState(state, documentId, { ...docState, rects });
148
- }
149
- case SET_SLICES: {
150
- const { documentId, slices } = action.payload;
151
- const docState = state.documents[documentId];
152
- if (!docState) return state;
153
- return updateDocState(state, documentId, { ...docState, slices });
154
- }
155
- case EVICT_PAGE_GEOMETRY: {
156
- const { documentId, pages } = action.payload;
157
- const docState = state.documents[documentId];
158
- if (!docState) return state;
159
- const geometry = { ...docState.geometry };
160
- const rects = { ...docState.rects };
161
- const slices = { ...docState.slices };
162
- for (const p of pages) {
163
- delete geometry[p];
164
- delete rects[p];
165
- delete slices[p];
166
- }
167
- return updateDocState(state, documentId, { ...docState, geometry, rects, slices });
168
- }
169
- case RESET: {
170
- const { documentId } = action.payload;
171
- const docState = state.documents[documentId];
172
- if (!docState) return state;
173
- return updateDocState(state, documentId, initialSelectionDocumentState);
174
- }
175
- default:
176
- return state;
177
- }
178
- };
179
- function selectRectsForPage(state, page) {
180
- return state.rects[page] ?? [];
181
- }
182
- function selectBoundingRectForPage(state, page) {
183
- return boundingRect(selectRectsForPage(state, page));
27
+ /**
28
+ * Flatten a page's text geometry into content-space glyphs + runs. Rotation does
29
+ * NOT enter here — the overlay rides the page's CSS rotation, so glyphs stay in
30
+ * un-rotated content space (the crop-relative y-flip is the only conversion).
31
+ */
32
+ function buildPageText(snapshot, crop, rotation, userUnit) {
33
+ const { pdfToContent } = pageGeometry({
34
+ crop,
35
+ rotation,
36
+ userUnit
37
+ }, 1);
38
+ const glyphs = [];
39
+ const runs = [];
40
+ for (const run of snapshot.runs) {
41
+ const start = glyphs.length;
42
+ for (const g of run.glyphs) glyphs.push({
43
+ loose: toContent(pdfToContent, g.looseBox),
44
+ tight: g.tightBox ? toContent(pdfToContent, g.tightBox) : void 0,
45
+ flags: g.flags
46
+ });
47
+ runs.push({
48
+ start,
49
+ count: run.glyphs.length,
50
+ rect: toContent(pdfToContent, run.rect),
51
+ fontSize: run.fontSize
52
+ });
53
+ }
54
+ return {
55
+ glyphs,
56
+ runs
57
+ };
184
58
  }
185
- function selectBoundingRectsForAllPages(state) {
186
- const out = [];
187
- const rectMap = state.rects;
188
- for (const key in rectMap) {
189
- const page = Number(key);
190
- const bRect = boundingRect(rectMap[page]);
191
- if (bRect) out.push({ page, rect: bRect });
192
- }
193
- return out;
59
+ function avgGlyphHeight(text) {
60
+ let total = 0;
61
+ let count = 0;
62
+ for (const g of text.glyphs) {
63
+ if (isEmpty(g.flags)) continue;
64
+ total += g.loose.height;
65
+ count++;
66
+ }
67
+ return count === 0 ? 0 : total / count;
194
68
  }
195
- function getFormattedSelectionForPage(state, page) {
196
- const segmentRects = state.rects[page] || [];
197
- if (segmentRects.length === 0) return null;
198
- const boundingRect2 = selectBoundingRectForPage(state, page);
199
- if (!boundingRect2) return null;
200
- return { pageIndex: page, rect: boundingRect2, segmentRects };
69
+ /**
70
+ * The glyph index at a content-space point, or null when nothing is near (so the
71
+ * caller can show the pointer cursor off-text). PDFium `GetIndexAtPos`: exact
72
+ * tight-box containment first, then a tolerance pass (closest by Manhattan
73
+ * distance within `toleranceFactor × average glyph height`).
74
+ */
75
+ function glyphAt(text, p, toleranceFactor = 1.5) {
76
+ for (const run of text.runs) {
77
+ const r = run.rect;
78
+ if (p.x < r.x || p.x > r.x + r.width || p.y < r.y || p.y > r.y + r.height) continue;
79
+ for (let i = 0; i < run.count; i++) {
80
+ const b = text.glyphs[run.start + i].tight ?? text.glyphs[run.start + i].loose;
81
+ if (p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height) return run.start + i;
82
+ }
83
+ }
84
+ if (toleranceFactor <= 0) return null;
85
+ const half = avgGlyphHeight(text) * toleranceFactor / 2;
86
+ let best = -1;
87
+ let bestDist = Infinity;
88
+ for (const run of text.runs) {
89
+ const r = run.rect;
90
+ if (p.y < r.y - half || p.y > r.y + r.height + half || p.x < r.x - half || p.x > r.x + r.width + half) continue;
91
+ for (let i = 0; i < run.count; i++) {
92
+ const g = text.glyphs[run.start + i];
93
+ if (isEmpty(g.flags)) continue;
94
+ const b = g.tight ?? g.loose;
95
+ if (p.x < b.x - half || p.x > b.x + b.width + half || p.y < b.y - half || p.y > b.y + b.height + half) continue;
96
+ const dx = Math.min(Math.abs(p.x - b.x), Math.abs(p.x - (b.x + b.width)));
97
+ const dy = Math.min(Math.abs(p.y - b.y), Math.abs(p.y - (b.y + b.height)));
98
+ if (dx + dy < bestDist) {
99
+ bestDist = dx + dy;
100
+ best = run.start + i;
101
+ }
102
+ }
103
+ }
104
+ return best >= 0 ? best : null;
201
105
  }
202
- function getFormattedSelection(state) {
203
- const result = [];
204
- const pages = Object.keys(state.rects).map(Number);
205
- for (const pageIndex of pages) {
206
- const segmentRects = state.rects[pageIndex] || [];
207
- if (segmentRects.length === 0) continue;
208
- const boundingRect2 = selectBoundingRectForPage(state, pageIndex);
209
- if (boundingRect2) {
210
- result.push({
211
- pageIndex,
212
- rect: boundingRect2,
213
- segmentRects
214
- });
215
- }
216
- }
217
- return result;
106
+ /** Double-click: the word around `i` (walk to space/empty glyphs both ways). */
107
+ function expandToWord(text, i) {
108
+ const n = text.glyphs.length;
109
+ if (i < 0 || i >= n) return [i, i];
110
+ let from = i;
111
+ while (from > 0 && !isBoundary(text.glyphs[from - 1].flags)) from--;
112
+ let to = i;
113
+ while (to < n - 1 && !isBoundary(text.glyphs[to + 1].flags)) to++;
114
+ return [from, to];
218
115
  }
219
- function glyphAt(geo, pt, toleranceFactor = 1.5) {
220
- for (const run of geo.runs) {
221
- const inRun = pt.y >= run.rect.y && pt.y <= run.rect.y + run.rect.height && pt.x >= run.rect.x && pt.x <= run.rect.x + run.rect.width;
222
- if (!inRun) continue;
223
- const rel = run.glyphs.findIndex((g) => {
224
- const gx = g.tightX ?? g.x;
225
- const gy = g.tightY ?? g.y;
226
- const gw = g.tightWidth ?? g.width;
227
- const gh = g.tightHeight ?? g.height;
228
- return pt.x >= gx && pt.x <= gx + gw && pt.y >= gy && pt.y <= gy + gh;
229
- });
230
- if (rel !== -1) {
231
- return run.charStart + rel;
232
- }
233
- }
234
- if (toleranceFactor <= 0) return -1;
235
- const tolerance = computeTolerance(geo, toleranceFactor);
236
- const halfTol = tolerance / 2;
237
- let bestIndex = -1;
238
- let bestDist = Infinity;
239
- for (const run of geo.runs) {
240
- if (pt.y < run.rect.y - halfTol || pt.y > run.rect.y + run.rect.height + halfTol || pt.x < run.rect.x - halfTol || pt.x > run.rect.x + run.rect.width + halfTol) {
241
- continue;
242
- }
243
- for (let i = 0; i < run.glyphs.length; i++) {
244
- const g = run.glyphs[i];
245
- if (g.flags === 2) continue;
246
- const gx = g.tightX ?? g.x;
247
- const gy = g.tightY ?? g.y;
248
- const gw = g.tightWidth ?? g.width;
249
- const gh = g.tightHeight ?? g.height;
250
- const expandedLeft = gx - halfTol;
251
- const expandedRight = gx + gw + halfTol;
252
- const expandedTop = gy - halfTol;
253
- const expandedBottom = gy + gh + halfTol;
254
- if (pt.x < expandedLeft || pt.x > expandedRight || pt.y < expandedTop || pt.y > expandedBottom) {
255
- continue;
256
- }
257
- const curXdif = Math.min(Math.abs(pt.x - gx), Math.abs(pt.x - (gx + gw)));
258
- const curYdif = Math.min(Math.abs(pt.y - gy), Math.abs(pt.y - (gy + gh)));
259
- const dist = curXdif + curYdif;
260
- if (dist < bestDist) {
261
- bestDist = dist;
262
- bestIndex = run.charStart + i;
263
- }
264
- }
265
- }
266
- return bestIndex;
116
+ /** Triple-click: the full visual line — runs whose vertical extent overlaps the anchor run's. */
117
+ function expandToLine(text, i) {
118
+ const ri = text.runs.findIndex((r) => i >= r.start && i < r.start + r.count);
119
+ if (ri < 0) return [i, i];
120
+ const anchor = text.runs[ri];
121
+ const top = anchor.rect.y;
122
+ const bottom = anchor.rect.y + anchor.rect.height;
123
+ let from = anchor.start;
124
+ let to = anchor.start + anchor.count - 1;
125
+ for (let r = ri - 1; r >= 0; r--) {
126
+ const run = text.runs[r];
127
+ if (isZero(run.rect)) continue;
128
+ if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
129
+ from = run.start;
130
+ }
131
+ for (let r = ri + 1; r < text.runs.length; r++) {
132
+ const run = text.runs[r];
133
+ if (isZero(run.rect)) continue;
134
+ if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
135
+ to = run.start + run.count - 1;
136
+ }
137
+ return [from, to];
267
138
  }
268
- function computeTolerance(geo, factor) {
269
- let totalHeight = 0;
270
- let count = 0;
271
- for (const run of geo.runs) {
272
- for (const g of run.glyphs) {
273
- if (g.flags === 2) continue;
274
- totalHeight += g.height;
275
- count++;
276
- }
277
- }
278
- if (count === 0) return 0;
279
- return totalHeight / count * factor;
139
+ const CHAR_DISTANCE_FACTOR = 2.5;
140
+ const FONT_SIZE_RATIO_THRESHOLD = 1.5;
141
+ const VERTICAL_OVERLAP_THRESHOLD = .8;
142
+ const LINE_OVERLAP_THRESHOLD = .5;
143
+ function rectsForRange(text, from, to) {
144
+ const lo = Math.max(0, Math.min(from, to));
145
+ const hi = Math.min(text.glyphs.length - 1, Math.max(from, to));
146
+ const subRuns = [];
147
+ for (const run of text.runs) {
148
+ const runEnd = run.start + run.count - 1;
149
+ if (runEnd < lo || run.start > hi) continue;
150
+ const s = Math.max(lo, run.start);
151
+ const e = Math.min(hi, runEnd);
152
+ let minX = Infinity;
153
+ let maxX = -Infinity;
154
+ let minY = Infinity;
155
+ let maxY = -Infinity;
156
+ let charCount = 0;
157
+ let widthSum = 0;
158
+ let prevRight = -Infinity;
159
+ const flush = () => {
160
+ if (minX !== Infinity && charCount > 0) subRuns.push({
161
+ rect: {
162
+ x: minX,
163
+ y: minY,
164
+ width: maxX - minX,
165
+ height: maxY - minY
166
+ },
167
+ charCount,
168
+ fontSize: run.fontSize
169
+ });
170
+ minX = Infinity;
171
+ maxX = -Infinity;
172
+ minY = Infinity;
173
+ maxY = -Infinity;
174
+ charCount = 0;
175
+ widthSum = 0;
176
+ prevRight = -Infinity;
177
+ };
178
+ for (let gi = s; gi <= e; gi++) {
179
+ const g = text.glyphs[gi];
180
+ if (isEmpty(g.flags)) continue;
181
+ const b = g.loose;
182
+ if (charCount > 0 && prevRight > -Infinity) {
183
+ const avg = widthSum / charCount;
184
+ if (avg > 0 && Math.abs(b.x - prevRight) > CHAR_DISTANCE_FACTOR * avg) flush();
185
+ }
186
+ minX = Math.min(minX, b.x);
187
+ maxX = Math.max(maxX, b.x + b.width);
188
+ minY = Math.min(minY, b.y);
189
+ maxY = Math.max(maxY, b.y + b.height);
190
+ charCount++;
191
+ widthSum += b.width;
192
+ prevRight = b.x + b.width;
193
+ }
194
+ flush();
195
+ }
196
+ return mergeAdjacentRects(subRuns);
280
197
  }
281
- function sliceBounds(sel, geo, page) {
282
- if (!sel || !geo) return null;
283
- if (page < sel.start.page || page > sel.end.page) return null;
284
- const from = page === sel.start.page ? sel.start.index : 0;
285
- const lastRun = geo.runs[geo.runs.length - 1];
286
- const lastCharOnPage = lastRun.charStart + lastRun.glyphs.length - 1;
287
- const to = page === sel.end.page ? sel.end.index : lastCharOnPage;
288
- return { from, to };
198
+ function mergeAdjacentRects(runs) {
199
+ const out = [];
200
+ let prev = null;
201
+ let cur = null;
202
+ for (const run of runs) {
203
+ if (prev && cur && shouldMerge(prev, run)) cur = boundsOfRects([cur, run.rect]);
204
+ else {
205
+ if (cur) out.push(cur);
206
+ cur = run.rect;
207
+ }
208
+ prev = run;
209
+ }
210
+ if (cur && cur.width > 0 && cur.height > 0) out.push(cur);
211
+ return out;
289
212
  }
290
- function rectsWithinSlice(geo, from, to, merge = true) {
291
- const textRuns = [];
292
- const CHAR_DISTANCE_FACTOR = 2.5;
293
- for (const run of geo.runs) {
294
- const runStart = run.charStart;
295
- const runEnd = runStart + run.glyphs.length - 1;
296
- if (runEnd < from || runStart > to) continue;
297
- const sIdx = Math.max(from, runStart) - runStart;
298
- const eIdx = Math.min(to, runEnd) - runStart;
299
- let minX = Infinity, maxX = -Infinity;
300
- let minY = Infinity, maxY = -Infinity;
301
- let charCount = 0;
302
- let widthSum = 0;
303
- let prevRight = -Infinity;
304
- const flushSubRun = () => {
305
- if (minX !== Infinity && charCount > 0) {
306
- textRuns.push({
307
- rect: {
308
- origin: { x: minX, y: minY },
309
- size: { width: maxX - minX, height: maxY - minY }
310
- },
311
- charCount,
312
- fontSize: run.fontSize
313
- });
314
- }
315
- minX = Infinity;
316
- maxX = -Infinity;
317
- minY = Infinity;
318
- maxY = -Infinity;
319
- charCount = 0;
320
- widthSum = 0;
321
- prevRight = -Infinity;
322
- };
323
- for (let i = sIdx; i <= eIdx; i++) {
324
- const g = run.glyphs[i];
325
- if (g.flags === 2) continue;
326
- if (charCount > 0 && prevRight > -Infinity) {
327
- const gap = Math.abs(g.x - prevRight);
328
- const avgWidth = widthSum / charCount;
329
- if (avgWidth > 0 && gap > CHAR_DISTANCE_FACTOR * avgWidth) {
330
- flushSubRun();
331
- }
332
- }
333
- minX = Math.min(minX, g.x);
334
- maxX = Math.max(maxX, g.x + g.width);
335
- minY = Math.min(minY, g.y);
336
- maxY = Math.max(maxY, g.y + g.height);
337
- charCount++;
338
- widthSum += g.width;
339
- prevRight = g.x + g.width;
340
- }
341
- flushSubRun();
342
- }
343
- if (!merge) {
344
- return textRuns.map((run) => run.rect);
345
- }
346
- return mergeAdjacentRects(textRuns);
213
+ function shouldMerge(a, b) {
214
+ if (a.fontSize != null && b.fontSize != null && a.fontSize > 0 && b.fontSize > 0) {
215
+ if (Math.max(a.fontSize, b.fontSize) / Math.min(a.fontSize, b.fontSize) > FONT_SIZE_RATIO_THRESHOLD) return false;
216
+ }
217
+ if (verticalOverlap(a.rect, b.rect) < VERTICAL_OVERLAP_THRESHOLD) return false;
218
+ const aw = a.rect.width / a.charCount;
219
+ const bw = b.rect.width / b.charCount;
220
+ const aL = a.rect.x - aw;
221
+ const aR = a.rect.x + a.rect.width + aw;
222
+ const bL = b.rect.x - bw;
223
+ return aL < b.rect.x + b.rect.width + bw && aR > bL;
347
224
  }
348
- function rectUnion(rect1, rect2) {
349
- const left = Math.min(rect1.origin.x, rect2.origin.x);
350
- const top = Math.min(rect1.origin.y, rect2.origin.y);
351
- const right = Math.max(rect1.origin.x + rect1.size.width, rect2.origin.x + rect2.size.width);
352
- const bottom = Math.max(rect1.origin.y + rect1.size.height, rect2.origin.y + rect2.size.height);
353
- return {
354
- origin: { x: left, y: top },
355
- size: { width: right - left, height: bottom - top }
356
- };
225
+ function verticalOverlap(a, b) {
226
+ if (a.height <= 0 || b.height <= 0) return 0;
227
+ const u = Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y);
228
+ if (u === a.height || u === b.height) return 1;
229
+ return Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)) / u;
357
230
  }
358
- function rectIntersect(rect1, rect2) {
359
- const left = Math.max(rect1.origin.x, rect2.origin.x);
360
- const top = Math.max(rect1.origin.y, rect2.origin.y);
361
- const right = Math.min(rect1.origin.x + rect1.size.width, rect2.origin.x + rect2.size.width);
362
- const bottom = Math.min(rect1.origin.y + rect1.size.height, rect2.origin.y + rect2.size.height);
363
- const width = Math.max(0, right - left);
364
- const height = Math.max(0, bottom - top);
365
- return {
366
- origin: { x: left, y: top },
367
- size: { width, height }
368
- };
231
+ const isZero = (r) => r.width === 0 && r.height === 0;
232
+ function overlapV(top1, bottom1, top2, bottom2) {
233
+ const u = Math.max(bottom1, bottom2) - Math.min(top1, top2);
234
+ if (u === 0) return false;
235
+ return Math.max(0, Math.min(bottom1, bottom2) - Math.max(top1, top2)) / u >= LINE_OVERLAP_THRESHOLD;
369
236
  }
370
- function rectIsEmpty(rect) {
371
- return rect.size.width <= 0 || rect.size.height <= 0;
237
+ //#endregion
238
+ //#region src/capability.ts
239
+ const EMPTY = [];
240
+ /**
241
+ * The selection capability. The reducer state holds the selection range, the
242
+ * derived content-space rects (per page), and per-page loaded flags; the (large,
243
+ * non-serializable) per-page text geometry is cached HERE in the closure.
244
+ *
245
+ * Selection is cross-page: glyphs are ordered globally by (pageIndex, glyph), so
246
+ * a drag from page 2 into page 4 selects the tail of 2, all of 3, and the head of
247
+ * 4. `recompute` rebuilds the merged line rects for every loaded page in the span
248
+ * and re-runs whenever a mid-span page finishes loading.
249
+ */
250
+ function createSelectionCapability(ctx) {
251
+ const cache = /* @__PURE__ */ new Map();
252
+ const pending = /* @__PURE__ */ new Set();
253
+ const changeCbs = /* @__PURE__ */ new Set();
254
+ const commitCbs = /* @__PURE__ */ new Set();
255
+ const fireChange = () => changeCbs.forEach((cb) => cb());
256
+ const pageIndexOf = (pon) => ctx.document()?.pages.findIndex((p) => p.pageObjectNumber === pon) ?? -1;
257
+ const ponAtIndex = (i) => ctx.document()?.pages[i]?.pageObjectNumber;
258
+ function orderedEnds(sel) {
259
+ const ai = pageIndexOf(sel.anchor.pon);
260
+ const fi = pageIndexOf(sel.focus.pon);
261
+ return ai < fi || ai === fi && sel.anchor.glyph <= sel.focus.glyph ? {
262
+ start: sel.anchor,
263
+ end: sel.focus,
264
+ direction: "forward"
265
+ } : {
266
+ start: sel.focus,
267
+ end: sel.anchor,
268
+ direction: "backward"
269
+ };
270
+ }
271
+ function endpointFor(ptr, which) {
272
+ const rects = ctx.getState().rects[ptr.pon] ?? EMPTY;
273
+ if (!rects.length) return null;
274
+ return {
275
+ pon: ptr.pon,
276
+ rect: which === "start" ? rects[0] : rects[rects.length - 1]
277
+ };
278
+ }
279
+ function snapshot() {
280
+ const { selection, rects } = ctx.getState();
281
+ const pages = Object.keys(rects).map(Number).filter((pon) => (rects[pon]?.length ?? 0) > 0).map((pon) => ({
282
+ pon,
283
+ rects: rects[pon]
284
+ }));
285
+ if (!selection) return {
286
+ pages,
287
+ start: null,
288
+ end: null,
289
+ direction: "forward"
290
+ };
291
+ const { start, end, direction } = orderedEnds(selection);
292
+ return {
293
+ pages,
294
+ start: endpointFor(start, "start"),
295
+ end: endpointFor(end, "end"),
296
+ direction
297
+ };
298
+ }
299
+ function ensurePage(pon) {
300
+ if (cache.has(pon) || pending.has(pon)) return;
301
+ const doc = ctx.doc;
302
+ const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);
303
+ if (!doc || !layout) return;
304
+ pending.add(pon);
305
+ doc.page(pon).geometry.read().then((snapshot) => {
306
+ pending.delete(pon);
307
+ cache.set(pon, buildPageText(snapshot, layout.boxes.crop, layout.rotation, layout.userUnit));
308
+ ctx.dispatch({
309
+ type: "PAGE_LOADED",
310
+ pon
311
+ });
312
+ if (ctx.getState().selection) recompute();
313
+ }, () => {
314
+ pending.delete(pon);
315
+ });
316
+ }
317
+ function recompute(sel = ctx.getState().selection) {
318
+ if (!sel) return;
319
+ const { start, end } = orderedEnds(sel);
320
+ const si = pageIndexOf(start.pon);
321
+ const ei = pageIndexOf(end.pon);
322
+ if (si < 0 || ei < 0) return;
323
+ const rects = {};
324
+ for (let i = si; i <= ei; i++) {
325
+ const pon = ponAtIndex(i);
326
+ if (pon == null) continue;
327
+ const text = cache.get(pon);
328
+ if (!text) {
329
+ ensurePage(pon);
330
+ continue;
331
+ }
332
+ rects[pon] = rectsForRange(text, i === si ? start.glyph : 0, i === ei ? end.glyph : text.glyphs.length - 1);
333
+ }
334
+ ctx.dispatch({
335
+ type: "SET",
336
+ selection: sel,
337
+ rects
338
+ });
339
+ fireChange();
340
+ }
341
+ function selectSpan(pon, point, expand) {
342
+ const text = cache.get(pon);
343
+ if (!text) return;
344
+ const i = glyphAt(text, point);
345
+ if (i == null) return;
346
+ const [from, to] = expand === "word" ? expandToWord(text, i) : expandToLine(text, i);
347
+ recompute({
348
+ anchor: {
349
+ pon,
350
+ glyph: from
351
+ },
352
+ focus: {
353
+ pon,
354
+ glyph: to
355
+ }
356
+ });
357
+ }
358
+ return {
359
+ ensurePage,
360
+ isLoaded: (pon) => !!ctx.getState().loaded[pon],
361
+ isOverText: (pon, point) => {
362
+ const text = cache.get(pon);
363
+ return text ? glyphAt(text, point) != null : false;
364
+ },
365
+ beginAt: (pon, point) => {
366
+ const text = cache.get(pon);
367
+ if (!text) return false;
368
+ const i = glyphAt(text, point);
369
+ if (i == null) return false;
370
+ recompute({
371
+ anchor: {
372
+ pon,
373
+ glyph: i
374
+ },
375
+ focus: {
376
+ pon,
377
+ glyph: i
378
+ }
379
+ });
380
+ return true;
381
+ },
382
+ selectWord: (pon, point) => selectSpan(pon, point, "word"),
383
+ selectLine: (pon, point) => selectSpan(pon, point, "line"),
384
+ extendTo: (pon, point) => {
385
+ const cur = ctx.getState().selection;
386
+ if (!cur) return;
387
+ const text = cache.get(pon);
388
+ if (!text) {
389
+ ensurePage(pon);
390
+ return;
391
+ }
392
+ const i = glyphAt(text, point);
393
+ if (i == null) return;
394
+ recompute({
395
+ anchor: cur.anchor,
396
+ focus: {
397
+ pon,
398
+ glyph: i
399
+ }
400
+ });
401
+ },
402
+ end: () => commitCbs.forEach((cb) => cb()),
403
+ clear: () => {
404
+ ctx.dispatch({ type: "CLEAR" });
405
+ fireChange();
406
+ },
407
+ snapshot,
408
+ rectsForPage: (pon) => ctx.getState().rects[pon] ?? EMPTY,
409
+ hasSelection: () => ctx.getState().selection != null,
410
+ selectedPages: () => {
411
+ const { rects } = ctx.getState();
412
+ return Object.keys(rects).map(Number).filter((pon) => (rects[pon]?.length ?? 0) > 0);
413
+ },
414
+ onChange: (cb) => {
415
+ changeCbs.add(cb);
416
+ return () => changeCbs.delete(cb);
417
+ },
418
+ onCommit: (cb) => {
419
+ commitCbs.add(cb);
420
+ return () => commitCbs.delete(cb);
421
+ },
422
+ setHighlightVisible: (visible) => ctx.dispatch({
423
+ type: "SET_HIGHLIGHT_HIDDEN",
424
+ hidden: !visible
425
+ }),
426
+ highlightVisible: () => !ctx.getState().highlightHidden
427
+ };
372
428
  }
373
- function getVerticalOverlap(rect1, rect2) {
374
- if (rectIsEmpty(rect1) || rectIsEmpty(rect2)) return 0;
375
- const unionRect = rectUnion(rect1, rect2);
376
- if (unionRect.size.height === rect1.size.height || unionRect.size.height === rect2.size.height) {
377
- return 1;
378
- }
379
- const intersectRect = rectIntersect(rect1, rect2);
380
- return intersectRect.size.height / unionRect.size.height;
429
+ //#endregion
430
+ //#region src/handler.ts
431
+ const CURSOR_TOKEN = "selection-text";
432
+ const DRAG_THRESHOLD_PX = 4;
433
+ /**
434
+ * The text-selection pointer handler. It is live only under tools that enable the
435
+ * `'text-select'` tag (the built-in `pointer` tool does; `pan` does not — so
436
+ * switching to pan disables text selection with zero special-casing here).
437
+ *
438
+ * - single click → deselect; records an anchor but selects NOTHING until
439
+ * the pointer moves past the drag threshold (Chrome/Acrobat feel)
440
+ * - single-click + drag → caret selection from the anchor (extends across pages)
441
+ * - double-click → select the word
442
+ * - triple-click → select the visual line
443
+ * - down off-text → deselect, and DON'T capture (so it can't block)
444
+ * - hover → I-beam only when over text, else the pointer cursor
445
+ */
446
+ function createTextSelectHandler(selection, interaction) {
447
+ let anchor = null;
448
+ let dragging = false;
449
+ return {
450
+ id: "text-select",
451
+ priority: 60,
452
+ enabledFor: (tool) => tool.enables.has("text-select"),
453
+ onDown: (s) => {
454
+ anchor = null;
455
+ dragging = false;
456
+ if (!s.page) return false;
457
+ const { pon, point } = s.page;
458
+ const clicks = s.clickCount ?? 1;
459
+ if (clicks >= 3) {
460
+ selection.selectLine(pon, point);
461
+ return true;
462
+ }
463
+ if (clicks === 2) {
464
+ selection.selectWord(pon, point);
465
+ return true;
466
+ }
467
+ selection.clear();
468
+ if (!selection.isOverText(pon, point)) return false;
469
+ anchor = {
470
+ pon,
471
+ point,
472
+ vx: s.viewport.x,
473
+ vy: s.viewport.y
474
+ };
475
+ return true;
476
+ },
477
+ onMove: (s) => {
478
+ if (!s.page || !anchor) return;
479
+ if (!dragging) {
480
+ if (Math.hypot(s.viewport.x - anchor.vx, s.viewport.y - anchor.vy) < DRAG_THRESHOLD_PX) return;
481
+ dragging = true;
482
+ selection.beginAt(anchor.pon, anchor.point);
483
+ }
484
+ selection.extendTo(s.page.pon, s.page.point);
485
+ },
486
+ onUp: () => {
487
+ anchor = null;
488
+ dragging = false;
489
+ selection.end();
490
+ },
491
+ onHover: (s) => {
492
+ if (!s.page) {
493
+ interaction.setCursor(CURSOR_TOKEN, null);
494
+ return;
495
+ }
496
+ selection.ensurePage(s.page.pon);
497
+ const overText = selection.isOverText(s.page.pon, s.page.point);
498
+ interaction.setCursor(CURSOR_TOKEN, overText ? "text" : null, 10);
499
+ }
500
+ };
381
501
  }
382
- function shouldMergeHorizontalRects(textRun1, textRun2) {
383
- const FONT_SIZE_RATIO_THRESHOLD = 1.5;
384
- if (textRun1.fontSize != null && textRun2.fontSize != null && textRun1.fontSize > 0 && textRun2.fontSize > 0) {
385
- const ratio = Math.max(textRun1.fontSize, textRun2.fontSize) / Math.min(textRun1.fontSize, textRun2.fontSize);
386
- if (ratio > FONT_SIZE_RATIO_THRESHOLD) {
387
- return false;
388
- }
389
- }
390
- const VERTICAL_OVERLAP_THRESHOLD = 0.8;
391
- const rect1 = textRun1.rect;
392
- const rect2 = textRun2.rect;
393
- if (getVerticalOverlap(rect1, rect2) < VERTICAL_OVERLAP_THRESHOLD) {
394
- return false;
395
- }
396
- const HORIZONTAL_WIDTH_FACTOR = 1;
397
- const averageWidth1 = HORIZONTAL_WIDTH_FACTOR * rect1.size.width / textRun1.charCount;
398
- const averageWidth2 = HORIZONTAL_WIDTH_FACTOR * rect2.size.width / textRun2.charCount;
399
- const rect1Left = rect1.origin.x - averageWidth1;
400
- const rect1Right = rect1.origin.x + rect1.size.width + averageWidth1;
401
- const rect2Left = rect2.origin.x - averageWidth2;
402
- const rect2Right = rect2.origin.x + rect2.size.width + averageWidth2;
403
- return rect1Left < rect2Right && rect1Right > rect2Left;
404
- }
405
- function mergeAdjacentRects(textRuns) {
406
- const results = [];
407
- let previousTextRun = null;
408
- let currentRect = null;
409
- for (const textRun of textRuns) {
410
- if (previousTextRun && currentRect) {
411
- if (shouldMergeHorizontalRects(previousTextRun, textRun)) {
412
- currentRect = rectUnion(currentRect, textRun.rect);
413
- } else {
414
- results.push(currentRect);
415
- currentRect = textRun.rect;
416
- }
417
- } else {
418
- currentRect = textRun.rect;
419
- }
420
- previousTextRun = textRun;
421
- }
422
- if (currentRect && !rectIsEmpty(currentRect)) {
423
- results.push(currentRect);
424
- }
425
- return results;
426
- }
427
- const VERTICAL_OVERLAP_THRESHOLD_LINE = 0.5;
428
- function resolveCharIndex(geo, charIndex) {
429
- for (let r = 0; r < geo.runs.length; r++) {
430
- const run = geo.runs[r];
431
- const localIdx = charIndex - run.charStart;
432
- if (localIdx >= 0 && localIdx < run.glyphs.length) {
433
- return { runIdx: r, localIdx };
434
- }
435
- }
436
- return null;
437
- }
438
- function isGlyphWordBoundary(flags) {
439
- return flags === 1 || flags === 2;
440
- }
441
- function expandToWordBoundary(geo, charIndex) {
442
- const resolved = resolveCharIndex(geo, charIndex);
443
- if (!resolved) return null;
444
- const totalChars = getTotalCharCount(geo);
445
- if (totalChars === 0) return null;
446
- let from = charIndex;
447
- while (from > 0) {
448
- const prev = resolveCharIndex(geo, from - 1);
449
- if (!prev) break;
450
- if (isGlyphWordBoundary(geo.runs[prev.runIdx].glyphs[prev.localIdx].flags)) break;
451
- from--;
452
- }
453
- let to = charIndex;
454
- while (to < totalChars - 1) {
455
- const next = resolveCharIndex(geo, to + 1);
456
- if (!next) break;
457
- if (isGlyphWordBoundary(geo.runs[next.runIdx].glyphs[next.localIdx].flags)) break;
458
- to++;
459
- }
460
- return { from, to };
461
- }
462
- function expandToLineBoundary(geo, charIndex) {
463
- const resolved = resolveCharIndex(geo, charIndex);
464
- if (!resolved) return null;
465
- const anchorRun = geo.runs[resolved.runIdx];
466
- const anchorTop = anchorRun.rect.y;
467
- const anchorBottom = anchorRun.rect.y + anchorRun.rect.height;
468
- let from = anchorRun.charStart;
469
- let to = anchorRun.charStart + anchorRun.glyphs.length - 1;
470
- for (let r = resolved.runIdx - 1; r >= 0; r--) {
471
- const run = geo.runs[r];
472
- if (isZeroSizeRun(run)) continue;
473
- if (!runsOverlapVertically(run.rect.y, run.rect.y + run.rect.height, anchorTop, anchorBottom)) {
474
- break;
475
- }
476
- from = run.charStart;
477
- }
478
- for (let r = resolved.runIdx + 1; r < geo.runs.length; r++) {
479
- const run = geo.runs[r];
480
- if (isZeroSizeRun(run)) continue;
481
- if (!runsOverlapVertically(run.rect.y, run.rect.y + run.rect.height, anchorTop, anchorBottom)) {
482
- break;
483
- }
484
- to = run.charStart + run.glyphs.length - 1;
485
- }
486
- return { from, to };
487
- }
488
- function isZeroSizeRun(run) {
489
- return run.rect.width === 0 && run.rect.height === 0;
490
- }
491
- function runsOverlapVertically(top1, bottom1, top2, bottom2) {
492
- const unionHeight = Math.max(bottom1, bottom2) - Math.min(top1, top2);
493
- const intersectHeight = Math.max(0, Math.min(bottom1, bottom2) - Math.max(top1, top2));
494
- if (unionHeight === 0) return false;
495
- return intersectHeight / unionHeight >= VERTICAL_OVERLAP_THRESHOLD_LINE;
496
- }
497
- function getTotalCharCount(geo) {
498
- if (geo.runs.length === 0) return 0;
499
- const lastRun = geo.runs[geo.runs.length - 1];
500
- return lastRun.charStart + lastRun.glyphs.length;
501
- }
502
- const TRIPLE_CLICK_INTERVAL_MS = 500;
503
- function createTextSelectionHandler(opts) {
504
- const minDrag = opts.minDragDistance ?? 3;
505
- const tolFactor = opts.toleranceFactor ?? 1.5;
506
- let anchorGlyph = null;
507
- let anchorPos = null;
508
- let dragStarted = false;
509
- let lastDblClickTime = 0;
510
- function reset() {
511
- var _a;
512
- anchorGlyph = null;
513
- anchorPos = null;
514
- dragStarted = false;
515
- (_a = opts.setHasTextAnchor) == null ? void 0 : _a.call(opts, false);
516
- }
517
- return {
518
- onPointerDown: (point, evt, modeId) => {
519
- var _a, _b;
520
- if (evt.target === evt.currentTarget) {
521
- (_a = opts.onEmptySpaceClick) == null ? void 0 : _a.call(opts, modeId);
522
- }
523
- if (!opts.isEnabled(modeId)) return;
524
- const now = Date.now();
525
- if (lastDblClickTime === 0 || now - lastDblClickTime >= TRIPLE_CLICK_INTERVAL_MS) {
526
- opts.onClear(modeId);
527
- }
528
- const geo = opts.getGeometry();
529
- if (!geo) return;
530
- const g = glyphAt(geo, point, tolFactor);
531
- if (g !== -1) {
532
- anchorGlyph = g;
533
- anchorPos = point;
534
- dragStarted = false;
535
- (_b = opts.setHasTextAnchor) == null ? void 0 : _b.call(opts, true);
536
- }
537
- },
538
- onPointerMove: (point, _evt, modeId) => {
539
- if (!opts.isEnabled(modeId)) return;
540
- const geo = opts.getGeometry();
541
- if (!geo) return;
542
- const g = glyphAt(geo, point, tolFactor);
543
- opts.setCursor(g !== -1 ? "text" : null);
544
- if (anchorGlyph !== null && anchorPos && !dragStarted) {
545
- const dx = point.x - anchorPos.x;
546
- const dy = point.y - anchorPos.y;
547
- const dist = Math.sqrt(dx * dx + dy * dy);
548
- if (dist >= minDrag) {
549
- dragStarted = true;
550
- opts.onBegin(anchorGlyph, modeId);
551
- if (g !== -1) {
552
- opts.onUpdate(g, modeId);
553
- }
554
- }
555
- return;
556
- }
557
- if (opts.isSelecting() && g !== -1) {
558
- opts.onUpdate(g, modeId);
559
- }
560
- },
561
- onPointerUp: (_point, _evt, modeId) => {
562
- if (!opts.isEnabled(modeId)) {
563
- reset();
564
- return;
565
- }
566
- if (dragStarted) {
567
- opts.onEnd(modeId);
568
- }
569
- reset();
570
- },
571
- onDoubleClick: (point, _evt, modeId) => {
572
- var _a;
573
- if (!opts.isEnabled(modeId)) return;
574
- const geo = opts.getGeometry();
575
- if (!geo) return;
576
- const g = glyphAt(geo, point, tolFactor);
577
- if (g === -1) return;
578
- lastDblClickTime = Date.now();
579
- (_a = opts.onWordSelect) == null ? void 0 : _a.call(opts, g, modeId);
580
- },
581
- onClick: (point, _evt, modeId) => {
582
- var _a;
583
- if (!opts.isEnabled(modeId)) return;
584
- if (lastDblClickTime === 0) return;
585
- const now = Date.now();
586
- if (now - lastDblClickTime > TRIPLE_CLICK_INTERVAL_MS) {
587
- lastDblClickTime = 0;
588
- return;
589
- }
590
- lastDblClickTime = 0;
591
- const geo = opts.getGeometry();
592
- if (!geo) return;
593
- const g = glyphAt(geo, point, tolFactor);
594
- if (g === -1) return;
595
- (_a = opts.onLineSelect) == null ? void 0 : _a.call(opts, g, modeId);
596
- },
597
- onHandlerActiveEnd: (modeId) => {
598
- reset();
599
- if (!opts.isEnabled(modeId)) return;
600
- opts.onClear(modeId);
601
- }
602
- };
603
- }
604
- function createMarqueeSelectionHandler(opts) {
605
- const { pageSize, scale, minDragPx = 5 } = opts;
606
- let start = null;
607
- let last = null;
608
- return {
609
- onPointerDown: (pos, evt, modeId) => {
610
- var _a, _b;
611
- if (!opts.isEnabled(modeId)) return;
612
- if ((_a = opts.isTextSelecting) == null ? void 0 : _a.call(opts)) return;
613
- start = pos;
614
- last = { origin: { x: pos.x, y: pos.y }, size: { width: 0, height: 0 } };
615
- opts.onBegin(pos, modeId);
616
- (_b = evt.setPointerCapture) == null ? void 0 : _b.call(evt);
617
- },
618
- onPointerMove: (pos, _evt, modeId) => {
619
- if (!start || !opts.isEnabled(modeId)) return;
620
- const x = clamp(pos.x, 0, pageSize.width);
621
- const y = clamp(pos.y, 0, pageSize.height);
622
- last = {
623
- origin: { x: Math.min(start.x, x), y: Math.min(start.y, y) },
624
- size: { width: Math.abs(x - start.x), height: Math.abs(y - start.y) }
625
- };
626
- opts.onChange(last, modeId);
627
- },
628
- onPointerUp: (_pos, evt, modeId) => {
629
- var _a;
630
- if (!opts.isEnabled(modeId)) return;
631
- if (last && start) {
632
- const dragPx = Math.max(last.size.width, last.size.height) * scale;
633
- if (dragPx > minDragPx) {
634
- opts.onEnd(last, modeId);
635
- } else {
636
- opts.onCancel(modeId);
637
- }
638
- }
639
- start = null;
640
- last = null;
641
- (_a = evt.releasePointerCapture) == null ? void 0 : _a.call(evt);
642
- },
643
- onPointerCancel: (_pos, evt, modeId) => {
644
- var _a;
645
- if (!opts.isEnabled(modeId)) return;
646
- start = null;
647
- last = null;
648
- opts.onCancel(modeId);
649
- (_a = evt.releasePointerCapture) == null ? void 0 : _a.call(evt);
650
- }
651
- };
652
- }
653
- const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
654
- constructor(id, registry, config) {
655
- var _a, _b, _c;
656
- super(id, registry);
657
- this.enabledModesPerDoc = /* @__PURE__ */ new Map();
658
- this.selecting = /* @__PURE__ */ new Map();
659
- this.anchor = /* @__PURE__ */ new Map();
660
- this.hasTextAnchor = /* @__PURE__ */ new Map();
661
- this.marqueePage = /* @__PURE__ */ new Map();
662
- this.pageCallbacks = /* @__PURE__ */ new Map();
663
- this.geoAccessOrder = /* @__PURE__ */ new Map();
664
- this.menuPlacement$ = createScopedEmitter((documentId, placement) => ({ documentId, placement }));
665
- this.selChange$ = createScopedEmitter((documentId, selection) => ({
666
- documentId,
667
- selection,
668
- modeId: this.interactionManagerCapability.forDocument(documentId).getActiveMode()
669
- }));
670
- this.textRetrieved$ = createScopedEmitter(
671
- (documentId, text) => ({ documentId, text })
672
- );
673
- this.copyToClipboard$ = createScopedEmitter(
674
- (documentId, text) => ({ documentId, text }),
675
- { cache: false }
676
- );
677
- this.beginSelection$ = createScopedEmitter(
678
- (documentId, data) => ({
679
- documentId,
680
- page: data.page,
681
- index: data.index,
682
- modeId: data.modeId
683
- }),
684
- { cache: false }
685
- );
686
- this.endSelection$ = createScopedEmitter((documentId, data) => ({ documentId, modeId: data.modeId }), { cache: false });
687
- this.marqueeChange$ = createScopedEmitter(
688
- (documentId, data) => ({
689
- documentId,
690
- pageIndex: data.pageIndex,
691
- rect: data.rect,
692
- modeId: data.modeId
693
- }),
694
- { cache: false }
695
- );
696
- this.marqueeEnd$ = createScopedEmitter(
697
- (documentId, data) => ({
698
- documentId,
699
- pageIndex: data.pageIndex,
700
- rect: data.rect,
701
- modeId: data.modeId
702
- }),
703
- { cache: false }
704
- );
705
- this.emptySpaceClick$ = createScopedEmitter(
706
- (documentId, data) => ({
707
- documentId,
708
- pageIndex: data.pageIndex,
709
- modeId: data.modeId
710
- }),
711
- { cache: false }
712
- );
713
- this.viewportCapability = null;
714
- this.scrollCapability = null;
715
- this.config = config;
716
- this.menuHeight = config.menuHeight ?? 40;
717
- const imPlugin = registry.getPlugin("interaction-manager");
718
- if (!imPlugin) {
719
- throw new Error("SelectionPlugin: InteractionManagerPlugin is required.");
720
- }
721
- this.interactionManagerCapability = imPlugin.provides();
722
- this.viewportCapability = ((_a = registry.getPlugin("viewport")) == null ? void 0 : _a.provides()) ?? null;
723
- this.scrollCapability = ((_b = registry.getPlugin("scroll")) == null ? void 0 : _b.provides()) ?? null;
724
- this.coreStore.onAction(REFRESH_PAGES, (action) => {
725
- const { documentId, pageIndexes } = action.payload;
726
- const tasks = pageIndexes.map(
727
- (pageIndex) => this.getNewPageGeometryAndCache(documentId, pageIndex)
728
- );
729
- Task.all(tasks).wait(() => {
730
- pageIndexes.forEach((pageIndex) => {
731
- this.notifyPage(documentId, pageIndex);
732
- });
733
- }, ignore);
734
- });
735
- (_c = this.viewportCapability) == null ? void 0 : _c.onViewportChange(
736
- (event) => {
737
- this.recalculateMenuPlacement(event.documentId);
738
- },
739
- { mode: "throttle", wait: 100 }
740
- );
741
- }
742
- /* ── life-cycle ────────────────────────────────────────── */
743
- onDocumentLoadingStarted(documentId) {
744
- var _a;
745
- this.dispatch(initSelectionState(documentId, initialSelectionDocumentState));
746
- const marqueeEnabled = ((_a = this.config.marquee) == null ? void 0 : _a.enabled) !== false;
747
- this.enabledModesPerDoc.set(
748
- documentId,
749
- /* @__PURE__ */ new Map([
750
- [
751
- "pointerMode",
752
- {
753
- enableSelection: true,
754
- showSelectionRects: true,
755
- enableMarquee: marqueeEnabled,
756
- showMarqueeRects: true
757
- }
758
- ]
759
- ])
760
- );
761
- this.pageCallbacks.set(documentId, /* @__PURE__ */ new Map());
762
- this.geoAccessOrder.set(documentId, []);
763
- this.selecting.set(documentId, false);
764
- this.anchor.set(documentId, void 0);
765
- this.hasTextAnchor.set(documentId, false);
766
- }
767
- onDocumentClosed(documentId) {
768
- this.dispatch(cleanupSelectionState(documentId));
769
- this.enabledModesPerDoc.delete(documentId);
770
- this.pageCallbacks.delete(documentId);
771
- this.geoAccessOrder.delete(documentId);
772
- this.selecting.delete(documentId);
773
- this.hasTextAnchor.delete(documentId);
774
- this.anchor.delete(documentId);
775
- this.marqueePage.delete(documentId);
776
- this.selChange$.clearScope(documentId);
777
- this.textRetrieved$.clearScope(documentId);
778
- this.copyToClipboard$.clearScope(documentId);
779
- this.beginSelection$.clearScope(documentId);
780
- this.endSelection$.clearScope(documentId);
781
- this.menuPlacement$.clearScope(documentId);
782
- this.marqueeChange$.clearScope(documentId);
783
- this.marqueeEnd$.clearScope(documentId);
784
- this.emptySpaceClick$.clearScope(documentId);
785
- }
786
- async initialize() {
787
- }
788
- async destroy() {
789
- this.selChange$.clear();
790
- this.textRetrieved$.clear();
791
- this.copyToClipboard$.clear();
792
- this.beginSelection$.clear();
793
- this.endSelection$.clear();
794
- this.menuPlacement$.clear();
795
- this.marqueeChange$.clear();
796
- this.marqueeEnd$.clear();
797
- this.emptySpaceClick$.clear();
798
- super.destroy();
799
- }
800
- /* ── capability exposed to UI / other plugins ─────────── */
801
- buildCapability() {
802
- const getDocId = (documentId) => documentId ?? this.getActiveDocumentId();
803
- return {
804
- // Active document operations
805
- getFormattedSelection: (docId) => getFormattedSelection(this.getDocumentState(getDocId(docId))),
806
- getFormattedSelectionForPage: (p, docId) => getFormattedSelectionForPage(this.getDocumentState(getDocId(docId)), p),
807
- getHighlightRectsForPage: (p, docId) => selectRectsForPage(this.getDocumentState(getDocId(docId)), p),
808
- getHighlightRects: (docId) => this.getDocumentState(getDocId(docId)).rects,
809
- getBoundingRectForPage: (p, docId) => selectBoundingRectForPage(this.getDocumentState(getDocId(docId)), p),
810
- getBoundingRects: (docId) => selectBoundingRectsForAllPages(this.getDocumentState(getDocId(docId))),
811
- getSelectedText: (docId) => this.getSelectedText(getDocId(docId)),
812
- clear: (docId) => this.clearSelection(getDocId(docId)),
813
- copyToClipboard: (docId) => this.copyToClipboard(getDocId(docId)),
814
- getState: (docId) => this.getDocumentState(getDocId(docId)),
815
- enableForMode: (modeId, options, docId) => {
816
- var _a;
817
- return (_a = this.enabledModesPerDoc.get(getDocId(docId))) == null ? void 0 : _a.set(modeId, { ...options });
818
- },
819
- isEnabledForMode: (modeId, docId) => {
820
- var _a;
821
- return ((_a = this.enabledModesPerDoc.get(getDocId(docId))) == null ? void 0 : _a.has(modeId)) ?? false;
822
- },
823
- setMarqueeEnabled: (enabled, docId) => this.setMarqueeEnabled(getDocId(docId), enabled),
824
- isMarqueeEnabled: (docId) => this.isMarqueeEnabled(getDocId(docId)),
825
- // Document-scoped operations
826
- forDocument: this.createSelectionScope.bind(this),
827
- // Global events
828
- onCopyToClipboard: this.copyToClipboard$.onGlobal,
829
- onSelectionChange: this.selChange$.onGlobal,
830
- onTextRetrieved: this.textRetrieved$.onGlobal,
831
- onBeginSelection: this.beginSelection$.onGlobal,
832
- onEndSelection: this.endSelection$.onGlobal,
833
- // Marquee selection events
834
- onMarqueeChange: this.marqueeChange$.onGlobal,
835
- onMarqueeEnd: this.marqueeEnd$.onGlobal,
836
- // Empty space click event
837
- onEmptySpaceClick: this.emptySpaceClick$.onGlobal
838
- };
839
- }
840
- createSelectionScope(documentId) {
841
- return {
842
- getFormattedSelection: () => getFormattedSelection(this.getDocumentState(documentId)),
843
- getFormattedSelectionForPage: (p) => getFormattedSelectionForPage(this.getDocumentState(documentId), p),
844
- getHighlightRectsForPage: (p) => selectRectsForPage(this.getDocumentState(documentId), p),
845
- getHighlightRects: () => this.getDocumentState(documentId).rects,
846
- getBoundingRectForPage: (p) => selectBoundingRectForPage(this.getDocumentState(documentId), p),
847
- getBoundingRects: () => selectBoundingRectsForAllPages(this.getDocumentState(documentId)),
848
- getSelectedText: () => this.getSelectedText(documentId),
849
- clear: () => this.clearSelection(documentId),
850
- copyToClipboard: () => this.copyToClipboard(documentId),
851
- getState: () => this.getDocumentState(documentId),
852
- setMarqueeEnabled: (enabled) => this.setMarqueeEnabled(documentId, enabled),
853
- isMarqueeEnabled: () => this.isMarqueeEnabled(documentId),
854
- onSelectionChange: this.selChange$.forScope(documentId),
855
- onTextRetrieved: this.textRetrieved$.forScope(documentId),
856
- onCopyToClipboard: this.copyToClipboard$.forScope(documentId),
857
- onBeginSelection: this.beginSelection$.forScope(documentId),
858
- onEndSelection: this.endSelection$.forScope(documentId),
859
- onMarqueeChange: this.marqueeChange$.forScope(documentId),
860
- onMarqueeEnd: this.marqueeEnd$.forScope(documentId),
861
- onEmptySpaceClick: this.emptySpaceClick$.forScope(documentId)
862
- };
863
- }
864
- getDocumentState(documentId) {
865
- const state = this.state.documents[documentId];
866
- if (!state) {
867
- throw new Error(`Selection state not found for document: ${documentId}`);
868
- }
869
- return state;
870
- }
871
- /**
872
- * Subscribe to menu placement changes for a specific document
873
- * @param documentId - The document ID to subscribe to
874
- * @param listener - Callback function that receives placement updates
875
- * @returns Unsubscribe function
876
- */
877
- onMenuPlacement(documentId, listener) {
878
- return this.menuPlacement$.forScope(documentId)(listener);
879
- }
880
- /**
881
- * Register text selection on a page. Uses `registerAlways` so any plugin
882
- * can enable text selection for their mode via `enableForMode()`.
883
- */
884
- registerSelectionOnPage(opts) {
885
- var _a;
886
- const { documentId, pageIndex, onRectsChange } = opts;
887
- const docState = this.state.documents[documentId];
888
- if (!docState) {
889
- this.logger.warn(
890
- "SelectionPlugin",
891
- "RegisterFailed",
892
- `Cannot register selection on page ${pageIndex} for document ${documentId}: document state not initialized.`
893
- );
894
- return () => {
895
- };
896
- }
897
- (_a = this.pageCallbacks.get(documentId)) == null ? void 0 : _a.set(pageIndex, onRectsChange);
898
- const geoTask = this.getOrLoadGeometry(documentId, pageIndex);
899
- const interactionScope = this.interactionManagerCapability.forDocument(documentId);
900
- const enabledModes = this.enabledModesPerDoc.get(documentId);
901
- onRectsChange({
902
- rects: selectRectsForPage(docState, pageIndex),
903
- boundingRect: selectBoundingRectForPage(docState, pageIndex)
904
- });
905
- geoTask.wait((geo) => {
906
- const currentState = this.getDocumentState(documentId);
907
- const sel = currentState.selection;
908
- if (!sel || pageIndex < sel.start.page || pageIndex > sel.end.page) return;
909
- const sb = sliceBounds(sel, geo, pageIndex);
910
- if (!sb) return;
911
- const pageRects = rectsWithinSlice(geo, sb.from, sb.to);
912
- this.dispatch(setRects(documentId, { ...currentState.rects, [pageIndex]: pageRects }));
913
- this.dispatch(
914
- setSlices(documentId, {
915
- ...currentState.slices,
916
- [pageIndex]: { start: sb.from, count: sb.to - sb.from + 1 }
917
- })
918
- );
919
- this.notifyPage(documentId, pageIndex);
920
- }, ignore);
921
- const textHandler = createTextSelectionHandler({
922
- getGeometry: () => this.getDocumentState(documentId).geometry[pageIndex],
923
- isEnabled: (modeId) => {
924
- const config = enabledModes == null ? void 0 : enabledModes.get(modeId);
925
- if (!config) return false;
926
- return config.enableSelection !== false;
927
- },
928
- onBegin: (g, modeId) => this.beginSelection(documentId, pageIndex, g, modeId),
929
- onUpdate: (g, modeId) => this.updateSelection(documentId, pageIndex, g, modeId),
930
- onEnd: (modeId) => this.endSelection(documentId, modeId),
931
- onClear: (modeId) => this.clearSelection(documentId, modeId),
932
- isSelecting: () => this.selecting.get(documentId) ?? false,
933
- setCursor: (cursor) => cursor ? interactionScope.setCursor("selection-text", cursor, 10) : interactionScope.removeCursor("selection-text"),
934
- onEmptySpaceClick: (modeId) => this.emptySpaceClick$.emit(documentId, { pageIndex, modeId }),
935
- onWordSelect: (g, modeId) => this.selectWord(documentId, pageIndex, g, modeId),
936
- onLineSelect: (g, modeId) => this.selectLine(documentId, pageIndex, g, modeId),
937
- setHasTextAnchor: (active) => this.hasTextAnchor.set(documentId, active),
938
- minDragDistance: this.config.minSelectionDragDistance,
939
- toleranceFactor: this.config.toleranceFactor
940
- });
941
- const unregisterHandlers = this.interactionManagerCapability.registerAlways({
942
- scope: { type: "page", documentId, pageIndex },
943
- handlers: textHandler
944
- });
945
- return () => {
946
- var _a2;
947
- unregisterHandlers();
948
- (_a2 = this.pageCallbacks.get(documentId)) == null ? void 0 : _a2.delete(pageIndex);
949
- geoTask.abort({ code: PdfErrorCode.Cancelled, message: "Cleanup" });
950
- };
951
- }
952
- /**
953
- * Register marquee selection on a page. Uses `registerAlways` so any plugin
954
- * can enable marquee selection for their mode via `enableForMode({ enableMarquee: true })`.
955
- */
956
- registerMarqueeOnPage(opts) {
957
- var _a;
958
- const { documentId, pageIndex, scale, onRectChange } = opts;
959
- const docState = this.state.documents[documentId];
960
- if (!docState) {
961
- this.logger.warn(
962
- "SelectionPlugin",
963
- "RegisterMarqueeFailed",
964
- `Cannot register marquee on page ${pageIndex} for document ${documentId}: document state not initialized.`
965
- );
966
- return () => {
967
- };
968
- }
969
- const coreDoc = this.coreState.core.documents[documentId];
970
- if (!coreDoc || !coreDoc.document) {
971
- this.logger.warn(
972
- "SelectionPlugin",
973
- "DocumentNotFound",
974
- `Cannot register marquee on page ${pageIndex}: document not found`
975
- );
976
- return () => {
977
- };
978
- }
979
- const page = coreDoc.document.pages[pageIndex];
980
- if (!page) {
981
- this.logger.warn(
982
- "SelectionPlugin",
983
- "PageNotFound",
984
- `Cannot register marquee on page ${pageIndex}: page not found`
985
- );
986
- return () => {
987
- };
988
- }
989
- const pageSize = page.size;
990
- const minDragPx = ((_a = this.config.marquee) == null ? void 0 : _a.minDragPx) ?? 5;
991
- const shouldShowRect = () => {
992
- var _a2;
993
- const mode = this.interactionManagerCapability.forDocument(documentId).getActiveMode();
994
- const config = (_a2 = this.enabledModesPerDoc.get(documentId)) == null ? void 0 : _a2.get(mode);
995
- return (config == null ? void 0 : config.showMarqueeRects) !== false;
996
- };
997
- const marqueeHandler = createMarqueeSelectionHandler({
998
- pageSize,
999
- scale,
1000
- minDragPx,
1001
- isEnabled: (modeId) => {
1002
- var _a2;
1003
- const config = (_a2 = this.enabledModesPerDoc.get(documentId)) == null ? void 0 : _a2.get(modeId);
1004
- return (config == null ? void 0 : config.enableMarquee) === true;
1005
- },
1006
- isTextSelecting: () => (this.selecting.get(documentId) ?? false) || (this.hasTextAnchor.get(documentId) ?? false),
1007
- onBegin: (pos, modeId) => this.beginMarquee(documentId, pageIndex, pos, modeId),
1008
- onChange: (rect, modeId) => {
1009
- this.updateMarquee(documentId, pageIndex, rect, modeId);
1010
- onRectChange(shouldShowRect() ? rect : null);
1011
- },
1012
- onEnd: (rect, modeId) => {
1013
- this.endMarquee(documentId, pageIndex, rect, modeId);
1014
- onRectChange(null);
1015
- },
1016
- onCancel: (modeId) => {
1017
- this.cancelMarquee(documentId, modeId);
1018
- onRectChange(null);
1019
- }
1020
- });
1021
- const unregisterHandlers = this.interactionManagerCapability.registerAlways({
1022
- scope: { type: "page", documentId, pageIndex },
1023
- handlers: marqueeHandler
1024
- });
1025
- return unregisterHandlers;
1026
- }
1027
- /**
1028
- * Helper to calculate viewport relative metrics for a page rect.
1029
- * Returns null if the rect cannot be converted to viewport space.
1030
- */
1031
- getPlacementMetrics(documentId, pageIndex, rect, vpMetrics) {
1032
- var _a;
1033
- const scrollScope = (_a = this.scrollCapability) == null ? void 0 : _a.forDocument(documentId);
1034
- const viewportRect = scrollScope == null ? void 0 : scrollScope.getRectPositionForPage(pageIndex, rect);
1035
- if (!viewportRect) return null;
1036
- const rectTopInView = viewportRect.origin.y - vpMetrics.scrollTop;
1037
- const rectBottomInView = viewportRect.origin.y + viewportRect.size.height - vpMetrics.scrollTop;
1038
- return {
1039
- pageIndex,
1040
- rect,
1041
- // Original Page Rect
1042
- spaceAbove: rectTopInView,
1043
- spaceBelow: vpMetrics.clientHeight - rectBottomInView,
1044
- isBottomVisible: rectBottomInView > 0 && rectBottomInView <= vpMetrics.clientHeight,
1045
- isTopVisible: rectTopInView >= 0 && rectTopInView < vpMetrics.clientHeight
1046
- };
1047
- }
1048
- emitMenuPlacement(documentId, placement) {
1049
- this.menuPlacement$.emit(documentId, placement);
1050
- if (placement) {
1051
- this.interactionManagerCapability.claimPageActivity(
1052
- documentId,
1053
- "selection-menu",
1054
- placement.pageIndex
1055
- );
1056
- } else {
1057
- this.interactionManagerCapability.releasePageActivity(documentId, "selection-menu");
1058
- }
1059
- }
1060
- recalculateMenuPlacement(documentId) {
1061
- const docState = this.state.documents[documentId];
1062
- if (!docState) return;
1063
- if (docState.selecting || docState.selection === null) {
1064
- this.emitMenuPlacement(documentId, null);
1065
- return;
1066
- }
1067
- const bounds = selectBoundingRectsForAllPages(docState);
1068
- if (bounds.length === 0) {
1069
- this.emitMenuPlacement(documentId, null);
1070
- return;
1071
- }
1072
- const tail = bounds[bounds.length - 1];
1073
- if (!this.viewportCapability || !this.scrollCapability) {
1074
- this.emitMenuPlacement(documentId, {
1075
- pageIndex: tail.page,
1076
- rect: tail.rect,
1077
- spaceAbove: 0,
1078
- spaceBelow: Infinity,
1079
- // Pretend we have infinite space below to prevent auto-flipping
1080
- suggestTop: false,
1081
- isVisible: true
1082
- // Assume visible
1083
- });
1084
- return;
1085
- }
1086
- const viewportScope = this.viewportCapability.forDocument(documentId);
1087
- const vpMetrics = viewportScope.getMetrics();
1088
- const head = bounds[0];
1089
- const tailMetrics = this.getPlacementMetrics(documentId, tail.page, tail.rect, vpMetrics);
1090
- const headMetrics = this.getPlacementMetrics(documentId, head.page, head.rect, vpMetrics);
1091
- if (tailMetrics) {
1092
- if (tailMetrics.isBottomVisible && tailMetrics.spaceBelow > this.menuHeight) {
1093
- this.emitMenuPlacement(documentId, {
1094
- ...tailMetrics,
1095
- suggestTop: false,
1096
- isVisible: true
1097
- });
1098
- return;
1099
- }
1100
- }
1101
- if (headMetrics) {
1102
- if (headMetrics.isTopVisible) {
1103
- this.emitMenuPlacement(documentId, {
1104
- ...headMetrics,
1105
- suggestTop: true,
1106
- isVisible: true
1107
- });
1108
- return;
1109
- }
1110
- }
1111
- if (tailMetrics && tailMetrics.isBottomVisible) {
1112
- this.emitMenuPlacement(documentId, {
1113
- ...tailMetrics,
1114
- suggestTop: false,
1115
- isVisible: true
1116
- });
1117
- return;
1118
- }
1119
- this.emitMenuPlacement(documentId, null);
1120
- }
1121
- notifyPage(documentId, pageIndex) {
1122
- var _a, _b;
1123
- const callback = (_a = this.pageCallbacks.get(documentId)) == null ? void 0 : _a.get(pageIndex);
1124
- if (callback) {
1125
- const docState = this.getDocumentState(documentId);
1126
- const mode = this.interactionManagerCapability.forDocument(documentId).getActiveMode();
1127
- const modeConfig = (_b = this.enabledModesPerDoc.get(documentId)) == null ? void 0 : _b.get(mode);
1128
- const shouldShowRects = modeConfig && (modeConfig.showSelectionRects ?? modeConfig.showRects) !== false;
1129
- if (shouldShowRects) {
1130
- callback({
1131
- rects: selectRectsForPage(docState, pageIndex),
1132
- boundingRect: selectBoundingRectForPage(docState, pageIndex)
1133
- });
1134
- } else {
1135
- callback({ rects: [], boundingRect: null });
1136
- }
1137
- }
1138
- }
1139
- notifyAllPages(documentId) {
1140
- var _a;
1141
- (_a = this.pageCallbacks.get(documentId)) == null ? void 0 : _a.forEach((_, pageIndex) => {
1142
- this.notifyPage(documentId, pageIndex);
1143
- });
1144
- }
1145
- getNewPageGeometryAndCache(documentId, pageIdx) {
1146
- const coreDoc = this.getCoreDocument(documentId);
1147
- if (!coreDoc || !coreDoc.document)
1148
- return PdfTaskHelper.reject({ code: PdfErrorCode.NotFound, message: "Doc Not Found" });
1149
- const page = coreDoc.document.pages.find((p) => p.index === pageIdx);
1150
- const task = this.engine.getPageGeometry(coreDoc.document, page);
1151
- task.wait((geo) => {
1152
- this.dispatch(cachePageGeometry(documentId, pageIdx, geo));
1153
- this.touchGeometry(documentId, pageIdx);
1154
- }, ignore);
1155
- return task;
1156
- }
1157
- /* ── geometry cache ───────────────────────────────────── */
1158
- getOrLoadGeometry(documentId, pageIdx) {
1159
- const cached = this.getDocumentState(documentId).geometry[pageIdx];
1160
- if (cached) {
1161
- this.touchGeometry(documentId, pageIdx);
1162
- return PdfTaskHelper.resolve(cached);
1163
- }
1164
- return this.getNewPageGeometryAndCache(documentId, pageIdx);
1165
- }
1166
- /* ── geometry LRU eviction ──────────────────────────────── */
1167
- touchGeometry(documentId, pageIdx) {
1168
- const order = this.geoAccessOrder.get(documentId);
1169
- if (!order) return;
1170
- const idx = order.indexOf(pageIdx);
1171
- if (idx > -1) order.splice(idx, 1);
1172
- order.push(pageIdx);
1173
- this.evictGeometryIfNeeded(documentId);
1174
- }
1175
- evictGeometryIfNeeded(documentId) {
1176
- const max = this.config.maxCachedGeometries ?? 50;
1177
- const order = this.geoAccessOrder.get(documentId);
1178
- if (!order || order.length <= max) return;
1179
- const pinned = this.pageCallbacks.get(documentId);
1180
- const toEvict = [];
1181
- while (order.length - toEvict.length > max) {
1182
- const candidate = order.find((p) => !toEvict.includes(p) && !(pinned == null ? void 0 : pinned.has(p)));
1183
- if (candidate === void 0) break;
1184
- toEvict.push(candidate);
1185
- }
1186
- if (toEvict.length === 0) return;
1187
- for (const p of toEvict) {
1188
- const idx = order.indexOf(p);
1189
- if (idx > -1) order.splice(idx, 1);
1190
- }
1191
- this.dispatch(evictPageGeometry(documentId, toEvict));
1192
- }
1193
- /* ── selection state updates ───────────────────────────── */
1194
- beginSelection(documentId, page, index, modeId) {
1195
- this.selecting.set(documentId, true);
1196
- this.anchor.set(documentId, { page, index });
1197
- this.dispatch(startSelection(documentId));
1198
- this.beginSelection$.emit(documentId, { page, index, modeId });
1199
- this.recalculateMenuPlacement(documentId);
1200
- }
1201
- endSelection(documentId, modeId) {
1202
- this.selecting.set(documentId, false);
1203
- this.anchor.set(documentId, void 0);
1204
- this.dispatch(endSelection(documentId));
1205
- this.endSelection$.emit(documentId, { modeId });
1206
- this.recalculateMenuPlacement(documentId);
1207
- }
1208
- clearSelection(documentId, _modeId) {
1209
- this.selecting.set(documentId, false);
1210
- this.anchor.set(documentId, void 0);
1211
- this.dispatch(clearSelection(documentId));
1212
- this.selChange$.emit(documentId, null);
1213
- this.emitMenuPlacement(documentId, null);
1214
- this.notifyAllPages(documentId);
1215
- }
1216
- selectWord(documentId, page, charIndex, modeId) {
1217
- const geo = this.getDocumentState(documentId).geometry[page];
1218
- if (!geo) return;
1219
- const bounds = expandToWordBoundary(geo, charIndex);
1220
- if (!bounds) return;
1221
- this.applyInstantSelection(documentId, page, bounds.from, bounds.to, modeId);
1222
- }
1223
- selectLine(documentId, page, charIndex, modeId) {
1224
- const geo = this.getDocumentState(documentId).geometry[page];
1225
- if (!geo) return;
1226
- const bounds = expandToLineBoundary(geo, charIndex);
1227
- if (!bounds) return;
1228
- this.applyInstantSelection(documentId, page, bounds.from, bounds.to, modeId);
1229
- }
1230
- /**
1231
- * Set a selection range without going through the drag begin/update/end flow.
1232
- * Used by double-click (word) and triple-click (line) selection.
1233
- */
1234
- applyInstantSelection(documentId, page, from, to, modeId) {
1235
- const range = {
1236
- start: { page, index: from },
1237
- end: { page, index: to }
1238
- };
1239
- this.selecting.set(documentId, false);
1240
- this.anchor.set(documentId, void 0);
1241
- this.dispatch(startSelection(documentId));
1242
- this.dispatch(setSelection(documentId, range));
1243
- this.updateRectsAndSlices(documentId, range);
1244
- this.dispatch(endSelection(documentId));
1245
- this.selChange$.emit(documentId, range);
1246
- this.beginSelection$.emit(documentId, { page, index: from, modeId });
1247
- this.endSelection$.emit(documentId, { modeId });
1248
- for (let p = range.start.page; p <= range.end.page; p++) {
1249
- this.notifyPage(documentId, p);
1250
- }
1251
- this.recalculateMenuPlacement(documentId);
1252
- }
1253
- updateSelection(documentId, page, index, modeId) {
1254
- if (!this.selecting.get(documentId) || !this.anchor.get(documentId)) return;
1255
- const a = this.anchor.get(documentId);
1256
- const forward = page > a.page || page === a.page && index >= a.index;
1257
- const start = forward ? a : { page, index };
1258
- const end = forward ? { page, index } : a;
1259
- const range = { start, end };
1260
- this.dispatch(setSelection(documentId, range));
1261
- this.updateRectsAndSlices(documentId, range);
1262
- this.selChange$.emit(documentId, range);
1263
- for (let p = range.start.page; p <= range.end.page; p++) {
1264
- this.notifyPage(documentId, p);
1265
- }
1266
- }
1267
- updateRectsAndSlices(documentId, range) {
1268
- const docState = this.getDocumentState(documentId);
1269
- const allRects = {};
1270
- const allSlices = {};
1271
- for (let p = range.start.page; p <= range.end.page; p++) {
1272
- const geo = docState.geometry[p];
1273
- const sb = sliceBounds(range, geo, p);
1274
- if (!sb) continue;
1275
- allRects[p] = rectsWithinSlice(geo, sb.from, sb.to);
1276
- allSlices[p] = { start: sb.from, count: sb.to - sb.from + 1 };
1277
- }
1278
- this.dispatch(setRects(documentId, allRects));
1279
- this.dispatch(setSlices(documentId, allSlices));
1280
- }
1281
- getSelectedText(documentId) {
1282
- if (!this.checkPermission(documentId, PdfPermissionFlag.CopyContents)) {
1283
- this.logger.debug(
1284
- "SelectionPlugin",
1285
- "GetSelectedText",
1286
- `Cannot get selected text: document ${documentId} lacks CopyContents permission`
1287
- );
1288
- return PdfTaskHelper.reject({
1289
- code: PdfErrorCode.Security,
1290
- message: "Document lacks CopyContents permission"
1291
- });
1292
- }
1293
- const coreDoc = this.getCoreDocument(documentId);
1294
- const docState = this.getDocumentState(documentId);
1295
- if (!(coreDoc == null ? void 0 : coreDoc.document) || !docState.selection) {
1296
- return PdfTaskHelper.reject({
1297
- code: PdfErrorCode.NotFound,
1298
- message: "Doc Not Found or No Selection"
1299
- });
1300
- }
1301
- const sel = docState.selection;
1302
- const req = [];
1303
- for (let p = sel.start.page; p <= sel.end.page; p++) {
1304
- const s = docState.slices[p];
1305
- if (s) req.push({ pageIndex: p, charIndex: s.start, charCount: s.count });
1306
- }
1307
- if (req.length === 0) return PdfTaskHelper.resolve([]);
1308
- const task = this.engine.getTextSlices(coreDoc.document, req);
1309
- task.wait((text) => {
1310
- this.textRetrieved$.emit(documentId, text);
1311
- }, ignore);
1312
- return task;
1313
- }
1314
- copyToClipboard(documentId) {
1315
- if (!this.checkPermission(documentId, PdfPermissionFlag.CopyContents)) {
1316
- this.logger.debug(
1317
- "SelectionPlugin",
1318
- "CopyToClipboard",
1319
- `Cannot copy to clipboard: document ${documentId} lacks CopyContents permission`
1320
- );
1321
- return;
1322
- }
1323
- const text = this.getSelectedText(documentId);
1324
- text.wait((text2) => {
1325
- this.copyToClipboard$.emit(documentId, text2.join("\n"));
1326
- }, ignore);
1327
- }
1328
- /* ── marquee selection state updates ─────────────────────── */
1329
- beginMarquee(documentId, pageIndex, _startPos, _modeId) {
1330
- this.marqueePage.set(documentId, pageIndex);
1331
- }
1332
- updateMarquee(documentId, pageIndex, rect, modeId) {
1333
- this.marqueeChange$.emit(documentId, { pageIndex, rect, modeId });
1334
- }
1335
- endMarquee(documentId, pageIndex, rect, modeId) {
1336
- this.marqueeEnd$.emit(documentId, { pageIndex, rect, modeId });
1337
- this.marqueeChange$.emit(documentId, { pageIndex, rect: null, modeId });
1338
- this.marqueePage.delete(documentId);
1339
- }
1340
- cancelMarquee(documentId, modeId) {
1341
- const pageIndex = this.marqueePage.get(documentId);
1342
- if (pageIndex !== void 0) {
1343
- this.marqueeChange$.emit(documentId, { pageIndex, rect: null, modeId });
1344
- this.marqueePage.delete(documentId);
1345
- }
1346
- }
1347
- /** @deprecated — shim for backward compat; delegates to pointerMode config */
1348
- setMarqueeEnabled(documentId, enabled) {
1349
- const modes = this.enabledModesPerDoc.get(documentId);
1350
- if (!modes) return;
1351
- const current = modes.get("pointerMode");
1352
- if (current) {
1353
- current.enableMarquee = enabled;
1354
- } else if (enabled) {
1355
- modes.set("pointerMode", { enableMarquee: true });
1356
- }
1357
- }
1358
- /** @deprecated — shim for backward compat; reads pointerMode config */
1359
- isMarqueeEnabled(documentId) {
1360
- var _a;
1361
- const config = (_a = this.enabledModesPerDoc.get(documentId)) == null ? void 0 : _a.get("pointerMode");
1362
- return (config == null ? void 0 : config.enableMarquee) !== false;
1363
- }
1364
- };
1365
- _SelectionPlugin.id = "selection";
1366
- let SelectionPlugin = _SelectionPlugin;
1367
- const SelectionPluginPackage = {
1368
- manifest,
1369
- create: (registry, config) => new SelectionPlugin(SELECTION_PLUGIN_ID, registry, config),
1370
- reducer: selectionReducer,
1371
- initialState
502
+ //#endregion
503
+ //#region src/reducer.ts
504
+ const initialSelectionState = {
505
+ selection: null,
506
+ rects: {},
507
+ loaded: {},
508
+ highlightHidden: false
1372
509
  };
1373
- export {
1374
- SELECTION_PLUGIN_ID,
1375
- SelectionPlugin,
1376
- SelectionPluginPackage,
1377
- expandToLineBoundary,
1378
- expandToWordBoundary,
1379
- getVerticalOverlap,
1380
- glyphAt,
1381
- manifest,
1382
- mergeAdjacentRects,
1383
- rectIntersect,
1384
- rectIsEmpty,
1385
- rectUnion,
1386
- rectsWithinSlice,
1387
- shouldMergeHorizontalRects,
1388
- sliceBounds
510
+ const selectionReducer = (state, action) => {
511
+ switch (action.type) {
512
+ case "PAGE_LOADED": return state.loaded[action.pon] ? state : {
513
+ ...state,
514
+ loaded: {
515
+ ...state.loaded,
516
+ [action.pon]: true
517
+ }
518
+ };
519
+ case "SET": return {
520
+ ...state,
521
+ selection: action.selection,
522
+ rects: action.rects
523
+ };
524
+ case "CLEAR": return state.selection === null && Object.keys(state.rects).length === 0 ? state : {
525
+ ...state,
526
+ selection: null,
527
+ rects: {}
528
+ };
529
+ case "SET_HIGHLIGHT_HIDDEN": return state.highlightHidden === action.hidden ? state : {
530
+ ...state,
531
+ highlightHidden: action.hidden
532
+ };
533
+ default: return state;
534
+ }
1389
535
  };
1390
- //# sourceMappingURL=index.js.map
536
+ //#endregion
537
+ //#region src/types.ts
538
+ const SelectionToken = createCapabilityToken("selection");
539
+ //#endregion
540
+ //#region src/selection.plugin.ts
541
+ /**
542
+ * Text selection — document-scoped, requires the interaction hub. In `init` it
543
+ * registers ITS pointer handler with the hub; the hub owns the pointer stream
544
+ * and arbitration. Works with `<Stage>` or a standalone `<PageView>` — selection
545
+ * only needs the page coordinate context + the engine's text geometry.
546
+ */
547
+ const selectionPlugin = () => definePlugin({
548
+ id: "selection",
549
+ token: SelectionToken,
550
+ scope: "document",
551
+ requires: [InteractionToken],
552
+ initialState: initialSelectionState,
553
+ reduce: selectionReducer,
554
+ capability: createSelectionCapability,
555
+ init: (ctx) => {
556
+ const interaction = ctx.get(InteractionToken);
557
+ const selection = ctx.get(SelectionToken);
558
+ interaction.registerHandler(createTextSelectHandler(selection, interaction));
559
+ }
560
+ });
561
+ //#endregion
562
+ export { SelectionToken, buildPageText, createSelectionCapability, createTextSelectHandler, expandToLine, expandToWord, glyphAt, initialSelectionState, rectsForRange, selectionPlugin, selectionReducer };
563
+
564
+ //# sourceMappingURL=index.js.map