handlePageClick(pageIndex)}
@@ -137,10 +124,10 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
cursor: 'pointer',
borderRadius: '8px',
padding: '8px',
- backgroundColor: selectedPage === pageIndex + 1
+ backgroundColor: scrollState.currentPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#364FC7' : '#e7f5ff')
: 'transparent',
- border: selectedPage === pageIndex + 1
+ border: scrollState.currentPage === pageIndex + 1
? '2px solid #1c7ed6'
: '2px solid transparent',
transition: 'all 0.2s ease',
@@ -150,12 +137,12 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
gap: '8px'
}}
onMouseEnter={(e) => {
- if (selectedPage !== pageIndex + 1) {
+ if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = actualColorScheme === 'dark' ? '#25262b' : '#f1f3f5';
}
}}
onMouseLeave={(e) => {
- if (selectedPage !== pageIndex + 1) {
+ if (scrollState.currentPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
@@ -209,7 +196,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
diff --git a/frontend/src/components/viewer/ZoomAPIBridge.tsx b/frontend/src/components/viewer/ZoomAPIBridge.tsx
index 709f408b8..abf52d37d 100644
--- a/frontend/src/components/viewer/ZoomAPIBridge.tsx
+++ b/frontend/src/components/viewer/ZoomAPIBridge.tsx
@@ -1,12 +1,20 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useZoom } from '@embedpdf/plugin-zoom/react';
+import { useViewer } from '../../contexts/ViewerContext';
/**
- * Component that runs inside EmbedPDF context and exports zoom controls globally
+ * Component that runs inside EmbedPDF context and manages zoom state locally
*/
export function ZoomAPIBridge() {
const { provides: zoom, state: zoomState } = useZoom();
+ const { registerBridge } = useViewer();
const hasSetInitialZoom = useRef(false);
+
+ // Store state locally
+ const [_localState, setLocalState] = useState({
+ currentZoom: 1.4,
+ zoomPercent: 140
+ });
// Set initial zoom once when plugin is ready
useEffect(() => {
@@ -20,20 +28,21 @@ export function ZoomAPIBridge() {
}, [zoom]);
useEffect(() => {
- if (zoom) {
-
- // Export zoom controls to global window for right rail access
- (window as any).embedPdfZoom = {
- zoomIn: () => zoom.zoomIn(),
- zoomOut: () => zoom.zoomOut(),
- toggleMarqueeZoom: () => zoom.toggleMarqueeZoom(),
- requestZoom: (level: any) => zoom.requestZoom(level),
- currentZoom: zoomState?.currentZoomLevel || 1.4,
- zoomPercent: Math.round((zoomState?.currentZoomLevel || 1.4) * 100),
+ if (zoom && zoomState) {
+ // Update local state
+ const newState = {
+ currentZoom: zoomState.currentZoomLevel || 1.4,
+ zoomPercent: Math.round((zoomState.currentZoomLevel || 1.4) * 100),
};
+ setLocalState(newState);
+ // Register this bridge with ViewerContext
+ registerBridge('zoom', {
+ state: newState,
+ api: zoom
+ });
}
- }, [zoom, zoomState]);
+ }, [zoom, zoomState, registerBridge]);
return null;
}
diff --git a/frontend/src/contexts/ViewerContext.tsx b/frontend/src/contexts/ViewerContext.tsx
index 1b470c058..86fd8d844 100644
--- a/frontend/src/contexts/ViewerContext.tsx
+++ b/frontend/src/contexts/ViewerContext.tsx
@@ -1,33 +1,393 @@
-import React, { createContext, useContext, useState, ReactNode } from 'react';
+import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
-interface ViewerContextType {
- // Thumbnail sidebar state
- isThumbnailSidebarVisible: boolean;
- toggleThumbnailSidebar: () => void;
- setThumbnailSidebarVisible: (visible: boolean) => void;
+// State interfaces - represent the shape of data from each bridge
+interface ScrollState {
+ currentPage: number;
+ totalPages: number;
}
-const ViewerContext = createContext(null);
+interface ZoomState {
+ currentZoom: number;
+ zoomPercent: number;
+}
+
+interface PanState {
+ isPanning: boolean;
+}
+
+interface SelectionState {
+ hasSelection: boolean;
+}
+
+interface SpreadState {
+ spreadMode: any;
+ isDualPage: boolean;
+}
+
+interface RotationState {
+ rotation: number;
+}
+
+// Bridge registration interface - bridges register with state and API
+interface BridgeRef {
+ state: any;
+ api: any;
+}
+
+/**
+ * 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
+ */
+interface ViewerContextType {
+ // UI state managed by this context
+ isThumbnailSidebarVisible: boolean;
+ toggleThumbnailSidebar: () => void;
+
+ // State getters - read current state from bridges
+ getScrollState: () => ScrollState;
+ getZoomState: () => ZoomState;
+ getPanState: () => PanState;
+ getSelectionState: () => SelectionState;
+ getSpreadState: () => SpreadState;
+ getRotationState: () => RotationState;
+ getSearchResults: () => any[] | null;
+ getSearchActiveIndex: () => number;
+ getThumbnailAPI: () => any;
+
+ // 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: () => any;
+ };
+
+ spreadActions: {
+ setSpreadMode: (mode: any) => void;
+ getSpreadMode: () => any;
+ toggleSpreadMode: () => void;
+ };
+
+ rotationActions: {
+ rotateForward: () => void;
+ rotateBackward: () => void;
+ setRotation: (rotation: number) => void;
+ getRotation: () => number;
+ };
+
+ searchActions: {
+ search: (query: string) => Promise;
+ next: () => void;
+ previous: () => void;
+ clear: () => void;
+ };
+
+ // Bridge registration - internal use by bridges
+ registerBridge: (type: string, ref: BridgeRef) => void;
+}
+
+export const ViewerContext = createContext(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC = ({ children }) => {
+ // UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
+
+ // Bridge registry - bridges register their state and APIs here
+ const bridgeRefs = useRef({
+ scroll: null as BridgeRef | null,
+ zoom: null as BridgeRef | null,
+ pan: null as BridgeRef | null,
+ selection: null as BridgeRef | null,
+ search: null as BridgeRef | null,
+ spread: null as BridgeRef | null,
+ rotation: null as BridgeRef | null,
+ thumbnail: null as BridgeRef | null,
+ });
+
+ const registerBridge = (type: string, ref: BridgeRef) => {
+ bridgeRefs.current[type as keyof typeof bridgeRefs.current] = ref;
+ };
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
- const setThumbnailSidebarVisible = (visible: boolean) => {
- setIsThumbnailSidebarVisible(visible);
+ // 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: null, isDualPage: false };
+ };
+
+ const getRotationState = (): RotationState => {
+ return bridgeRefs.current.rotation?.state || { rotation: 0 };
+ };
+
+ const getSearchResults = () => {
+ return bridgeRefs.current.search?.state?.results || null;
+ };
+
+ const getSearchActiveIndex = () => {
+ return bridgeRefs.current.search?.state?.activeIndex || 0;
+ };
+
+ const getThumbnailAPI = () => {
+ return bridgeRefs.current.thumbnail?.api || null;
+ };
+
+ // 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) {
+ api.zoomIn();
+ }
+ },
+ zoomOut: () => {
+ const api = bridgeRefs.current.zoom?.api;
+ if (api?.zoomOut) {
+ 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: any) => {
+ 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 value: ViewerContextType = {
+ // UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
- setThumbnailSidebarVisible,
+
+ // State getters
+ getScrollState,
+ getZoomState,
+ getPanState,
+ getSelectionState,
+ getSpreadState,
+ getRotationState,
+ getSearchResults,
+ getSearchActiveIndex,
+ getThumbnailAPI,
+
+ // Actions
+ scrollActions,
+ zoomActions,
+ panActions,
+ selectionActions,
+ spreadActions,
+ rotationActions,
+ searchActions,
+
+ // Bridge registration
+ registerBridge,
};
return (