Viewer update and autozoom (#4800)

Updated embed PDF
Added Autozoom
Added file page size to metadata for use in calculations for autozoom,
will come in handy elsewhere.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Reece Browne
2025-11-10 13:52:13 +00:00
committed by GitHub
co-authored by James Brunton
parent 3cf89b6ede
commit ce6b2460d8
15 changed files with 1214 additions and 622 deletions
+120 -438
View File
@@ -1,112 +1,55 @@
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import React, {
createContext,
useContext,
useState,
ReactNode,
useRef,
useCallback,
} from 'react';
import { useNavigation } from '@app/contexts/NavigationContext';
import {
createViewerActions,
ScrollActions,
ZoomActions,
PanActions,
SelectionActions,
SpreadActions,
RotationActions,
SearchActions,
ExportActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
BridgeApiMap,
BridgeStateMap,
BridgeKey,
ViewerBridgeRegistry,
createBridgeRegistry,
registerBridge as setBridgeRef,
ScrollState,
ZoomState,
PanState,
SelectionState,
SpreadState,
RotationState,
SearchState,
ExportState,
ThumbnailAPIWrapper,
} from '@app/contexts/viewer/viewerBridges';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
// Bridge API interfaces - these match what the bridges provide
interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
}
function useImmediateNotifier<Args extends unknown[]>() {
const callbackRef = useRef<((...args: Args) => void) | null>(null);
interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
}
const register = useCallback((callback: (...args: Args) => void) => {
callbackRef.current = callback;
}, []);
interface PanAPIWrapper {
enable: () => void;
disable: () => void;
toggle: () => void;
}
const trigger = useCallback((...args: Args) => {
callbackRef.current?.(...args);
}, []);
interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
}
interface SpreadAPIWrapper {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
}
interface RotationAPIWrapper {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
clear: () => void;
next: () => void;
previous: () => void;
}
interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
}
interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
currentPage: number;
totalPages: number;
}
interface ZoomState {
currentZoom: number;
zoomPercent: number;
}
interface PanState {
isPanning: boolean;
}
interface SelectionState {
hasSelection: boolean;
}
interface SpreadState {
spreadMode: SpreadMode;
isDualPage: boolean;
}
interface RotationState {
rotation: number;
}
interface SearchResult {
pageIndex: number;
rects: Array<{
origin: { x: number; y: number };
size: { width: number; height: number };
}>;
}
interface SearchState {
results: SearchResult[] | null;
activeIndex: number;
}
interface ExportState {
canExport: boolean;
}
// Bridge registration interface - bridges register with state and API
interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
return { register, trigger };
}
/**
@@ -150,66 +93,28 @@ interface ViewerContextType {
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: {
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
};
zoomActions: {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
};
panActions: {
enablePan: () => void;
disablePan: () => void;
togglePan: () => void;
};
selectionActions: {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => unknown;
};
spreadActions: {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
};
rotationActions: {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
};
searchActions: {
search: (query: string) => Promise<void>;
next: () => void;
previous: () => void;
clear: () => void;
};
exportActions: {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
registerBridge: <K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => void;
}
export const ViewerContext = createContext<ViewerContextType | null>(null);
@@ -229,56 +134,51 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
useNavigation();
// Bridge registry - bridges register their state and APIs here
const bridgeRefs = useRef({
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
zoom: null as BridgeRef<ZoomState, ZoomAPIWrapper> | null,
pan: null as BridgeRef<PanState, PanAPIWrapper> | null,
selection: null as BridgeRef<SelectionState, SelectionAPIWrapper> | null,
search: null as BridgeRef<SearchState, SearchAPIWrapper> | null,
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
});
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
const {
register: registerImmediateZoomUpdate,
trigger: triggerImmediateZoomInternal,
} = useImmediateNotifier<[number]>();
const {
register: registerImmediateScrollUpdate,
trigger: triggerImmediateScrollInternal,
} = useImmediateNotifier<[number, number]>();
const {
register: registerImmediateSpreadUpdate,
trigger: triggerImmediateSpreadInternal,
} = useImmediateNotifier<[SpreadMode, boolean]>();
// Immediate scroll callback for responsive display updates
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
const triggerImmediateZoomUpdate = useCallback(
(percent: number) => {
triggerImmediateZoomInternal(percent);
},
[triggerImmediateZoomInternal]
);
const registerBridge = (type: string, ref: BridgeRef) => {
// Type-safe assignment - we know the bridges will provide correct types
switch (type) {
case 'scroll':
bridgeRefs.current.scroll = ref as BridgeRef<ScrollState, ScrollAPIWrapper>;
break;
case 'zoom':
bridgeRefs.current.zoom = ref as BridgeRef<ZoomState, ZoomAPIWrapper>;
break;
case 'pan':
bridgeRefs.current.pan = ref as BridgeRef<PanState, PanAPIWrapper>;
break;
case 'selection':
bridgeRefs.current.selection = ref as BridgeRef<SelectionState, SelectionAPIWrapper>;
break;
case 'search':
bridgeRefs.current.search = ref as BridgeRef<SearchState, SearchAPIWrapper>;
break;
case 'spread':
bridgeRefs.current.spread = ref as BridgeRef<SpreadState, SpreadAPIWrapper>;
break;
case 'rotation':
bridgeRefs.current.rotation = ref as BridgeRef<RotationState, RotationAPIWrapper>;
break;
case 'thumbnail':
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
break;
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
}
};
const triggerImmediateScrollUpdate = useCallback(
(currentPage: number, totalPages: number) => {
triggerImmediateScrollInternal(currentPage, totalPages);
},
[triggerImmediateScrollInternal]
);
const triggerImmediateSpreadUpdate = useCallback(
(mode: SpreadMode, isDualPage: boolean = mode !== SpreadMode.None) => {
triggerImmediateSpreadInternal(mode, isDualPage);
},
[triggerImmediateSpreadInternal]
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
[]
);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
@@ -334,241 +234,21 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const scrollState = getScrollState();
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage && scrollState.totalPages > 0) {
api.scrollToPage({ pageNumber: scrollState.totalPages });
}
}
};
const zoomActions = {
zoomIn: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomIn) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomIn();
}
},
zoomOut: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomOut) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomOut();
}
},
toggleMarqueeZoom: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
const api = bridgeRefs.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
}
};
const panActions = {
enablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
}
};
const selectionActions = {
copyToClipboard: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText();
}
return '';
},
getFormattedSelection: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
return null;
}
};
const spreadActions = {
setSpreadMode: (mode: SpreadMode) => {
const api = bridgeRefs.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
}
};
const rotationActions = {
rotateForward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
const api = bridgeRefs.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
return 0;
}
};
const searchActions = {
search: async (query: string) => {
const api = bridgeRefs.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
const api = bridgeRefs.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
const api = bridgeRefs.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
const api = bridgeRefs.current.search?.api;
if (api?.clear) {
api.clear();
}
}
};
const exportActions = {
download: () => {
const api = bridgeRefs.current.export?.api;
if (api?.download) {
api.download();
}
},
saveAsCopy: async () => {
const api = bridgeRefs.current.export?.api;
if (api?.saveAsCopy) {
try {
const result = api.saveAsCopy();
return await result.toPromise();
} catch (error) {
console.error('Failed to save PDF copy:', error);
return null;
}
}
return null;
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
immediateScrollUpdateCallback.current = callback;
};
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
if (immediateScrollUpdateCallback.current) {
immediateScrollUpdateCallback.current(currentPage, totalPages);
}
};
const triggerImmediateZoomUpdate = (zoomPercent: number) => {
if (immediateZoomUpdateCallback.current) {
immediateZoomUpdateCallback.current(zoomPercent);
}
};
const {
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
getZoomState,
triggerImmediateZoomUpdate,
});
const value: ViewerContextType = {
// UI state
@@ -600,8 +280,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
// Actions
scrollActions,