@embedpdf/plugin-selection 3.0.0-next.1 → 3.0.0-next.10

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.
package/dist/index.js CHANGED
@@ -1,564 +1,147 @@
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
1
+ import { i as createSelectionCapability, l as SelectionToken$1, n as selectionReducer, r as createTextSelectHandler, t as initialSelectionState } from "./reducer-Dy0YPuX0.js";
2
+ import { definePlugin } from "@embedpdf/core";
3
+ import { FeedbackToken, InteractionToken } from "@embedpdf/plugin-interaction";
4
+ import { textQuadEdge } from "@embedpdf/core-geometry";
5
+ //#region src/selection.plugin.ts
5
6
  /**
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.
7
+ * Text selection — document-scoped, requires the interaction hub. In `init` it
8
+ * registers ITS pointer handler with the hub; the hub owns the pointer stream
9
+ * and arbitration. Works with `<Stage>` or a standalone `<PageView>` — selection
10
+ * only needs the page coordinate context + the engine's text geometry.
9
11
  *
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
26
- });
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
- };
58
- }
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;
68
- }
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`).
12
+ * Registry/content invalidation is wired inside the capability itself
13
+ * (revision watch + document event subscription), so headless/programmatic
14
+ * use gets the same reconciliation as the full viewer.
74
15
  */
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;
105
- }
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];
115
- }
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];
138
- }
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);
197
- }
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;
212
- }
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;
16
+ const selectionPlugin = () => definePlugin({
17
+ id: "selection",
18
+ token: SelectionToken$1,
19
+ scope: "document",
20
+ requires: [InteractionToken],
21
+ optional: [FeedbackToken],
22
+ initialState: initialSelectionState,
23
+ reduce: selectionReducer,
24
+ capability: createSelectionCapability,
25
+ init: (ctx) => {
26
+ const interaction = ctx.get(InteractionToken);
27
+ const selection = ctx.get(SelectionToken$1);
28
+ const feedback = ctx.tryGet(FeedbackToken);
29
+ interaction.registerHandler(createTextSelectHandler(selection, interaction, feedback ?? void 0));
216
30
  }
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;
224
- }
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;
230
- }
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;
236
- }
31
+ });
237
32
  //#endregion
238
- //#region src/capability.ts
239
- const EMPTY = [];
33
+ //#region src/handles.ts
240
34
  /**
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.
35
+ * Selection handles — the pure policy half (the `wheel.ts` convention).
36
+ *
37
+ * A handle IS the boundary glyph's leading (or trailing) edge: a segment
38
+ * between two corners of the glyph's ORIENTED cell, with the grab head just
39
+ * beyond the ascent at the start / past the baseline at the end. Bar length,
40
+ * angle, and head all derive from that one segment, so rotated text and
41
+ * rotated pages are carried by construction — the AABB never enters.
244
42
  *
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.
43
+ * Everything here is DOM-free and framework-free: geometry is pure given a
44
+ * projector, and the drag session speaks the selection's own gesture verbs
45
+ * (`beginAt`/`extendTo`/`end` — the same ones the pointer handler uses; a
46
+ * handle drag IS a selection gesture, re-anchored). The framework adapters
47
+ * keep only subscriptions and markup; the DOM listener mechanics live in
48
+ * `@embedpdf/web`'s `attachSelectionHandle`.
49
+ *
50
+ * The view dependency is STRUCTURAL (satisfied by five lines over
51
+ * `StageCapability`) so this plugin stays stage-free — selection also runs
52
+ * in stage-less hosts (`PageView`) — and the math tests run against a fake.
249
53
  */
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
- }
54
+ const HANDLE_HEAD = 12;
55
+ const HANDLE_BAR = 2;
56
+ const HANDLE_PAD = 14;
57
+ /** Tilts within ~0.05° of upright render untransformed (float-noise guard —
58
+ * and the dominant case stays pixel-identical to an axis-aligned box). */
59
+ const HANDLE_ROT_EPSILON = .05;
60
+ const midpoint = (a, b) => ({
61
+ x: (a.x + b.x) / 2,
62
+ y: (a.y + b.y) / 2
63
+ });
64
+ /**
65
+ * The handle's geometry for one endpoint, in overlay space. Null when the
66
+ * endpoint's page isn't laid out (or the cell is degenerate) — render nothing.
67
+ */
68
+ function selectionHandleGeom(view, ep, role) {
69
+ const leading = role === "start" ? ep.advance > 0 : ep.advance < 0;
70
+ const [upperPage, lowerPage] = textQuadEdge(ep.glyphQuad, leading ? "start" : "end");
71
+ const upper = view.toOverlay(ep.pon, upperPage);
72
+ const lower = view.toOverlay(ep.pon, lowerPage);
73
+ if (!upper || !lower) return null;
74
+ const dx = lower.x - upper.x;
75
+ const dy = lower.y - upper.y;
76
+ const length = Math.hypot(dx, dy);
77
+ if (length === 0) return null;
78
+ const rotation = Math.atan2(dy, dx) * 180 / Math.PI - 90;
79
+ const upright = Math.abs(rotation) < HANDLE_ROT_EPSILON;
80
+ const ux = dx / length;
81
+ const uy = dy / length;
82
+ const head = role === "start" ? {
83
+ x: upper.x - ux * 12 / 2,
84
+ y: upper.y - uy * 12 / 2
85
+ } : {
86
+ x: lower.x + ux * 12 / 2,
87
+ y: lower.y + uy * 12 / 2
88
+ };
358
89
  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();
90
+ bar: {
91
+ from: upper,
92
+ to: lower
406
93
  },
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
94
+ length,
95
+ rotation,
96
+ upright,
97
+ head
427
98
  };
