Skip to content

Commit c3795c1

Browse files
FrooodleEthanHealy01jbrunton96
authored
fix(viewer): wire Ctrl+A to select all text in the PDF (Stirling-Tools#6517)
# Description of Changes Allow Ctrl A support in viewer and fix select text to copy issues via a hovering copy button --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: James Brunton <jbrunton96@gmail.com>
1 parent c8925ac commit c3795c1

10 files changed

Lines changed: 612 additions & 8 deletions

File tree

.gitleaksignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# PostHog project-level key phc_ prefix keys are public/client-side by design
1+
# PostHog project-level key - phc_ prefix keys are public/client-side by design
22
# (PostHog client-side tracking embeds them in the browser bundle). Committed
33
# intentionally in #6150 so engine/.env has a working default, with real
44
# credentials overridden via engine/.env.local.
@@ -12,6 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
1212
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
1313
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
1414
testing/compose/validate-mcp-test.sh:curl-auth-header:92
15+
testing/compose/validate-mcp-test.sh:curl-auth-header:116
16+
17+
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
18+
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
1519

1620
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
1721
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31

frontend/editor/public/locales/en-GB/translation.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7917,6 +7917,7 @@ valid = "Valid"
79177917

79187918
[viewer]
79197919
cannotPreviewFile = "Cannot Preview File"
7920+
copyText = "Copy"
79207921
disableColorFilter = "Disable Colour Filter"
79217922
dualPageView = "Dual Page View"
79227923
enableDarkFilter = "Enable Dark Filter"

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7890,6 +7890,7 @@ valid = "Valid"
78907890

78917891
[viewer]
78927892
cannotPreviewFile = "Cannot Preview File"
7893+
copyText = "Copy"
78937894
disableColorFilter = "Disable Color Filter"
78947895
dualPageView = "Dual Page View"
78957896
enableDarkFilter = "Enable Dark Filter"

frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ const EmbedPdfViewerContent = ({
173173
isAnnotationsVisible,
174174
exportActions,
175175
printActions,
176+
selectionActions,
176177
setApplyChanges,
177178
applyChanges: viewerApplyChanges,
178179
pdfRenderMode,
@@ -442,6 +443,17 @@ const EmbedPdfViewerContent = ({
442443
event.preventDefault();
443444
printActions.print();
444445
return;
446+
case "a":
447+
case "A":
448+
// Intercept unconditionally so the browser can't blanket-select the surrounding UI chrome.
449+
event.preventDefault();
450+
{
451+
const totalPages = getScrollState().totalPages;
452+
if (totalPages > 0) {
453+
void selectionActions.selectAll(totalPages);
454+
}
455+
}
456+
return;
445457
case "=":
446458
case "+":
447459
event.preventDefault();
@@ -469,11 +481,6 @@ const EmbedPdfViewerContent = ({
469481
// Modifier key shortcuts (Ctrl/Cmd + key)
470482
if (mod) {
471483
switch (event.key) {
472-
case "a":
473-
case "A":
474-
// Ctrl+A: Prevent browser from selecting all UI text
475-
event.preventDefault();
476-
return;
477484
case "f":
478485
case "F":
479486
event.preventDefault();
@@ -558,6 +565,8 @@ const EmbedPdfViewerContent = ({
558565
viewerApplyChanges,
559566
cyclePdfRenderMode,
560567
viewerKeyCommand,
568+
selectionActions,
569+
getScrollState,
561570
]);
562571

563572
// Watch the annotation history API to detect when the document becomes "dirty".

frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { LinkLayer } from "@app/components/viewer/LinkLayer";
8181
import { TextSelectionHandler } from "@app/components/viewer/TextSelectionHandler";
8282
import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu";
8383
import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu";
84+
import { TextSelectionMenu } from "@app/components/viewer/TextSelectionMenu";
8485
import {
8586
RedactionPendingTracker,
8687
RedactionPendingTrackerAPI,
@@ -995,6 +996,9 @@ export function LocalEmbedPDF({
995996
documentId={documentId}
996997
pageIndex={pageIndex}
997998
background="var(--pdf-selection-bg)"
999+
selectionMenu={(props) => (
1000+
<TextSelectionMenu {...props} />
1001+
)}
9981002
/>
9991003
</div>
10001004
<TextSelectionHandler

frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,135 @@
11
import { useEffect, useRef } from "react";
2-
import { useSelectionCapability } from "@embedpdf/plugin-selection/react";
2+
import {
3+
useSelectionCapability,
4+
useSelectionPlugin,
5+
glyphAt,
6+
} from "@embedpdf/plugin-selection/react";
7+
import { useDocumentState } from "@embedpdf/core/react";
38
import { useViewer } from "@app/contexts/ViewerContext";
49
import { useDocumentReady } from "@app/components/viewer/hooks/useDocumentReady";
10+
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
511

612
/**
713
* Connects the PDF selection plugin to the shared ViewerContext.
814
*/
915
export function SelectionAPIBridge() {
1016
const { provides: selection } = useSelectionCapability();
17+
const { plugin: selectionPlugin } = useSelectionPlugin();
1118
const { registerBridge } = useViewer();
1219
const documentReady = useDocumentReady();
20+
const activeDocumentId = useActiveDocumentId();
21+
const documentState = useDocumentState(activeDocumentId ?? "");
22+
const scaleRef = useRef(1);
23+
scaleRef.current =
24+
(documentState as { scale?: number } | undefined)?.scale ?? 1;
1325

1426
const hasSelectionRef = useRef(false);
1527
const selectedTextRef = useRef("");
1628

1729
useEffect(() => {
1830
if (!selection || !documentReady) return;
1931

32+
// begin/update/end + getOrLoadGeometry are runtime-public but typed private;
33+
// matches the TextSelectionHandler word/line cast.
34+
type SelectionPluginInternals = {
35+
clearSelection: (id: string) => void;
36+
beginSelection: (id: string, page: number, glyph: number) => void;
37+
updateSelection: (id: string, page: number, glyph: number) => void;
38+
endSelection: (id: string) => void;
39+
getOrLoadGeometry: (
40+
id: string,
41+
pageIdx: number,
42+
) => { toPromise: () => Promise<unknown> };
43+
};
44+
45+
const lastGlyphOnPage = (geo: {
46+
runs: { charStart: number; glyphs: unknown[] }[];
47+
}): number => {
48+
let last = 0;
49+
for (const run of geo.runs) {
50+
const end = run.charStart + run.glyphs.length - 1;
51+
if (end > last) last = end;
52+
}
53+
return last;
54+
};
55+
56+
const selectAllInDocument = async (
57+
documentId: string,
58+
totalPages: number,
59+
) => {
60+
const plugin = selectionPlugin as unknown as SelectionPluginInternals;
61+
if (totalPages <= 0) return false;
62+
63+
// Pre-load geometry for every page so updateRectsAndSlices has data to
64+
// emit rects for, and getSelectedText has slices for, every page.
65+
try {
66+
await Promise.all(
67+
Array.from({ length: totalPages }, (_, p) =>
68+
plugin.getOrLoadGeometry(documentId, p).toPromise(),
69+
),
70+
);
71+
} catch {
72+
// Continue with whatever geometry did load
73+
}
74+
75+
const state = selection.getState(documentId);
76+
let firstPage = -1;
77+
let lastPage = -1;
78+
let lastGlyph = 0;
79+
for (let p = 0; p < totalPages; p++) {
80+
const geo = state.geometry[p];
81+
if (!geo || geo.runs.length === 0) continue;
82+
if (firstPage === -1) firstPage = p;
83+
lastPage = p;
84+
lastGlyph = lastGlyphOnPage(geo);
85+
}
86+
87+
if (firstPage === -1 || lastPage === -1) return false;
88+
89+
plugin.clearSelection(documentId);
90+
plugin.beginSelection(documentId, firstPage, 0);
91+
plugin.updateSelection(documentId, lastPage, lastGlyph);
92+
plugin.endSelection(documentId);
93+
return true;
94+
};
95+
96+
const selectWordAt = (
97+
documentId: string,
98+
pageIndex: number,
99+
x: number,
100+
y: number,
101+
) => {
102+
const plugin = selectionPlugin as unknown as {
103+
selectWord: (
104+
id: string,
105+
page: number,
106+
glyph: number,
107+
modeId: string,
108+
) => void;
109+
};
110+
const state = selection.getState(documentId);
111+
const geo = state.geometry[pageIndex];
112+
if (!geo) return false;
113+
const g = glyphAt(geo, { x, y }, 3);
114+
if (g === -1) return false;
115+
plugin.selectWord(documentId, pageIndex, g, "pointerMode");
116+
return true;
117+
};
118+
20119
const buildApi = () => ({
21120
copyToClipboard: () => selection.copyToClipboard(),
22121
getSelectedText: () => selection.getSelectedText(),
23122
getFormattedSelection: () => selection.getFormattedSelection(),
123+
selectAll: async (totalPages: number) => {
124+
const docId = activeDocumentId;
125+
if (!docId || !selectionPlugin) return false;
126+
return selectAllInDocument(docId, totalPages);
127+
},
128+
selectWordAt: (pageIndex: number, x: number, y: number) => {
129+
const docId = activeDocumentId;
130+
if (!docId || !selectionPlugin) return false;
131+
return selectWordAt(docId, pageIndex, x, y);
132+
},
24133
});
25134

26135
registerBridge("selection", {
@@ -83,17 +192,47 @@ export function SelectionAPIBridge() {
83192
}
84193
};
85194

195+
// Right-click anywhere inside a PDF page: suppress the browser's "Copy
196+
// image" menu, and if nothing is currently selected, auto-select the
197+
// word under the cursor so the floating Copy menu appears in place.
198+
const handleContextMenu = (event: MouseEvent) => {
199+
let el = event.target as HTMLElement | null;
200+
while (el && !el.dataset?.pageIndex) {
201+
el = el.parentElement;
202+
}
203+
if (!el) return;
204+
event.preventDefault();
205+
if (hasSelectionRef.current) return;
206+
const docId = activeDocumentId;
207+
if (!docId || !selectionPlugin) return;
208+
const pageIndex = Number(el.dataset.pageIndex);
209+
if (Number.isNaN(pageIndex)) return;
210+
const rect = el.getBoundingClientRect();
211+
const scale = scaleRef.current || 1;
212+
const x = (event.clientX - rect.left) / scale;
213+
const y = (event.clientY - rect.top) / scale;
214+
selectWordAt(docId, pageIndex, x, y);
215+
};
216+
86217
document.addEventListener("copy", handleCopy);
87218
document.addEventListener("keydown", handleKeyDown);
219+
document.addEventListener("contextmenu", handleContextMenu);
88220

89221
return () => {
90222
unsubChange?.();
91223
unsubCopy?.();
92224
document.removeEventListener("copy", handleCopy);
93225
document.removeEventListener("keydown", handleKeyDown);
226+
document.removeEventListener("contextmenu", handleContextMenu);
94227
registerBridge("selection", null);
95228
};
96-
}, [selection, documentReady, registerBridge]);
229+
}, [
230+
selection,
231+
selectionPlugin,
232+
activeDocumentId,
233+
documentReady,
234+
registerBridge,
235+
]);
97236

98237
return null;
99238
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { ActionIcon, Tooltip } from "@mantine/core";
2+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { createPortal } from "react-dom";
5+
import { useTranslation } from "react-i18next";
6+
import type { SelectionSelectionMenuProps } from "@embedpdf/plugin-selection/react";
7+
import { useSelectionCapability } from "@embedpdf/plugin-selection/react";
8+
9+
export function TextSelectionMenu({
10+
selected,
11+
menuWrapperProps,
12+
placement,
13+
}: SelectionSelectionMenuProps) {
14+
const { t } = useTranslation();
15+
const { provides: selection } = useSelectionCapability();
16+
const wrapperRef = useRef<HTMLDivElement>(null);
17+
const [position, setPosition] = useState<{
18+
top: number;
19+
left: number;
20+
} | null>(null);
21+
22+
const setRef = useCallback(
23+
(node: HTMLDivElement | null) => {
24+
wrapperRef.current = node;
25+
menuWrapperProps?.ref?.(node);
26+
},
27+
[menuWrapperProps],
28+
);
29+
30+
const showAbove = placement?.suggestTop ?? true;
31+
32+
useEffect(() => {
33+
if (!selected || !wrapperRef.current) {
34+
setPosition(null);
35+
return;
36+
}
37+
const update = () => {
38+
const wrapper = wrapperRef.current;
39+
if (!wrapper) return;
40+
const r = wrapper.getBoundingClientRect();
41+
setPosition({
42+
top: showAbove ? r.top - 8 : r.bottom + 8,
43+
left: r.left + r.width / 2,
44+
});
45+
};
46+
update();
47+
window.addEventListener("scroll", update, true);
48+
window.addEventListener("resize", update);
49+
return () => {
50+
window.removeEventListener("scroll", update, true);
51+
window.removeEventListener("resize", update);
52+
};
53+
}, [selected, showAbove]);
54+
55+
const handleCopy = useCallback(() => {
56+
selection?.copyToClipboard();
57+
}, [selection]);
58+
59+
const portalContent =
60+
position &&
61+
createPortal(
62+
<div
63+
style={{
64+
position: "fixed",
65+
top: position.top,
66+
left: position.left,
67+
transform: `translate(-50%, ${showAbove ? "-100%" : "0"})`,
68+
zIndex: 10000,
69+
pointerEvents: "auto",
70+
}}
71+
onMouseDown={(e) => e.preventDefault()}
72+
>
73+
<Tooltip label={t("viewer.copyText", "Copy")} withArrow>
74+
<ActionIcon
75+
variant="filled"
76+
size="md"
77+
onClick={handleCopy}
78+
aria-label={t("viewer.copyText", "Copy")}
79+
style={{
80+
backgroundColor: "var(--mantine-color-body)",
81+
border: "1px solid var(--mantine-color-default-border)",
82+
color: "var(--text-primary)",
83+
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.25)",
84+
}}
85+
>
86+
<ContentCopyIcon style={{ fontSize: 18 }} />
87+
</ActionIcon>
88+
</Tooltip>
89+
</div>,
90+
document.body,
91+
);
92+
93+
return (
94+
<>
95+
<div ref={setRef} style={menuWrapperProps?.style} />
96+
{portalContent}
97+
</>
98+
);
99+
}

0 commit comments

Comments
 (0)