feat(viewer): handle keyboard shortcuts for print, save, undo, etc. (#5748)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Balázs Szücs
2026-02-23 23:12:23 +00:00
committed by GitHub
co-authored by James Brunton
parent eaa01a5c23
commit 6000a2aaed
3 changed files with 138 additions and 30 deletions
@@ -131,13 +131,15 @@ const EmbedPdfViewerContent = ({
zoomActions, zoomActions,
scrollActions, scrollActions,
panActions: _panActions, panActions: _panActions,
rotationActions: _rotationActions, rotationActions,
getScrollState, getScrollState,
getRotationState, getRotationState,
setAnnotationMode, setAnnotationMode,
isAnnotationsVisible, isAnnotationsVisible,
exportActions, exportActions,
printActions,
setApplyChanges, setApplyChanges,
applyChanges: viewerApplyChanges,
} = useViewer(); } = useViewer();
const scrollState = getScrollState(); const scrollState = getScrollState();
@@ -372,40 +374,146 @@ const EmbedPdfViewerContent = ({
onZoomOut: zoomActions.zoomOut, onZoomOut: zoomActions.zoomOut,
}); });
// Handle keyboard shortcuts (zoom and search) // Handle keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return; const mod = event.ctrlKey || event.metaKey;
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed // Ctrl+P (print) and Ctrl+R (rotate) must be intercepted unconditionally
if (event.ctrlKey || event.metaKey) { // whenever the viewer is mounted, even before the user has hovered over it.
if (event.key === '=' || event.key === '+') { // Without this, the browser falls through to its native "print HTML page"
// Ctrl+= or Ctrl++ for zoom in // or "reload page" behaviour.
event.preventDefault(); if (mod) {
zoomActions.zoomIn(); const target = event.target as Element;
} else if (event.key === '-' || event.key === '_') { const isInTextInput =
// Ctrl+- for zoom out target.tagName === 'INPUT' ||
event.preventDefault(); target.tagName === 'TEXTAREA' ||
zoomActions.zoomOut(); (target as HTMLElement).isContentEditable;
} else if (event.key === 'f' || event.key === 'F') {
// Ctrl+F for search if (!isInTextInput) {
event.preventDefault(); switch (event.key) {
if (isSearchInterfaceVisible) { case 'p':
// If already open, trigger refocus event case 'P':
window.dispatchEvent(new CustomEvent('refocus-search-input')); event.preventDefault();
} else { printActions.print();
// Open search interface return;
searchInterfaceActions.open(); case 'r':
case 'R':
// Ctrl+R: Rotate forward; Ctrl+Shift+R: Rotate backward
event.preventDefault();
if (event.shiftKey) {
rotationActions.rotateBackward();
} else {
rotationActions.rotateForward();
}
return;
} }
} }
} }
// All remaining shortcuts require the viewer to be hovered so they
// don't conflict with the rest of the UI when the viewer is mounted
// but not the active focus target.
if (!isViewerHovered) return;
// Modifier key shortcuts (Ctrl/Cmd + key)
if (mod) {
switch (event.key) {
case '=':
case '+':
event.preventDefault();
zoomActions.zoomIn();
return;
case '-':
case '_':
event.preventDefault();
zoomActions.zoomOut();
return;
case '0':
// Ctrl+0: Reset zoom to fit width
event.preventDefault();
zoomActions.requestZoom('fit-width');
return;
case 'a':
case 'A':
// Ctrl+A: Prevent browser from selecting all UI text
event.preventDefault();
return;
case 'f':
case 'F':
event.preventDefault();
if (isSearchInterfaceVisible) {
window.dispatchEvent(new CustomEvent('refocus-search-input'));
} else {
searchInterfaceActions.open();
}
return;
case 's':
case 'S':
// Ctrl+S: Save/apply changes
if (!event.shiftKey) {
event.preventDefault();
if (viewerApplyChanges) {
viewerApplyChanges();
}
}
return;
case 'z':
case 'Z':
// Ctrl+Z: Undo; Ctrl+Shift+Z: Redo
event.preventDefault();
if (event.shiftKey) {
historyApiRef.current?.redo?.();
} else {
historyApiRef.current?.undo?.();
}
return;
case 'y':
case 'Y':
// Ctrl+Y: Redo
event.preventDefault();
historyApiRef.current?.redo?.();
return;
}
return;
}
// Non-modifier shortcuts
switch (event.key) {
case 'Home':
event.preventDefault();
scrollActions.scrollToFirstPage();
return;
case 'End':
event.preventDefault();
scrollActions.scrollToLastPage();
return;
case 'PageUp':
event.preventDefault();
scrollActions.scrollToPreviousPage();
return;
case 'PageDown':
event.preventDefault();
scrollActions.scrollToNextPage();
return;
case 'Escape':
if (isSearchInterfaceVisible) {
event.preventDefault();
searchInterfaceActions.close();
}
return;
}
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]); }, [
isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions,
scrollActions, printActions, exportActions, rotationActions, historyApiRef,
viewerApplyChanges,
]);
// Watch the annotation history API to detect when the document becomes "dirty". // Watch the annotation history API to detect when the document becomes "dirty".
// We treat any change that makes the history undoable as unsaved changes until // We treat any change that makes the history undoable as unsaved changes until
@@ -720,11 +828,11 @@ const EmbedPdfViewerContent = ({
// Only attempt if PDF is loaded (totalPages > 0) // Only attempt if PDF is loaded (totalPages > 0)
if (currentState.totalPages > 0) { if (currentState.totalPages > 0) {
_rotationActions.setRotation(rotationToRestore); rotationActions.setRotation(rotationToRestore);
// Check if rotation succeeded after a brief delay // Check if rotation succeeded after a brief delay
setTimeout(() => { setTimeout(() => {
const currentRotation = _rotationActions.getRotation(); const currentRotation = rotationActions.getRotation();
if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) { if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) {
// Success or max attempts reached - clear pending // Success or max attempts reached - clear pending
pendingRotationRestoreRef.current = null; pendingRotationRestoreRef.current = null;
@@ -755,7 +863,7 @@ const EmbedPdfViewerContent = ({
// Start attempting after initial delay // Start attempting after initial delay
const timer = setTimeout(attemptRotation, 150); const timer = setTimeout(attemptRotation, 150);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [scrollState.totalPages, _rotationActions, getScrollState]); }, [scrollState.totalPages, rotationActions, getScrollState]);
// Register applyChanges with ViewerContext so tools can access it directly // Register applyChanges with ViewerContext so tools can access it directly
useEffect(() => { useEffect(() => {
@@ -19,7 +19,7 @@ export interface ZoomActions {
zoomIn: () => void; zoomIn: () => void;
zoomOut: () => void; zoomOut: () => void;
toggleMarqueeZoom: () => void; toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void; requestZoom: (level: any, center?: any) => void;
} }
export interface PanActions { export interface PanActions {
@@ -199,10 +199,10 @@ export function createViewerActions({
api.toggleMarqueeZoom(); api.toggleMarqueeZoom();
} }
}, },
requestZoom: (level: number) => { requestZoom: (level: any, center?: any) => {
const api = registry.current.zoom?.api; const api = registry.current.zoom?.api;
if (api?.requestZoom) { if (api?.requestZoom) {
api.requestZoom(level); api.requestZoom(level, center);
} }
}, },
}; };
@@ -43,7 +43,7 @@ export interface ZoomAPIWrapper {
zoomIn: () => void; zoomIn: () => void;
zoomOut: () => void; zoomOut: () => void;
toggleMarqueeZoom: () => void; toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void; requestZoom: (level: any, center?: any) => void;
} }
export interface PanAPIWrapper { export interface PanAPIWrapper {