From 6000a2aaed9f3130b28cba996d809a0f4f7bffe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:12:23 +0100 Subject: [PATCH] feat(viewer): handle keyboard shortcuts for print, save, undo, etc. (#5748) Co-authored-by: James Brunton --- .../core/components/viewer/EmbedPdfViewer.tsx | 160 +++++++++++++++--- .../src/core/contexts/viewer/viewerActions.ts | 6 +- .../src/core/contexts/viewer/viewerBridges.ts | 2 +- 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 30f8e2f8b..f6904eee3 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -131,13 +131,15 @@ const EmbedPdfViewerContent = ({ zoomActions, scrollActions, panActions: _panActions, - rotationActions: _rotationActions, + rotationActions, getScrollState, getRotationState, setAnnotationMode, isAnnotationsVisible, exportActions, + printActions, setApplyChanges, + applyChanges: viewerApplyChanges, } = useViewer(); const scrollState = getScrollState(); @@ -372,40 +374,146 @@ const EmbedPdfViewerContent = ({ onZoomOut: zoomActions.zoomOut, }); - // Handle keyboard shortcuts (zoom and search) + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (!isViewerHovered) return; + const mod = event.ctrlKey || event.metaKey; - // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed - if (event.ctrlKey || event.metaKey) { - if (event.key === '=' || event.key === '+') { - // Ctrl+= or Ctrl++ for zoom in - event.preventDefault(); - zoomActions.zoomIn(); - } else if (event.key === '-' || event.key === '_') { - // Ctrl+- for zoom out - event.preventDefault(); - zoomActions.zoomOut(); - } else if (event.key === 'f' || event.key === 'F') { - // Ctrl+F for search - event.preventDefault(); - if (isSearchInterfaceVisible) { - // If already open, trigger refocus event - window.dispatchEvent(new CustomEvent('refocus-search-input')); - } else { - // Open search interface - searchInterfaceActions.open(); + // Ctrl+P (print) and Ctrl+R (rotate) must be intercepted unconditionally + // whenever the viewer is mounted, even before the user has hovered over it. + // Without this, the browser falls through to its native "print HTML page" + // or "reload page" behaviour. + if (mod) { + const target = event.target as Element; + const isInTextInput = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + (target as HTMLElement).isContentEditable; + + if (!isInTextInput) { + switch (event.key) { + case 'p': + case 'P': + event.preventDefault(); + printActions.print(); + return; + 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); return () => { 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". // 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) if (currentState.totalPages > 0) { - _rotationActions.setRotation(rotationToRestore); + rotationActions.setRotation(rotationToRestore); // Check if rotation succeeded after a brief delay setTimeout(() => { - const currentRotation = _rotationActions.getRotation(); + const currentRotation = rotationActions.getRotation(); if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) { // Success or max attempts reached - clear pending pendingRotationRestoreRef.current = null; @@ -755,7 +863,7 @@ const EmbedPdfViewerContent = ({ // Start attempting after initial delay const timer = setTimeout(attemptRotation, 150); return () => clearTimeout(timer); - }, [scrollState.totalPages, _rotationActions, getScrollState]); + }, [scrollState.totalPages, rotationActions, getScrollState]); // Register applyChanges with ViewerContext so tools can access it directly useEffect(() => { diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index 6eebfb2f4..2a704163e 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -19,7 +19,7 @@ export interface ZoomActions { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; + requestZoom: (level: any, center?: any) => void; } export interface PanActions { @@ -199,10 +199,10 @@ export function createViewerActions({ api.toggleMarqueeZoom(); } }, - requestZoom: (level: number) => { + requestZoom: (level: any, center?: any) => { const api = registry.current.zoom?.api; if (api?.requestZoom) { - api.requestZoom(level); + api.requestZoom(level, center); } }, }; diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index 93e02ee5c..3812acaf5 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -43,7 +43,7 @@ export interface ZoomAPIWrapper { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; + requestZoom: (level: any, center?: any) => void; } export interface PanAPIWrapper {