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;