428
99
  }
429
- //#endregion
430
- //#region src/handler.ts
431
- const CURSOR_TOKEN = "selection-text";
432
- const DRAG_THRESHOLD_PX = 4;
433
100
  /**
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
101
+ * One armed handle drag. Dragging a handle extends from the OPPOSITE
102
+ * endpoint: the first movement re-roots the selection gesture at that
103
+ * endpoint's cell centre, and every position then extends toward the pointer
104
+ * — snapping to glyphs and crossing pages exactly like a pointer drag,
105
+ * firing the same change/commit signals. Over a gap the point projects onto
106
+ * the last page hit (unclamped), so the selection keeps tracking instead of
107
+ * freezing at a page edge. `end()` commits only if the drag actually
108
+ * re-rooted; an untouched press settles nothing.
445
109
  */
446
- function createTextSelectHandler(selection, interaction) {
447
- let anchor = null;
448
- let dragging = false;
110
+ function createSelectionHandleDrag(selection, view, opposite, draggedPon) {
111
+ const q = opposite.glyphQuad;
112
+ const anchorPoint = midpoint(midpoint(q.upperStart, q.lowerEnd), midpoint(q.upperEnd, q.lowerStart));
113
+ let begun = false;
114
+ let lastPon = draggedPon;
449
115
  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;
116
+ move: (overlay) => {
117
+ if (!begun) {
118
+ if (!selection.beginAt(opposite.pon, anchorPoint)) return;
119
+ begun = true;
462
120
  }
463
- if (clicks === 2) {
464
- selection.selectWord(pon, point);
465
- return true;
121
+ const hit = view.pageAt(overlay);
122
+ if (hit) {
123
+ lastPon = hit.pon;
124
+ selection.extendTo(hit.pon, hit.point);
125
+ } else {
126
+ const p = view.pointOnPage(lastPon, overlay);
127
+ if (p) selection.extendTo(lastPon, p);
466
128
  }
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
129
  },
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);
130
+ end: () => {
131
+ if (begun) selection.end();
499
132
  }
500
133
  };
501
134
  }
502
135
  //#endregion
503
- //#region src/reducer.ts
504
- const initialSelectionState = {
505
- selection: null,
506
- rects: {},
507
- loaded: {},
508
- highlightHidden: false
509
- };
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
- }
535
- };
536
- //#endregion
537
- //#region src/types.ts
538
- const SelectionToken = createCapabilityToken("selection");
539
- //#endregion
540
- //#region src/selection.plugin.ts
136
+ //#region src/index.ts
541
137
  /**
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.
138
+ * The selection capability token, narrowed to the public
139
+ * {@link SelectionCapability} lens. It is the SAME runtime token the plugin
140
+ * provides — the host-only surface (pointer gestures, geometry warming) is
141
+ * reachable only via `@embedpdf/plugin-selection/internal`.
546
142
  */
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
- });
143
+ const SelectionToken = SelectionToken$1;
561
144
  //#endregion
562
- export { SelectionToken, buildPageText, createSelectionCapability, createTextSelectHandler, expandToLine, expandToWord, glyphAt, initialSelectionState, rectsForRange, selectionPlugin, selectionReducer };
145
+ export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SelectionToken, createSelectionHandleDrag, selectionHandleGeom, selectionPlugin };
563
146
 
564
147
  //# sourceMappingURL=index.js.map