mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(text-selection): implement text selection enhancement for double and triple-click actions (#5712)
This commit is contained in:
@@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
|||||||
import { isPdfFile } from '@app/utils/fileUtils';
|
import { isPdfFile } from '@app/utils/fileUtils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||||
|
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
|
||||||
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
|
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
|
||||||
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
||||||
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
|
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
|
||||||
@@ -707,7 +708,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
|
|||||||
|
|
||||||
<CustomSearchLayer documentId={documentId} pageIndex={pageIndex} />
|
<CustomSearchLayer documentId={documentId} pageIndex={pageIndex} />
|
||||||
|
|
||||||
<SelectionLayer documentId={documentId} pageIndex={pageIndex} />
|
<div className="pdf-selection-layer" style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||||
|
<SelectionLayer documentId={documentId} pageIndex={pageIndex} background="var(--pdf-selection-bg)" />
|
||||||
|
</div>
|
||||||
|
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
|
||||||
|
|
||||||
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
||||||
{(enableAnnotations || enableRedaction) && (
|
{(enableAnnotations || enableRedaction) && (
|
||||||
|
|||||||
@@ -1,59 +1,75 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSelectionCapability } from '@embedpdf/plugin-selection/react';
|
import { useSelectionCapability } from '@embedpdf/plugin-selection/react';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
|
||||||
export function SelectionAPIBridge() {
|
export function SelectionAPIBridge() {
|
||||||
const { provides: selection } = useSelectionCapability();
|
const { provides: selection } = useSelectionCapability();
|
||||||
const { registerBridge } = useViewer();
|
const { registerBridge } = useViewer();
|
||||||
const [hasSelection, setHasSelection] = useState(false);
|
|
||||||
|
|
||||||
|
const hasSelectionRef = useRef(false);
|
||||||
|
const selectedTextRef = useRef('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection) {
|
if (!selection) return;
|
||||||
const newState = {
|
|
||||||
hasSelection
|
|
||||||
};
|
|
||||||
|
|
||||||
registerBridge('selection', {
|
const buildApi = () => ({
|
||||||
state: newState,
|
copyToClipboard: () => selection.copyToClipboard(),
|
||||||
api: {
|
getSelectedText: () => selection.getSelectedText(),
|
||||||
copyToClipboard: () => selection.copyToClipboard(),
|
getFormattedSelection: () => selection.getFormattedSelection(),
|
||||||
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) => {
|
// Fallback: subscribe to the plugin's copy event for navigator.clipboard writes
|
||||||
const hasText = !!(event?.selection || event);
|
const unsubCopy = selection.onCopyToClipboard(({ text }: { text: string }) => {
|
||||||
setHasSelection(hasText);
|
if (text) {
|
||||||
const updatedState = { hasSelection: hasText };
|
navigator.clipboard.writeText(text).catch(() => { /* ignore */ });
|
||||||
registerBridge('selection', {
|
}
|
||||||
state: updatedState,
|
});
|
||||||
api: {
|
|
||||||
copyToClipboard: () => selection.copyToClipboard(),
|
|
||||||
getSelectedText: () => selection.getSelectedText(),
|
|
||||||
getFormattedSelection: () => selection.getFormattedSelection(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Intercept Ctrl+C only when we have PDF text selected
|
const handleCopy = (event: ClipboardEvent) => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
if (!hasSelectionRef.current || !selectedTextRef.current) return;
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelection) {
|
event.clipboardData?.setData('text/plain', selectedTextRef.current);
|
||||||
// Call EmbedPDF's copyToClipboard API
|
event.preventDefault();
|
||||||
selection.copyToClipboard();
|
};
|
||||||
// Don't prevent default - let EmbedPDF handle the clipboard
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add keyboard listener
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelectionRef.current) {
|
||||||
|
selection.copyToClipboard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
document.addEventListener('copy', handleCopy);
|
||||||
unsubscribe?.();
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
return () => {
|
||||||
}
|
unsubChange?.();
|
||||||
}, [selection, hasSelection]);
|
unsubCopy?.();
|
||||||
|
document.removeEventListener('copy', handleCopy);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -231,6 +231,10 @@
|
|||||||
--text-brand-accent: #DC2626;
|
--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 */
|
/* Placeholder text colors */
|
||||||
--search-text-and-icon-color: #6B7382;
|
--search-text-and-icon-color: #6B7382;
|
||||||
|
|
||||||
@@ -548,6 +552,10 @@
|
|||||||
--tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */
|
--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) */
|
/* Placeholder text colors (dark mode) */
|
||||||
--search-text-and-icon-color: #FFFFFF !important;
|
--search-text-and-icon-color: #FFFFFF !important;
|
||||||
|
|
||||||
@@ -593,7 +601,7 @@
|
|||||||
--api-keys-input-border: #3A4047;
|
--api-keys-input-border: #3A4047;
|
||||||
--api-keys-button-bg: #3A4047;
|
--api-keys-button-bg: #3A4047;
|
||||||
--api-keys-button-color: #D0D6DC;
|
--api-keys-button-color: #D0D6DC;
|
||||||
|
|
||||||
/* Code token colors (dark mode - Cursor-like) */
|
/* Code token colors (dark mode - Cursor-like) */
|
||||||
--code-kw-color: #C792EA; /* purple */
|
--code-kw-color: #C792EA; /* purple */
|
||||||
--code-str-color: #C3E88D; /* green */
|
--code-str-color: #C3E88D; /* green */
|
||||||
@@ -652,6 +660,17 @@
|
|||||||
background-color: #6b7280 !important;
|
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 */
|
/* Smooth transitions for theme switching */
|
||||||
* {
|
* {
|
||||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
|||||||
Reference in New Issue
Block a user