diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 9e7c7904f..267a9475e 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; import { LinkLayer } from '@app/components/viewer/LinkLayer'; +import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler'; import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu'; import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker'; import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge'; @@ -707,7 +708,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda - +
+ +
+ {/* AnnotationLayer for annotation editing and annotation-based redactions */} {(enableAnnotations || enableRedaction) && ( diff --git a/frontend/src/core/components/viewer/SelectionAPIBridge.tsx b/frontend/src/core/components/viewer/SelectionAPIBridge.tsx index 4aaf361bf..ae25fdecf 100644 --- a/frontend/src/core/components/viewer/SelectionAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SelectionAPIBridge.tsx @@ -1,59 +1,75 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { useSelectionCapability } from '@embedpdf/plugin-selection/react'; import { useViewer } from '@app/contexts/ViewerContext'; export function SelectionAPIBridge() { const { provides: selection } = useSelectionCapability(); const { registerBridge } = useViewer(); - const [hasSelection, setHasSelection] = useState(false); + + + const hasSelectionRef = useRef(false); + const selectedTextRef = useRef(''); useEffect(() => { - if (selection) { - const newState = { - hasSelection - }; + if (!selection) return; - registerBridge('selection', { - state: newState, - api: { - copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), - getFormattedSelection: () => selection.getFormattedSelection(), + const buildApi = () => ({ + copyToClipboard: () => selection.copyToClipboard(), + getSelectedText: () => selection.getSelectedText(), + getFormattedSelection: () => selection.getFormattedSelection(), + }); + + registerBridge('selection', { state: { hasSelection: false }, api: buildApi() }); + + const unsubChange = selection.onSelectionChange((event: any) => { + const hasText = !!event?.selection; + hasSelectionRef.current = hasText; + + registerBridge('selection', { state: { hasSelection: hasText }, api: buildApi() }); + + if (hasText) { + try { + const result = selection.getSelectedText(); + result?.wait?.((texts: string[]) => { + selectedTextRef.current = texts.join('\n'); + }, () => { /* ignore errors */ }); + } catch { + // Engine access failed } - }); + } else { + selectedTextRef.current = ''; + } + }); - const unsubscribe = selection.onSelectionChange((event: any) => { - const hasText = !!(event?.selection || event); - setHasSelection(hasText); - const updatedState = { hasSelection: hasText }; - registerBridge('selection', { - state: updatedState, - api: { - copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), - getFormattedSelection: () => selection.getFormattedSelection(), - } - }); - }); + // Fallback: subscribe to the plugin's copy event for navigator.clipboard writes + const unsubCopy = selection.onCopyToClipboard(({ text }: { text: string }) => { + if (text) { + navigator.clipboard.writeText(text).catch(() => { /* ignore */ }); + } + }); - // Intercept Ctrl+C only when we have PDF text selected - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelection) { - // Call EmbedPDF's copyToClipboard API - selection.copyToClipboard(); - // Don't prevent default - let EmbedPDF handle the clipboard - } - }; + const handleCopy = (event: ClipboardEvent) => { + if (!hasSelectionRef.current || !selectedTextRef.current) return; + event.clipboardData?.setData('text/plain', selectedTextRef.current); + event.preventDefault(); + }; - // Add keyboard listener - document.addEventListener('keydown', handleKeyDown); + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelectionRef.current) { + selection.copyToClipboard(); + } + }; - return () => { - unsubscribe?.(); - document.removeEventListener('keydown', handleKeyDown); - }; - } - }, [selection, hasSelection]); + document.addEventListener('copy', handleCopy); + document.addEventListener('keydown', handleKeyDown); + + return () => { + unsubChange?.(); + unsubCopy?.(); + document.removeEventListener('copy', handleCopy); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [selection]); return null; } diff --git a/frontend/src/core/components/viewer/TextSelectionHandler.tsx b/frontend/src/core/components/viewer/TextSelectionHandler.tsx new file mode 100644 index 000000000..d1d2ab2de --- /dev/null +++ b/frontend/src/core/components/viewer/TextSelectionHandler.tsx @@ -0,0 +1,231 @@ +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; +} diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index c43b330b0..db4cb9e86 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -231,6 +231,10 @@ --text-brand-accent: #DC2626; + /* PDF text selection colors */ + --pdf-selection-bg: rgba(59, 130, 246, 0.2); + --pdf-selection-ring: rgba(59, 130, 246, 0.18); + /* Placeholder text colors */ --search-text-and-icon-color: #6B7382; @@ -548,6 +552,10 @@ --tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */ + /* PDF text selection colors (dark mode) */ + --pdf-selection-bg: rgba(96, 165, 250, 0.25); + --pdf-selection-ring: rgba(96, 165, 250, 0.28); + /* Placeholder text colors (dark mode) */ --search-text-and-icon-color: #FFFFFF !important; @@ -593,7 +601,7 @@ --api-keys-input-border: #3A4047; --api-keys-button-bg: #3A4047; --api-keys-button-color: #D0D6DC; - + /* Code token colors (dark mode - Cursor-like) */ --code-kw-color: #C792EA; /* purple */ --code-str-color: #C3E88D; /* green */ @@ -652,6 +660,17 @@ background-color: #6b7280 !important; } +/* Override the flat multiply blend for a clean, modern semi-transparent overlay */ +.pdf-selection-layer > div:first-child { + mix-blend-mode: normal !important; +} + +/* Individual selection highlight rectangles */ +.pdf-selection-layer > div:first-child > div { + border-radius: 3px !important; + box-shadow: 0 0 0 0.5px var(--pdf-selection-ring); +} + /* Smooth transitions for theme switching */ * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;