@embedpdf/plugin-selection 2.6.0 → 2.6.2

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
@@ -21,6 +21,7 @@ const END_SELECTION = "SELECTION/END_SELECTION";
21
21
  const CLEAR_SELECTION = "SELECTION/CLEAR_SELECTION";
22
22
  const SET_RECTS = "SELECTION/SET_RECTS";
23
23
  const SET_SLICES = "SELECTION/SET_SLICES";
24
+ const EVICT_PAGE_GEOMETRY = "SELECTION/EVICT_PAGE_GEOMETRY";
24
25
  const RESET = "SELECTION/RESET";
25
26
  const initSelectionState = (documentId, state) => ({
26
27
  type: INIT_SELECTION_STATE,
@@ -55,6 +56,10 @@ const setRects = (documentId, allRects) => ({
55
56
  payload: { documentId, rects: allRects }
56
57
  });
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
+ });
58
63
  const initialSelectionDocumentState = {
59
64
  geometry: {},
60
65
  rects: {},
@@ -147,6 +152,20 @@ const selectionReducer = (state = initialState, action) => {
147
152
  if (!docState) return state;
148
153
  return updateDocState(state, documentId, { ...docState, slices });
149
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
+ }
150
169
  case RESET: {
151
170
  const { documentId } = action.payload;
152
171
  const docState = state.documents[documentId];
@@ -197,18 +216,67 @@ function getFormattedSelection(state) {
197
216
  }
198
217
  return result;
199
218
  }
200
- function glyphAt(geo, pt) {
219
+ function glyphAt(geo, pt, toleranceFactor = 1.5) {
201
220
  for (const run of geo.runs) {
202
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;
203
222
  if (!inRun) continue;
204
- const rel = run.glyphs.findIndex(
205
- (g) => pt.x >= g.x && pt.x <= g.x + g.width && pt.y >= g.y && pt.y <= g.y + g.height
206
- );
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
+ });
207
230
  if (rel !== -1) {
208
231
  return run.charStart + rel;
209
232
  }
210
233
  }
211
- return -1;
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;
267
+ }
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;
212
280
  }
213
281
  function sliceBounds(sel, geo, page) {
214
282
  if (!sel || !geo) return null;
@@ -221,6 +289,7 @@ function sliceBounds(sel, geo, page) {
221
289
  }
222
290
  function rectsWithinSlice(geo, from, to, merge = true) {
223
291
  const textRuns = [];
292
+ const CHAR_DISTANCE_FACTOR = 2.5;
224
293
  for (const run of geo.runs) {
225
294
  const runStart = run.charStart;
226
295
  const runEnd = runStart + run.glyphs.length - 1;
@@ -230,24 +299,46 @@ function rectsWithinSlice(geo, from, to, merge = true) {
230
299
  let minX = Infinity, maxX = -Infinity;
231
300
  let minY = Infinity, maxY = -Infinity;
232
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
+ };
233
323
  for (let i = sIdx; i <= eIdx; i++) {
234
324
  const g = run.glyphs[i];
235
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
+ }
236
333
  minX = Math.min(minX, g.x);
237
334
  maxX = Math.max(maxX, g.x + g.width);
238
335
  minY = Math.min(minY, g.y);
239
336
  maxY = Math.max(maxY, g.y + g.height);
240
337
  charCount++;
338
+ widthSum += g.width;
339
+ prevRight = g.x + g.width;
241
340
  }
242
- if (minX !== Infinity && charCount > 0) {
243
- textRuns.push({
244
- rect: {
245
- origin: { x: minX, y: minY },
246
- size: { width: maxX - minX, height: maxY - minY }
247
- },
248
- charCount
249
- });
250
- }
341
+ flushSubRun();
251
342
  }
252
343
  if (!merge) {
253
344
  return textRuns.map((run) => run.rect);
@@ -289,6 +380,13 @@ function getVerticalOverlap(rect1, rect2) {
289
380
  return intersectRect.size.height / unionRect.size.height;
290
381
  }
291
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
+ }
292
390
  const VERTICAL_OVERLAP_THRESHOLD = 0.8;
293
391
  const rect1 = textRun1.rect;
294
392
  const rect2 = textRun2.rect;
@@ -326,39 +424,178 @@ function mergeAdjacentRects(textRuns) {
326
424
  }
327
425
  return results;
328
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;
329
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
+ }
330
517
  return {
331
518
  onPointerDown: (point, evt, modeId) => {
332
- var _a;
519
+ var _a, _b;
333
520
  if (evt.target === evt.currentTarget) {
334
521
  (_a = opts.onEmptySpaceClick) == null ? void 0 : _a.call(opts, modeId);
335
522
  }
336
523
  if (!opts.isEnabled(modeId)) return;
337
- opts.onClear(modeId);
524
+ const now = Date.now();
525
+ if (lastDblClickTime === 0 || now - lastDblClickTime >= TRIPLE_CLICK_INTERVAL_MS) {
526
+ opts.onClear(modeId);
527
+ }
338
528
  const geo = opts.getGeometry();
339
- if (geo) {
340
- const g = glyphAt(geo, point);
341
- if (g !== -1) {
342
- opts.onBegin(g, modeId);
343
- }
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);
344
536
  }
345
537
  },
