Files
Stirling-PDF/frontend/src/core/contexts/ViewerContext.tsx
T
2026-03-25 15:38:42 +00:00

544 lines
16 KiB
TypeScript

import React, {
createContext,
useContext,
useState,
ReactNode,
useRef,
useCallback,
} from 'react';
import { useNavigation } from '@app/contexts/NavigationContext';
import { preferencesService, type PdfRenderMode } from '@app/services/preferencesService';
import {
createViewerActions,
ScrollActions,
ZoomActions,
PanActions,
SelectionActions,
SpreadActions,
RotationActions,
SearchActions,
ExportActions,
BookmarkActions,
AttachmentActions,
PrintActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
BridgeApiMap,
BridgeStateMap,
BridgeKey,
ViewerBridgeRegistry,
createBridgeRegistry,
registerBridge as setBridgeRef,
ScrollState,
ZoomState,
PanState,
SelectionState,
SpreadState,
RotationState,
SearchState,
ExportState,
ThumbnailAPIWrapper,
BookmarkState,
AttachmentState,
DocumentPermissionsState,
PdfPermissionFlag,
} from '@app/contexts/viewer/viewerBridges';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
function useImmediateNotifier<Args extends unknown[]>() {
const callbacksRef = useRef(new Set<(...args: Args) => void>());
const register = useCallback((callback: (...args: Args) => void) => {
callbacksRef.current.add(callback);
return () => {
callbacksRef.current.delete(callback);
};
}, []);
const trigger = useCallback((...args: Args) => {
callbacksRef.current.forEach(cb => {
try {
cb(...args);
} catch (error) {
console.error('Immediate callback error:', error);
}
});
}, []);
return { register, trigger };
}
/**
* ViewerContext provides a unified interface to EmbedPDF functionality.
*
* Architecture:
* - Bridges store their own state locally and register with this context
* - Context provides read-only access to bridge state via getter functions
* - Actions call EmbedPDF APIs directly through bridge references
* - No circular dependencies - bridges don't call back into this context
*/
export interface ViewerContextType {
// UI state managed by this context
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
isAttachmentSidebarVisible: boolean;
toggleAttachmentSidebar: () => void;
isCommentsSidebarVisible: boolean;
setCommentsSidebarVisible: (visible: boolean) => void;
toggleCommentsSidebar: () => void;
/** Request focus or highlight of a comment card in the sidebar (opens sidebar, then scrolls + flashes or focuses input). */
highlightCommentRequest: {
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null;
requestCommentFocus: (documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => void;
clearHighlightCommentRequest: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
searchInterfaceActions: {
open: () => void;
close: () => void;
toggle: () => void;
};
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
// Annotation/drawing mode for viewer
isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void;
// Active file index for multi-file viewing
activeFileIndex: number;
setActiveFileIndex: (index: number) => void;
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
getPanState: () => PanState;
getSelectionState: () => SelectionState;
getSpreadState: () => SpreadState;
getRotationState: () => RotationState;
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
getBookmarkState: () => BookmarkState;
hasBookmarkSupport: () => boolean;
getAttachmentState: () => AttachmentState;
hasAttachmentSupport: () => boolean;
getDocumentPermissions: () => DocumentPermissionsState;
hasPermission: (flag: PdfPermissionFlag) => boolean;
// 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;
registerImmediatePanUpdate: (callback: (isPanning: 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;
triggerImmediatePanUpdate: (isPanning: boolean) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
attachmentActions: AttachmentActions;
printActions: PrintActions;
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
) => void;
// Save changes function - registered by EmbedPdfViewer
applyChanges: (() => Promise<void>) | null;
setApplyChanges: (fn: (() => Promise<void>) | null) => void;
// PDF page color rendering mode (viewer-only, never modifies the PDF)
pdfRenderMode: PdfRenderMode;
cyclePdfRenderMode: () => void;
}
export const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false);
const [isCommentsSidebarVisible, setIsCommentsSidebarVisible] = useState(false);
const [highlightCommentRequest, setHighlightCommentRequest] = useState<{
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null>(null);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
const [pdfRenderMode, setPdfRenderModeState] = useState<PdfRenderMode>(
() => preferencesService.getPreference('pdfRenderMode')
);
// Get current navigation state to check if we're in sign mode
useNavigation();
// Bridge registry - bridges register their state and APIs here
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
// Apply changes function - registered by EmbedPdfViewer
const applyChangesRef = useRef<(() => Promise<void>) | null>(null);
const setApplyChanges = useCallback((fn: (() => Promise<void>) | null) => {
applyChangesRef.current = fn;
}, []);
const applyChanges = useCallback(async () => {
if (applyChangesRef.current) {
await applyChangesRef.current();
}
}, []);
const {
register: registerImmediateZoomUpdate,
trigger: triggerImmediateZoomInternal,
} = useImmediateNotifier<[number]>();
const {
register: registerImmediateScrollUpdate,
trigger: triggerImmediateScrollInternal,
} = useImmediateNotifier<[number, number]>();
const {
register: registerImmediateSpreadUpdate,
trigger: triggerImmediateSpreadInternal,
} = useImmediateNotifier<[SpreadMode, boolean]>();
const {
register: registerImmediatePanUpdate,
trigger: triggerImmediatePanInternal,
} = useImmediateNotifier<[boolean]>();
const triggerImmediateZoomUpdate = useCallback(
(percent: number) => {
triggerImmediateZoomInternal(percent);
},
[triggerImmediateZoomInternal]
);
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 triggerImmediatePanUpdate = useCallback(
(isPanning: boolean) => {
triggerImmediatePanInternal(isPanning);
},
[triggerImmediatePanInternal]
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
[]
);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleBookmarkSidebar = () => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const toggleAttachmentSidebar = () => {
setIsAttachmentSidebarVisible(prev => !prev);
};
const setCommentsSidebarVisible = (visible: boolean) => {
setIsCommentsSidebarVisible(visible);
};
const toggleCommentsSidebar = () => {
setIsCommentsSidebarVisible(prev => !prev);
};
const requestCommentFocus = useCallback(
(documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => {
setIsCommentsSidebarVisible(true);
setHighlightCommentRequest({
documentId,
pageIndex,
annotationId,
action: hasContent ? 'highlight' : 'focus',
});
},
[]
);
const clearHighlightCommentRequest = useCallback(() => {
setHighlightCommentRequest(null);
}, []);
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
toggle: () => setSearchInterfaceVisible(prev => !prev),
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
const setAnnotationMode = (enabled: boolean) => {
setIsAnnotationModeState(enabled);
};
const cyclePdfRenderMode = useCallback(() => {
setPdfRenderModeState(prev => {
const next: PdfRenderMode =
prev === 'normal' ? 'dark' : prev === 'dark' ? 'sepia' : 'normal';
preferencesService.setPreference('pdfRenderMode', next);
return next;
});
}, []);
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
};
const getZoomState = (): ZoomState => {
return bridgeRefs.current.zoom?.state || { currentZoom: 1.4, zoomPercent: 140 };
};
const getPanState = (): PanState => {
return bridgeRefs.current.pan?.state || { isPanning: false };
};
const getSelectionState = (): SelectionState => {
return bridgeRefs.current.selection?.state || { hasSelection: false };
};
const getSpreadState = (): SpreadState => {
return bridgeRefs.current.spread?.state || { spreadMode: SpreadMode.None, isDualPage: false };
};
const getRotationState = (): RotationState => {
return bridgeRefs.current.rotation?.state || { rotation: 0 };
};
const getSearchState = (): SearchState => {
return bridgeRefs.current.search?.state || { results: null, activeIndex: 0 };
};
const getThumbnailAPI = () => {
return bridgeRefs.current.thumbnail?.api || null;
};
const getExportState = (): ExportState => {
return bridgeRefs.current.export?.state || { canExport: false };
};
const getBookmarkState = (): BookmarkState => {
return (
bridgeRefs.current.bookmark?.state || {
bookmarks: null,
isLoading: false,
error: null,
}
);
};
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
const getAttachmentState = (): AttachmentState => {
return (
bridgeRefs.current.attachment?.state || {
attachments: null,
isLoading: false,
error: null,
}
);
};
const hasAttachmentSupport = () => Boolean(bridgeRefs.current.attachment);
const getDocumentPermissions = (): DocumentPermissionsState => {
return bridgeRefs.current.permissions?.state || {
isEncrypted: false,
isOwnerUnlocked: false,
permissions: PdfPermissionFlag.AllowAll,
canPrint: true,
canModifyContents: true,
canCopyContents: true,
canModifyAnnotations: true,
canFillForms: true,
canExtractForAccessibility: true,
canAssembleDocument: true,
canPrintHighQuality: true,
};
};
const hasPermission = (flag: PdfPermissionFlag): boolean => {
const api = bridgeRefs.current.permissions?.api;
if (api?.hasPermission) {
return api.hasPermission(flag);
}
// Default: allow all permissions - warn in development
if (process.env.NODE_ENV === 'development') {
console.warn('[ViewerContext] Permissions API not available, defaulting to allow');
}
return true;
};
// Action handlers - call APIs directly
const {
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
bookmarkActions,
attachmentActions,
printActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
getZoomState,
triggerImmediateZoomUpdate,
});
const value: ViewerContextType = {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
isAttachmentSidebarVisible,
toggleAttachmentSidebar,
isCommentsSidebarVisible,
setCommentsSidebarVisible,
toggleCommentsSidebar,
highlightCommentRequest,
requestCommentFocus,
clearHighlightCommentRequest,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,
isAnnotationMode,
setAnnotationMode,
// Active file index
activeFileIndex,
setActiveFileIndex,
// State getters
getScrollState,
getZoomState,
getPanState,
getSelectionState,
getSpreadState,
getRotationState,
getSearchState,
getThumbnailAPI,
getExportState,
getBookmarkState,
hasBookmarkSupport,
getAttachmentState,
hasAttachmentSupport,
getDocumentPermissions,
hasPermission,
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
registerImmediatePanUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
triggerImmediatePanUpdate,
// Actions
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
bookmarkActions,
attachmentActions,
printActions,
// Bridge registration
registerBridge,
// Apply changes
applyChanges,
setApplyChanges,
// PDF page rendering mode
pdfRenderMode,
cyclePdfRenderMode,
};
return (
<ViewerContext.Provider value={value}>
{children}
</ViewerContext.Provider>
);
};
export const useViewer = (): ViewerContextType => {
const context = useContext(ViewerContext);
if (!context) {
throw new Error('useViewer must be used within a ViewerProvider');
}
return context;
};