diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx
index caffbee07..fc0334b91 100644
--- a/frontend/src/components/shared/RightRail.tsx
+++ b/frontend/src/components/shared/RightRail.tsx
@@ -18,8 +18,7 @@ import { ViewerContext } from '../../contexts/ViewerContext';
export default function RightRail() {
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
- const [_currentRotation, setCurrentRotation] = useState(0);
-
+
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
@@ -35,14 +34,6 @@ export default function RightRail() {
// Navigation view
const { workbench: currentView } = useNavigationState();
- // Update rotation display when switching to viewer mode
- useEffect(() => {
- if (currentView === 'viewer' && viewerContext) {
- const rotationState = viewerContext.getRotationState();
- setCurrentRotation((rotationState?.rotation ?? 0) * 90);
- }
- }, [currentView, viewerContext]);
-
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
@@ -244,9 +235,9 @@ export default function RightRail() {
- {}}
+ {}}
/>
diff --git a/frontend/src/components/viewer/CustomSearchLayer.tsx b/frontend/src/components/viewer/CustomSearchLayer.tsx
index 31c15c0cc..d25a5ad04 100644
--- a/frontend/src/components/viewer/CustomSearchLayer.tsx
+++ b/frontend/src/components/viewer/CustomSearchLayer.tsx
@@ -26,8 +26,8 @@ interface SearchResultState {
export function CustomSearchLayer({
pageIndex,
scale,
- highlightColor = '#FFFF00',
- activeHighlightColor = '#FFBF00',
+ highlightColor = 'var(--search-highlight-bg)',
+ activeHighlightColor = 'var(--search-highlight-active-bg)',
opacity = 0.6,
padding = 2,
borderRadius = 4
@@ -107,7 +107,7 @@ export function CustomSearchLayer({
transition: 'opacity 0.3s ease-in-out, background-color 0.2s ease-in-out',
pointerEvents: 'none',
boxShadow: originalIndex === searchResultState?.activeResultIndex
- ? '0 0 0 1px rgba(255, 191, 0, 0.8)'
+ ? '0 0 0 1px var(--search-highlight-active-border)'
: 'none'
}}
/>
diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx
index 6764b0a76..ac61bca5b 100644
--- a/frontend/src/components/viewer/EmbedPdfViewer.tsx
+++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx
@@ -28,7 +28,7 @@ const EmbedPdfViewerContent = ({
const viewerRef = React.useRef(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
-
+
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
@@ -64,21 +64,27 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
- // Handle scroll wheel zoom
+ // Handle scroll wheel zoom with accumulator for smooth trackpad pinch
React.useEffect(() => {
+ let accumulator = 0;
+
const handleWheel = (event: WheelEvent) => {
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
- if (event.deltaY < 0) {
- // Scroll up - zoom in
- zoomActions.zoomIn();
- } else {
- // Scroll down - zoom out
- zoomActions.zoomOut();
+ accumulator += event.deltaY;
+ const threshold = 10;
+ if (accumulator <= -threshold) {
+ // Accumulated scroll up - zoom in
+ zoomActions.zoomIn();
+ accumulator = 0;
+ } else if (accumulator >= threshold) {
+ // Accumulated scroll down - zoom out
+ zoomActions.zoomOut();
+ accumulator = 0;
}
}
};
@@ -90,7 +96,7 @@ const EmbedPdfViewerContent = ({
viewerElement.removeEventListener('wheel', handleWheel);
};
}
- }, []);
+ }, [zoomActions]);
// Handle keyboard zoom shortcuts
React.useEffect(() => {
diff --git a/frontend/src/components/viewer/SearchAPIBridge.tsx b/frontend/src/components/viewer/SearchAPIBridge.tsx
index c8b669f5d..67bb4c446 100644
--- a/frontend/src/components/viewer/SearchAPIBridge.tsx
+++ b/frontend/src/components/viewer/SearchAPIBridge.tsx
@@ -2,6 +2,14 @@ import { useEffect, useState } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '../../contexts/ViewerContext';
+interface SearchResult {
+ pageIndex: number;
+ rects: Array<{
+ origin: { x: number; y: number };
+ size: { width: number; height: number };
+ }>;
+}
+
/**
* SearchAPIBridge manages search state and provides search functionality.
* Listens for search result changes from EmbedPDF and maintains local state.
@@ -11,7 +19,7 @@ export function SearchAPIBridge() {
const { registerBridge } = useViewer();
const [localState, setLocalState] = useState({
- results: null as any[] | null,
+ results: null as SearchResult[] | null,
activeIndex: 0
});
diff --git a/frontend/src/components/viewer/ThumbnailSidebar.tsx b/frontend/src/components/viewer/ThumbnailSidebar.tsx
index 8a8dcfd08..00bffe8de 100644
--- a/frontend/src/components/viewer/ThumbnailSidebar.tsx
+++ b/frontend/src/components/viewer/ThumbnailSidebar.tsx
@@ -15,12 +15,18 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
const scrollState = getScrollState();
const thumbnailAPI = getThumbnailAPI();
- // Clear thumbnails when sidebar closes
+ // Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
useEffect(() => {
if (!visible) {
+ Object.values(thumbnails).forEach((thumbUrl) => {
+ // Only revoke if it's a blob URL (not 'error')
+ if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
+ URL.revokeObjectURL(thumbUrl);
+ }
+ });
setThumbnails({});
}
- }, [visible]);
+ }, [visible, thumbnails]);
// Generate thumbnails when sidebar becomes visible
useEffect(() => {
@@ -32,7 +38,7 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
if (thumbnails[pageIndex]) continue; // Skip if already generated
try {
- const thumbTask = (thumbnailAPI as any).renderThumb(pageIndex, 1.0);
+ const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
// Convert Task to Promise and handle properly
thumbTask.toPromise().then((thumbBlob: Blob) => {
diff --git a/frontend/src/contexts/ViewerContext.tsx b/frontend/src/contexts/ViewerContext.tsx
index 2224a666d..34a49de42 100644
--- a/frontend/src/contexts/ViewerContext.tsx
+++ b/frontend/src/contexts/ViewerContext.tsx
@@ -1,6 +1,57 @@
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
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;
+}
+
+interface ZoomAPIWrapper {
+ zoomIn: () => void;
+ zoomOut: () => void;
+ toggleMarqueeZoom: () => void;
+ requestZoom: (level: number) => void;
+}
+
+interface PanAPIWrapper {
+ enable: () => void;
+ disable: () => void;
+ toggle: () => void;
+}
+
+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;
+ clear: () => void;
+ next: () => void;
+ previous: () => void;
+}
+
+interface ThumbnailAPIWrapper {
+ renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise };
+}
+
+
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
currentPage: number;
@@ -29,8 +80,16 @@ interface RotationState {
rotation: number;
}
+interface SearchResult {
+ pageIndex: number;
+ rects: Array<{
+ origin: { x: number; y: number };
+ size: { width: number; height: number };
+ }>;
+}
+
interface SearchState {
- results: any[] | null;
+ results: SearchResult[] | null;
activeIndex: number;
}
@@ -42,7 +101,7 @@ interface BridgeRef {
/**
* 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
@@ -53,7 +112,7 @@ interface ViewerContextType {
// UI state managed by this context
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
-
+
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
@@ -62,16 +121,16 @@ interface ViewerContextType {
getSpreadState: () => SpreadState;
getRotationState: () => RotationState;
getSearchState: () => SearchState;
- getThumbnailAPI: () => unknown;
-
+ getThumbnailAPI: () => ThumbnailAPIWrapper | null;
+
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
-
+
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
-
+
// Action handlers - call EmbedPDF APIs directly
scrollActions: {
scrollToPage: (page: number) => void;
@@ -80,39 +139,39 @@ interface ViewerContextType {
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;
+ getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
};
-
+
rotationActions: {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
};
-
+
searchActions: {
search: (query: string) => Promise;
next: () => void;
@@ -120,7 +179,7 @@ interface ViewerContextType {
clear: () => void;
};
- // Bridge registration - internal use by bridges
+ // Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
@@ -133,27 +192,53 @@ interface ViewerProviderProps {
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,
+ 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,
});
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
-
+
// Immediate scroll callback for responsive display updates
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
const registerBridge = (type: string, ref: BridgeRef) => {
- (bridgeRefs.current as any)[type] = ref;
+ // Type-safe assignment - we know the bridges will provide correct types
+ switch (type) {
+ case 'scroll':
+ bridgeRefs.current.scroll = ref as BridgeRef;
+ break;
+ case 'zoom':
+ bridgeRefs.current.zoom = ref as BridgeRef;
+ break;
+ case 'pan':
+ bridgeRefs.current.pan = ref as BridgeRef;
+ break;
+ case 'selection':
+ bridgeRefs.current.selection = ref as BridgeRef;
+ break;
+ case 'search':
+ bridgeRefs.current.search = ref as BridgeRef;
+ break;
+ case 'spread':
+ bridgeRefs.current.spread = ref as BridgeRef;
+ break;
+ case 'rotation':
+ bridgeRefs.current.rotation = ref as BridgeRef;
+ break;
+ case 'thumbnail':
+ bridgeRefs.current.thumbnail = ref as BridgeRef;
+ break;
+ }
};
const toggleThumbnailSidebar = () => {
@@ -196,32 +281,32 @@ export const ViewerProvider: React.FC = ({ children }) => {
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
- const api = bridgeRefs.current.scroll?.api as any;
+ const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
- const api = bridgeRefs.current.scroll?.api as any;
+ const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
- const api = bridgeRefs.current.scroll?.api as any;
+ const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
- const api = bridgeRefs.current.scroll?.api as any;
+ const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const scrollState = getScrollState();
- const api = bridgeRefs.current.scroll?.api as any;
+ const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage && scrollState.totalPages > 0) {
api.scrollToPage({ pageNumber: scrollState.totalPages });
}
@@ -230,7 +315,7 @@ export const ViewerProvider: React.FC = ({ children }) => {
const zoomActions = {
zoomIn: () => {
- const api = bridgeRefs.current.zoom?.api as any;
+ const api = bridgeRefs.current.zoom?.api;
if (api?.zoomIn) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
@@ -242,7 +327,7 @@ export const ViewerProvider: React.FC = ({ children }) => {
}
},
zoomOut: () => {
- const api = bridgeRefs.current.zoom?.api as any;
+ const api = bridgeRefs.current.zoom?.api;
if (api?.zoomOut) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
@@ -254,13 +339,13 @@ export const ViewerProvider: React.FC = ({ children }) => {
}
},
toggleMarqueeZoom: () => {
- const api = bridgeRefs.current.zoom?.api as any;
+ const api = bridgeRefs.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
- const api = bridgeRefs.current.zoom?.api as any;
+ const api = bridgeRefs.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
@@ -269,19 +354,19 @@ export const ViewerProvider: React.FC = ({ children }) => {
const panActions = {
enablePan: () => {
- const api = bridgeRefs.current.pan?.api as any;
+ const api = bridgeRefs.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
- const api = bridgeRefs.current.pan?.api as any;
+ const api = bridgeRefs.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
- const api = bridgeRefs.current.pan?.api as any;
+ const api = bridgeRefs.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
@@ -290,20 +375,20 @@ export const ViewerProvider: React.FC = ({ children }) => {
const selectionActions = {
copyToClipboard: () => {
- const api = bridgeRefs.current.selection?.api as any;
+ const api = bridgeRefs.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
- const api = bridgeRefs.current.selection?.api as any;
+ const api = bridgeRefs.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText();
}
return '';
},
getFormattedSelection: () => {
- const api = bridgeRefs.current.selection?.api as any;
+ const api = bridgeRefs.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
@@ -313,20 +398,20 @@ export const ViewerProvider: React.FC = ({ children }) => {
const spreadActions = {
setSpreadMode: (mode: SpreadMode) => {
- const api = bridgeRefs.current.spread?.api as any;
+ const api = bridgeRefs.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
- const api = bridgeRefs.current.spread?.api as any;
+ const api = bridgeRefs.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
- const api = bridgeRefs.current.spread?.api as any;
+ const api = bridgeRefs.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
@@ -335,25 +420,25 @@ export const ViewerProvider: React.FC = ({ children }) => {
const rotationActions = {
rotateForward: () => {
- const api = bridgeRefs.current.rotation?.api as any;
+ const api = bridgeRefs.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
- const api = bridgeRefs.current.rotation?.api as any;
+ const api = bridgeRefs.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
- const api = bridgeRefs.current.rotation?.api as any;
+ const api = bridgeRefs.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
- const api = bridgeRefs.current.rotation?.api as any;
+ const api = bridgeRefs.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
@@ -363,25 +448,25 @@ export const ViewerProvider: React.FC = ({ children }) => {
const searchActions = {
search: async (query: string) => {
- const api = bridgeRefs.current.search?.api as any;
+ const api = bridgeRefs.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
- const api = bridgeRefs.current.search?.api as any;
+ const api = bridgeRefs.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
- const api = bridgeRefs.current.search?.api as any;
+ const api = bridgeRefs.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
- const api = bridgeRefs.current.search?.api as any;
+ const api = bridgeRefs.current.search?.api;
if (api?.clear) {
api.clear();
}
@@ -412,7 +497,7 @@ export const ViewerProvider: React.FC = ({ children }) => {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
-
+
// State getters
getScrollState,
getZoomState,
@@ -422,13 +507,13 @@ export const ViewerProvider: React.FC = ({ children }) => {
getRotationState,
getSearchState,
getThumbnailAPI,
-
+
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
-
+
// Actions
scrollActions,
zoomActions,
@@ -437,7 +522,7 @@ export const ViewerProvider: React.FC = ({ children }) => {
spreadActions,
rotationActions,
searchActions,
-
+
// Bridge registration
registerBridge,
};
@@ -455,4 +540,4 @@ export const useViewer = (): ViewerContextType => {
throw new Error('useViewer must be used within a ViewerProvider');
}
return context;
-};
\ No newline at end of file
+};
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index df3b98031..a38c357a6 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -152,6 +152,11 @@
--text-brand: var(--color-gray-700);
--text-brand-accent: #DC2626;
+ /* Search highlight colors */
+ --search-highlight-bg: #FFFF00;
+ --search-highlight-active-bg: #FFBF00;
+ --search-highlight-active-border: rgba(255, 191, 0, 0.8);
+
/* Placeholder text colors */
--search-text-and-icon-color: #6B7382;
@@ -337,6 +342,11 @@
--tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */
--tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */
+ /* Search highlight colors (dark mode) */
+ --search-highlight-bg: #FFF700;
+ --search-highlight-active-bg: #FFD700;
+ --search-highlight-active-border: rgba(255, 215, 0, 0.8);
+
/* Placeholder text colors (dark mode) */
--search-text-and-icon-color: #FFFFFF !important;