346
538
  onPointerMove: (point, _evt, modeId) => {
347
539
  if (!opts.isEnabled(modeId)) return;
348
540
  const geo = opts.getGeometry();
349
- if (geo) {
350
- const g = glyphAt(geo, point);
351
- opts.setCursor(g !== -1 ? "text" : null);
352
- if (opts.isSelecting() && g !== -1) {
353
- opts.onUpdate(g, modeId);
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
+ }
354
554
  }
555
+ return;
556
+ }
557
+ if (opts.isSelecting() && g !== -1) {
558
+ opts.onUpdate(g, modeId);
355
559
  }
356
560
  },
357
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;
358
583
  if (!opts.isEnabled(modeId)) return;
359
- opts.onEnd(modeId);
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);
360
596
  },
361
597
  onHandlerActiveEnd: (modeId) => {
598
+ reset();
362
599
  if (!opts.isEnabled(modeId)) return;
363
600
  opts.onClear(modeId);
364
601
  }
@@ -420,8 +657,10 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
420
657
  this.enabledModesPerDoc = /* @__PURE__ */ new Map();
421
658
  this.selecting = /* @__PURE__ */ new Map();
422
659
  this.anchor = /* @__PURE__ */ new Map();
660
+ this.hasTextAnchor = /* @__PURE__ */ new Map();
423
661
  this.marqueePage = /* @__PURE__ */ new Map();
424
662
  this.pageCallbacks = /* @__PURE__ */ new Map();
663
+ this.geoAccessOrder = /* @__PURE__ */ new Map();
425
664
  this.menuPlacement$ = createScopedEmitter((documentId, placement) => ({ documentId, placement }));
426
665
  this.selChange$ = createScopedEmitter((documentId, selection) => ({
427
666
  documentId,
@@ -520,14 +759,18 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
520
759
  ])
521
760
  );
522
761
  this.pageCallbacks.set(documentId, /* @__PURE__ */ new Map());
762
+ this.geoAccessOrder.set(documentId, []);
523
763
  this.selecting.set(documentId, false);
524
764
  this.anchor.set(documentId, void 0);
765
+ this.hasTextAnchor.set(documentId, false);
525
766
  }
