mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
James Brunton
parent
3cf89b6ede
commit
ce6b2460d8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user