From b574cef54a566b1029a17925ba584daf434310df Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Fri, 19 Sep 2025 10:48:29 +0100 Subject: [PATCH] improvements --- frontend/src/components/shared/RightRail.tsx | 17 +- .../components/viewer/CustomSearchLayer.tsx | 6 +- .../src/components/viewer/EmbedPdfViewer.tsx | 24 ++- .../src/components/viewer/SearchAPIBridge.tsx | 10 +- .../components/viewer/ThumbnailSidebar.tsx | 12 +- frontend/src/contexts/ViewerContext.tsx | 199 +++++++++++++----- frontend/src/styles/theme.css | 10 + 7 files changed, 192 insertions(+), 86 deletions(-) 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;