mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(viewer): handle keyboard shortcuts for print, save, undo, etc. (#5748)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
eaa01a5c23
commit
6000a2aaed
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user