import { useEffect, useRef } from 'react'; import { useSelectionPlugin, useSelectionCapability, glyphAt } from '@embedpdf/plugin-selection/react'; import { useInteractionManagerCapability } from '@embedpdf/plugin-interaction-manager/react'; import type { Position, PdfPageGeometry, PdfRun } from '@embedpdf/models'; interface TextSelectionHandlerProps { documentId: string; pageIndex: number; } // Time threshold for triple-click detection (ms) const TRIPLE_CLICK_TIME_THRESHOLD = 500; // Distance threshold for triple-click position matching (in page units) const TRIPLE_CLICK_POSITION_THRESHOLD = 20; const WORD_CHAR_REGEX = /[\p{L}\p{N}_]/u; function findRunForGlyph(geo: PdfPageGeometry, glyphIndex: number): PdfRun | null { for (const run of geo.runs) { const runEnd = run.charStart + run.glyphs.length - 1; if (glyphIndex >= run.charStart && glyphIndex <= runEnd) { return run; } } return null; } /** * Finds word boundaries in text content using actual character values. * Uses Unicode-aware word character detection for internationalization support. * * Behavior matches standard text editors / browsers: * - Double-click on a word character: selects the whole word (letters, digits, underscores) * - Double-click on punctuation: selects just that character * - Double-click on whitespace: no selection */ function findWordBoundariesInText(text: string, charIndex: number): { start: number; end: number } | null { if (charIndex < 0 || charIndex >= text.length) return null; const char = text[charIndex]; // If clicked on whitespace, don't select if (/\s/.test(char)) return null; if (WORD_CHAR_REGEX.test(char)) { let start = charIndex; while (start > 0 && WORD_CHAR_REGEX.test(text[start - 1])) start--; let end = charIndex; while (end < text.length - 1 && WORD_CHAR_REGEX.test(text[end + 1])) end++; return { start, end }; } return { start: charIndex, end: charIndex }; } /** * Finds line boundaries around the given glyph index. * A "line" is defined as consecutive runs sharing a similar Y position. */ function findLineBoundaries(geo: PdfPageGeometry, glyphIndex: number): { start: number; end: number } | null { let targetRunIndex = -1; for (let i = 0; i < geo.runs.length; i++) { const run = geo.runs[i]; const runEnd = run.charStart + run.glyphs.length - 1; if (glyphIndex >= run.charStart && glyphIndex <= runEnd) { targetRunIndex = i; break; } } if (targetRunIndex === -1) return null; const targetRun = geo.runs[targetRunIndex]; const targetY = targetRun.rect.y; const targetH = targetRun.rect.height; const threshold = targetH * 0.5; let firstRunIndex = targetRunIndex; while (firstRunIndex > 0) { const prevRun = geo.runs[firstRunIndex - 1]; if (Math.abs(prevRun.rect.y - targetY) < threshold) { firstRunIndex--; } else { break; } } let lastRunIndex = targetRunIndex; while (lastRunIndex < geo.runs.length - 1) { const nextRun = geo.runs[lastRunIndex + 1]; if (Math.abs(nextRun.rect.y - targetY) < threshold) { lastRunIndex++; } else { break; } } const firstRun = geo.runs[firstRunIndex]; const lastRun = geo.runs[lastRunIndex]; return { start: firstRun.charStart, end: lastRun.charStart + lastRun.glyphs.length - 1, }; } function setSelectionRange( selPlugin: any, documentId: string, pageIndex: number, startIndex: number, endIndex: number ): void { selPlugin.clearSelection(documentId); selPlugin.beginSelection(documentId, pageIndex, startIndex); selPlugin.updateSelection(documentId, pageIndex, endIndex); selPlugin.endSelection(documentId); } /** * Handles text selection with standard PDF viewer behaviors: * - Double-click to select a whole word * - Triple-click to select an entire line */ export function TextSelectionHandler({ documentId, pageIndex }: TextSelectionHandlerProps) { const { plugin: selPlugin } = useSelectionPlugin(); const { provides: selCapability } = useSelectionCapability(); const { provides: imCapability } = useInteractionManagerCapability(); const lastDblClickRef = useRef<{ time: number; x: number; y: number } | null>(null); const tripleClickTimeRef = useRef(0); useEffect(() => { if (!selPlugin || !selCapability || !imCapability) return; const handlers = { onDoubleClick: (pos: Position, _evt: any, modeId: string) => { if (Date.now() - tripleClickTimeRef.current < TRIPLE_CLICK_TIME_THRESHOLD) { return; } if (!selCapability.isEnabledForMode(modeId, documentId)) return; const state = selCapability.getState(documentId); const geo = state.geometry[pageIndex]; if (!geo) return; const g = glyphAt(geo, pos); if (g === -1) return; const run = findRunForGlyph(geo, g); if (!run || run.glyphs.length === 0) return; const localIndex = g - run.charStart; const plugin = selPlugin as any; try { const coreDoc = plugin.getCoreDocument(documentId); if (!coreDoc?.document) return; const task = plugin.engine.getTextSlices(coreDoc.document, [{ pageIndex, charIndex: run.charStart, charCount: run.glyphs.length, }]); task.wait((texts: string[]) => { const text = texts[0]; if (!text) return; const boundaries = findWordBoundariesInText(text, localIndex); if (!boundaries) return; lastDblClickRef.current = { time: Date.now(), x: pos.x, y: pos.y }; setSelectionRange( selPlugin, documentId, pageIndex, run.charStart + boundaries.start, run.charStart + boundaries.end ); }, () => { /* ignore errors */ }); } catch { // Engine access failed - silently ignore } }, onClick: (pos: Position, _evt: any, modeId: string) => { const dbl = lastDblClickRef.current; if (!dbl) return; if (!selCapability.isEnabledForMode(modeId, documentId)) return; const now = Date.now(); const timeDiff = now - dbl.time; const dx = Math.abs(pos.x - dbl.x); const dy = Math.abs(pos.y - dbl.y); if ( timeDiff < TRIPLE_CLICK_TIME_THRESHOLD && dx < TRIPLE_CLICK_POSITION_THRESHOLD && dy < TRIPLE_CLICK_POSITION_THRESHOLD ) { const state = selCapability.getState(documentId); const geo = state.geometry[pageIndex]; if (!geo) return; const g = glyphAt(geo, pos); if (g === -1) return; const line = findLineBoundaries(geo, g); if (!line) return; // Mark triple-click time to skip subsequent dblclick in this sequence tripleClickTimeRef.current = now; lastDblClickRef.current = null; setSelectionRange(selPlugin, documentId, pageIndex, line.start, line.end); } }, }; return imCapability.registerAlways({ scope: { type: 'page', documentId, pageIndex }, handlers, }); }, [selPlugin, selCapability, imCapability, documentId, pageIndex]); return null; }