526
767
  onDocumentClosed(documentId) {
527
768
  this.dispatch(cleanupSelectionState(documentId));
528
769
  this.enabledModesPerDoc.delete(documentId);
529
770
  this.pageCallbacks.delete(documentId);
771
+ this.geoAccessOrder.delete(documentId);
530
772
  this.selecting.delete(documentId);
773
+ this.hasTextAnchor.delete(documentId);
531
774
  this.anchor.delete(documentId);
532
775
  this.marqueePage.delete(documentId);
533
776
  this.selChange$.clearScope(documentId);
@@ -659,6 +902,22 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
659
902
  rects: selectRectsForPage(docState, pageIndex),
660
903
  boundingRect: selectBoundingRectForPage(docState, pageIndex)
661
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);
662
921
  const textHandler = createTextSelectionHandler({
663
922
  getGeometry: () => this.getDocumentState(documentId).geometry[pageIndex],
664
923
  isEnabled: (modeId) => {
@@ -672,7 +931,12 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
672
931
  onClear: (modeId) => this.clearSelection(documentId, modeId),
673
932
  isSelecting: () => this.selecting.get(documentId) ?? false,
674
933
  setCursor: (cursor) => cursor ? interactionScope.setCursor("selection-text", cursor, 10) : interactionScope.removeCursor("selection-text"),
675
- onEmptySpaceClick: (modeId) => this.emptySpaceClick$.emit(documentId, { pageIndex, modeId })
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
676
940
  });
677
941
  const unregisterHandlers = this.interactionManagerCapability.registerAlways({
678
942
  scope: { type: "page", documentId, pageIndex },
@@ -739,7 +1003,7 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
739
1003
  const config = (_a2 = this.enabledModesPerDoc.get(documentId)) == null ? void 0 : _a2.get(modeId);
740
1004
  return (config == null ? void 0 : config.enableMarquee) === true;
741
1005
  },
742
- isTextSelecting: () => this.selecting.get(documentId) ?? false,
1006
+ isTextSelecting: () => (this.selecting.get(documentId) ?? false) || (this.hasTextAnchor.get(documentId) ?? false),
743
1007
  onBegin: (pos, modeId) => this.beginMarquee(documentId, pageIndex, pos, modeId),
744
1008
  onChange: (rect, modeId) => {
745
1009
  this.updateMarquee(documentId, pageIndex, rect, modeId);
@@ -886,15 +1150,46 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
886
1150
  const task = this.engine.getPageGeometry(coreDoc.document, page);
887
1151
  task.wait((geo) => {
888
1152
  this.dispatch(cachePageGeometry(documentId, pageIdx, geo));
1153
+ this.touchGeometry(documentId, pageIdx);
889
1154
  }, ignore);
890
1155
  return task;
891
1156
  }
892
1157
  /* ── geometry cache ───────────────────────────────────── */
893
1158
  getOrLoadGeometry(documentId, pageIdx) {
894
1159
  const cached = this.getDocumentState(documentId).geometry[pageIdx];
895
- if (cached) return PdfTaskHelper.resolve(cached);
1160
+ if (cached) {
1161
+ this.touchGeometry(documentId, pageIdx);
1162
+ return PdfTaskHelper.resolve(cached);
1163
+ }
896
1164
  return this.getNewPageGeometryAndCache(documentId, pageIdx);
897
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
+ }
898
1193
  /* ── selection state updates ───────────────────────────── */
899
1194
  beginSelection(documentId, page, index, modeId) {
900
1195
  this.selecting.set(documentId, true);
@@ -918,6 +1213,43 @@ const _SelectionPlugin = class _SelectionPlugin extends BasePlugin {
918
1213
  this.emitMenuPlacement(documentId, null);
919
1214
  this.notifyAllPages(documentId);
920
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
+ }
921
1253
  updateSelection(documentId, page, index, modeId) {
922
1254
  if (!this.selecting.get(documentId) || !this.anchor.get(documentId)) return;
923
1255
  const a = this.anchor.get(documentId);
@@ -1042,6 +1374,8 @@ export {
1042
1374
  SELECTION_PLUGIN_ID,
1043
1375
  SelectionPlugin,
1044
1376
  SelectionPluginPackage,
1377
+ expandToLineBoundary,
1378
+ expandToWordBoundary,
1045
1379
  getVerticalOverlap,
1046
1380
  glyphAt,
1047
1381
  manifest,