From 423617db52203f4de20c20188aae317b83fa851f Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Fri, 12 Sep 2025 14:21:31 +0100 Subject: [PATCH] thumbnail sidebar --- frontend/package-lock.json | 16 ++ frontend/package.json | 1 + frontend/src/components/layout/Workbench.tsx | 2 +- frontend/src/components/shared/RightRail.tsx | 2 +- .../components/viewer/CustomSearchLayer.tsx | 31 +-- .../src/components/viewer/EmbedPdfViewer.tsx | 85 ++++--- .../src/components/viewer/LocalEmbedPDF.tsx | 6 + .../viewer/SearchControlsExporter.tsx | 50 +--- .../src/components/viewer/SearchInterface.tsx | 23 +- .../viewer/ThumbnailControlsExporter.tsx | 26 ++ .../components/viewer/ThumbnailSidebar.tsx | 226 ++++++++++++++++++ 11 files changed, 335 insertions(+), 133 deletions(-) create mode 100644 frontend/src/components/viewer/ThumbnailControlsExporter.tsx create mode 100644 frontend/src/components/viewer/ThumbnailSidebar.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46958caae..6ff7d4358 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@embedpdf/plugin-search": "^1.1.1", "@embedpdf/plugin-selection": "^1.1.1", "@embedpdf/plugin-spread": "^1.1.1", + "@embedpdf/plugin-thumbnail": "^1.1.1", "@embedpdf/plugin-tiling": "^1.1.1", "@embedpdf/plugin-viewport": "^1.1.1", "@embedpdf/plugin-zoom": "^1.1.1", @@ -779,6 +780,21 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-thumbnail": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.1.1.tgz", + "integrity": "sha512-xH5p2XgxkDgAbZKJSGAYcDNPlnKEsBHm0EH+YCxOI0T5zX/a9j4uFj7mbbs9IcQ9vEFuIi6lGvY3DUWN4TeDpQ==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-render": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@embedpdf/plugin-tiling": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 88fe1f36d..b33135246 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@embedpdf/plugin-search": "^1.1.1", "@embedpdf/plugin-selection": "^1.1.1", "@embedpdf/plugin-spread": "^1.1.1", + "@embedpdf/plugin-thumbnail": "^1.1.1", "@embedpdf/plugin-tiling": "^1.1.1", "@embedpdf/plugin-viewport": "^1.1.1", "@embedpdf/plugin-zoom": "^1.1.1", diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index d3fad72c4..ea6b94b28 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -157,7 +157,7 @@ export default function Workbench() { className="flex-1 min-h-0 relative z-10 workbench-scrollable " style={{ transition: 'opacity 0.15s ease-in-out', - marginTop: '1rem', + marginTop: 0, }} > {renderMainContent()} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 3f9272028..79349bdb8 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -260,7 +260,7 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={() => (window as any).embedPdfControls?.sidebar()} + onClick={() => (window as any).toggleThumbnailSidebar?.()} disabled={currentView !== 'viewer'} > diff --git a/frontend/src/components/viewer/CustomSearchLayer.tsx b/frontend/src/components/viewer/CustomSearchLayer.tsx index 46f81c835..8fe6aef17 100644 --- a/frontend/src/components/viewer/CustomSearchLayer.tsx +++ b/frontend/src/components/viewer/CustomSearchLayer.tsx @@ -37,43 +37,25 @@ export function CustomSearchLayer({ // Subscribe to search result state changes useEffect(() => { if (!searchProvides) { - console.log('🔍 CustomSearchLayer: No search provides available for page', pageIndex); return; } - - console.log('🔍 CustomSearchLayer: Setting up search result subscription for page', pageIndex); - console.log('🔍 CustomSearchLayer: Available search methods:', Object.keys(searchProvides)); const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { - console.log('🔍 CustomSearchLayer: Search result state changed for page', pageIndex, ':', { - state, - resultsCount: state?.results?.length || 0, - activeIndex: state?.activeResultIndex, - results: state?.results - }); - // Expose search results globally for SearchInterface if (state?.results) { (window as any).currentSearchResults = state.results; (window as any).currentActiveIndex = (state.activeResultIndex || 0) + 1; // Convert to 1-based index - console.log('🔍 CustomSearchLayer: Exposed global search data:', { - totalResults: state.results.length, - activeIndex: (state.activeResultIndex || 0) + 1 - }); // Auto-scroll to active result if we have one if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { const activeResult = state.results[state.activeResultIndex]; - if (activeResult) { - console.log('🔍 CustomSearchLayer: Auto-scrolling to active result on page', activeResult.pageIndex); - + if (activeResult) { // Use the scroll API to navigate to the page containing the active result const scrollAPI = (window as any).embedPdfScroll; if (scrollAPI && scrollAPI.scrollToPage) { // Convert 0-based page index to 1-based page number const pageNumber = activeResult.pageIndex + 1; scrollAPI.scrollToPage(pageNumber); - console.log('🔍 CustomSearchLayer: Scrolled to page', pageNumber); } } } @@ -85,9 +67,6 @@ export function CustomSearchLayer({ setSearchResultState(state); }); - if (!unsubscribe) { - console.warn('🔍 CustomSearchLayer: No onSearchResultStateChange method available'); - } return unsubscribe; }, [searchProvides, pageIndex]); @@ -95,7 +74,6 @@ export function CustomSearchLayer({ // Filter results for current page while preserving original indices const pageResults = useMemo(() => { if (!searchResultState?.results) { - console.log(`🔍 CustomSearchLayer: No search results for page ${pageIndex} (no results array)`); return []; } @@ -103,13 +81,6 @@ export function CustomSearchLayer({ .map((result, originalIndex) => ({ result, originalIndex })) .filter(({ result }) => result.pageIndex === pageIndex); - console.log(`🔍 CustomSearchLayer: Page ${pageIndex} filtering:`, { - totalResults: searchResultState.results.length, - pageResults: filtered.length, - allPageIndices: searchResultState.results.map(r => r.pageIndex), - currentPage: pageIndex, - filtered - }); return filtered; }, [searchResultState, pageIndex]); diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index defb4685a..c56328c16 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -9,6 +9,7 @@ import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { LocalEmbedPDF } from './LocalEmbedPDF'; import { PdfViewerToolbar } from './PdfViewerToolbar'; import { SearchInterface } from './SearchInterface'; +import { ThumbnailSidebar } from './ThumbnailSidebar'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -29,6 +30,7 @@ const EmbedPdfViewer = ({ const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); const [isSearchVisible, setIsSearchVisible] = React.useState(false); + const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = React.useState(false); // Get current file from FileContext const { selectors } = useFileState(); @@ -118,14 +120,19 @@ const EmbedPdfViewer = ({ }; }, [isViewerHovered]); - // Expose search toggle function globally for right rail button + // Expose toggle functions globally for right rail buttons React.useEffect(() => { (window as any).togglePdfSearch = () => { setIsSearchVisible(prev => !prev); }; + (window as any).toggleThumbnailSidebar = () => { + setIsThumbnailSidebarVisible(prev => !prev); + }; + return () => { delete (window as any).togglePdfSearch; + delete (window as any).toggleThumbnailSidebar; }; }, []); @@ -170,60 +177,68 @@ const EmbedPdfViewer = ({ )} - {/* EmbedPDF Viewer with Toolbar Overlay */} + {/* EmbedPDF Viewer */} - - {/* Bottom Toolbar Overlay */} -
-
- { - // Placeholder - will implement page navigation later - console.log('Navigate to page:', page); - }} - dualPage={false} - onDualPageToggle={() => { - (window as any).embedPdfSpread?.toggleSpreadMode(); - }} - currentZoom={100} - /> -
-
)} + {/* Bottom Toolbar Overlay */} + {effectiveFile && ( +
+
+ { + // Placeholder - will implement page navigation later + console.log('Navigate to page:', page); + }} + dualPage={false} + onDualPageToggle={() => { + (window as any).embedPdfSpread?.toggleSpreadMode(); + }} + currentZoom={100} + /> +
+
+ )} + {/* Search Interface Overlay */} setIsSearchVisible(false)} /> + + {/* Thumbnail Sidebar */} + setIsThumbnailSidebarVisible(prev => !prev)} + colorScheme={colorScheme} + /> ); }; diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx index 481e5c0a1..342153194 100644 --- a/frontend/src/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -15,6 +15,7 @@ import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react' import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react'; import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { CustomSearchLayer } from './CustomSearchLayer'; import { ZoomControlsExporter } from './ZoomControlsExporter'; import { ScrollControlsExporter } from './ScrollControlsExporter'; @@ -22,6 +23,7 @@ import { SelectionControlsExporter } from './SelectionControlsExporter'; import { PanControlsExporter } from './PanControlsExporter'; import { SpreadControlsExporter } from './SpreadControlsExporter'; import { SearchControlsExporter } from './SearchControlsExporter'; +import { ThumbnailControlsExporter } from './ThumbnailControlsExporter'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -101,6 +103,9 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { // Register search plugin for text search createPluginRegistration(SearchPluginPackage), + + // Register thumbnail plugin for page thumbnails + createPluginRegistration(ThumbnailPluginPackage), ]; }, [pdfUrl]); @@ -181,6 +186,7 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { + { if (search && state) { - // Debug: log the actual search hook structure - console.log('🔍 EmbedPDF search hook structure:', { - search, - state, - searchKeys: Object.keys(search), - stateKeys: Object.keys(state), - currentQuery: state.query, - isActive: state.active, - isLoading: state.loading - }); // Export search controls to global window for toolbar access (window as any).embedPdfSearch = { search: async (query: string) => { - console.log('🔍 EmbedPDF: Starting search for:', query); - console.log('🔍 Search API available:', !!search); - console.log('🔍 Search API methods:', search ? Object.keys(search) : 'none'); - try { - // First start the search session search.startSearch(); - console.log('🔍 startSearch() called'); - - // Then search all pages with the query const results = await search.searchAllPages(query); - console.log('🔍 searchAllPages() results:', results); - return results; } catch (error) { - console.error('🔍 Search error:', error); + console.error('Search error:', error); throw error; } }, clearSearch: () => { - console.log('🔍 EmbedPDF: Stopping search'); search.stopSearch(); }, nextResult: () => { - console.log('🔍 EmbedPDF: Going to next search result'); - const newIndex = search.nextResult(); - console.log('🔍 EmbedPDF: New active result index:', newIndex); - // The goToResult method should handle scrolling automatically - return newIndex; + return search.nextResult(); }, previousResult: () => { - console.log('🔍 EmbedPDF: Going to previous search result'); - const newIndex = search.previousResult(); - console.log('🔍 EmbedPDF: New active result index:', newIndex); - // The goToResult method should handle scrolling automatically - return newIndex; + return search.previousResult(); }, goToResult: (index: number) => { - console.log('🔍 EmbedPDF: Going to search result:', index); - const resultIndex = search.goToResult(index); - console.log('🔍 EmbedPDF: Navigated to result index:', resultIndex); - return resultIndex; + return search.goToResult(index); }, // State getters getSearchQuery: () => state.query, @@ -77,16 +45,6 @@ export function SearchControlsExporter() { availableMethods: search ? Object.keys(search) : [], }; - console.log('🔍 EmbedPDF search controls exported to window.embedPdfSearch', { - currentQuery: state.query, - isActive: state.active, - isLoading: state.loading, - searchAPI: search, - availableMethods: search ? Object.keys(search) : 'no search API', - state: state - }); - } else { - console.warn('EmbedPDF search hook not available yet'); } }, [search, state]); diff --git a/frontend/src/components/viewer/SearchInterface.tsx b/frontend/src/components/viewer/SearchInterface.tsx index 63fa93aca..a5e4aa9ef 100644 --- a/frontend/src/components/viewer/SearchInterface.tsx +++ b/frontend/src/components/viewer/SearchInterface.tsx @@ -27,25 +27,11 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { if (searchAPI) { const state = searchAPI.state; - console.log('🔍 SearchInterface: Checking search state:', { - state, - hasState: !!state, - query: state?.query, - active: state?.active, - loading: state?.loading - }); - if (state && state.query && state.active) { // Try to get result info from the global search data // The CustomSearchLayer stores results, let's try to access them const searchResults = (window as any).currentSearchResults; const activeIndex = (window as any).currentActiveIndex || 1; - - console.log('🔍 SearchInterface: Search results data:', { - searchResults, - activeIndex, - totalResults: searchResults ? searchResults.length : 0 - }); setResultInfo({ currentIndex: activeIndex, @@ -74,8 +60,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { if (searchAPI) { setIsSearching(true); try { - const results = await searchAPI.search(query.trim()); - console.log('Search completed:', results); + await searchAPI.search(query.trim()); } catch (error) { console.error('Search failed:', error); } finally { @@ -95,16 +80,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { const handleNext = () => { const searchAPI = (window as any).embedPdfSearch; if (searchAPI) { - const newIndex = searchAPI.nextResult(); - console.log('Next result:', newIndex); + searchAPI.nextResult(); } }; const handlePrevious = () => { const searchAPI = (window as any).embedPdfSearch; if (searchAPI) { - const newIndex = searchAPI.previousResult(); - console.log('Previous result:', newIndex); + searchAPI.previousResult(); } }; diff --git a/frontend/src/components/viewer/ThumbnailControlsExporter.tsx b/frontend/src/components/viewer/ThumbnailControlsExporter.tsx new file mode 100644 index 000000000..282a43b48 --- /dev/null +++ b/frontend/src/components/viewer/ThumbnailControlsExporter.tsx @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; +import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react'; + +/** + * Component that runs inside EmbedPDF context and exports thumbnail controls globally + */ +export function ThumbnailControlsExporter() { + const { provides: thumbnail } = useThumbnailCapability(); + + useEffect(() => { + console.log('📄 ThumbnailControlsExporter useEffect:', { thumbnail: !!thumbnail }); + if (thumbnail) { + console.log('📄 Exporting thumbnail controls to window:', { + availableMethods: Object.keys(thumbnail), + renderThumb: typeof thumbnail.renderThumb + }); + // Export thumbnail controls to global window for debugging + (window as any).embedPdfThumbnail = { + thumbnailAPI: thumbnail, + availableMethods: Object.keys(thumbnail), + }; + } + }, [thumbnail]); + + return null; // This component doesn't render anything +} \ No newline at end of file diff --git a/frontend/src/components/viewer/ThumbnailSidebar.tsx b/frontend/src/components/viewer/ThumbnailSidebar.tsx new file mode 100644 index 000000000..adb2e417e --- /dev/null +++ b/frontend/src/components/viewer/ThumbnailSidebar.tsx @@ -0,0 +1,226 @@ +import React, { useState, useEffect } from 'react'; +import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core'; +import { LocalIcon } from '../shared/LocalIcon'; + +interface ThumbnailSidebarProps { + visible: boolean; + onToggle: () => void; + colorScheme: 'light' | 'dark' | 'auto'; +} + +export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSidebarProps) { + const [selectedPage, setSelectedPage] = useState(1); + const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({}); + const [totalPages, setTotalPages] = useState(0); + + // Convert color scheme + const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme; + + // Get total pages from scroll API + useEffect(() => { + const scrollAPI = (window as any).embedPdfScroll; + if (scrollAPI && scrollAPI.totalPages) { + setTotalPages(scrollAPI.totalPages); + } + }, [visible]); + + // Generate thumbnails when sidebar becomes visible + useEffect(() => { + if (!visible || totalPages === 0) return; + + const thumbnailAPI = (window as any).embedPdfThumbnail?.thumbnailAPI; + console.log('📄 ThumbnailSidebar useEffect triggered:', { + visible, + thumbnailAPI: !!thumbnailAPI, + totalPages, + existingThumbnails: Object.keys(thumbnails).length + }); + + if (!thumbnailAPI) return; + + const generateThumbnails = async () => { + console.log('📄 Starting thumbnail generation for', totalPages, 'pages'); + + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + if (thumbnails[pageIndex]) continue; // Skip if already generated + + try { + console.log('📄 Attempting to generate thumbnail for page', pageIndex + 1); + const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0); + console.log('📄 Received thumbTask:', thumbTask); + + // Convert Task to Promise and handle properly + thumbTask.toPromise().then((thumbBlob: Blob) => { + console.log('📄 Thumbnail generated successfully for page', pageIndex + 1, 'blob:', thumbBlob); + const thumbUrl = URL.createObjectURL(thumbBlob); + console.log('📄 Created blob URL:', thumbUrl); + + setThumbnails(prev => ({ + ...prev, + [pageIndex]: thumbUrl + })); + }).catch((error: any) => { + console.error('📄 Failed to generate thumbnail for page', pageIndex + 1, error); + setThumbnails(prev => ({ + ...prev, + [pageIndex]: 'error' + })); + }); + + } catch (error) { + console.error('Failed to generate thumbnail for page', pageIndex + 1, error); + // Set a placeholder or error state + setThumbnails(prev => ({ + ...prev, + [pageIndex]: 'error' + })); + } + } + }; + + generateThumbnails(); + + // Cleanup blob URLs when component unmounts + return () => { + Object.values(thumbnails).forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + }; + }, [visible, totalPages, thumbnails]); + + const handlePageClick = (pageIndex: number) => { + const pageNumber = pageIndex + 1; // Convert to 1-based + setSelectedPage(pageNumber); + + // Use scroll API to navigate to page + const scrollAPI = (window as any).embedPdfScroll; + if (scrollAPI && scrollAPI.scrollToPage) { + scrollAPI.scrollToPage(pageNumber); + } + }; + + return ( + <> + {/* Thumbnail Sidebar */} + {visible && ( + + {/* Thumbnails Container */} + + +
+ {Array.from({ length: totalPages }, (_, pageIndex) => ( + handlePageClick(pageIndex)} + style={{ + cursor: 'pointer', + borderRadius: '8px', + padding: '8px', + backgroundColor: selectedPage === pageIndex + 1 + ? (actualColorScheme === 'dark' ? '#364FC7' : '#e7f5ff') + : 'transparent', + border: selectedPage === pageIndex + 1 + ? '2px solid #1c7ed6' + : '2px solid transparent', + transition: 'all 0.2s ease', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px' + }} + onMouseEnter={(e) => { + if (selectedPage !== pageIndex + 1) { + e.currentTarget.style.backgroundColor = actualColorScheme === 'dark' ? '#25262b' : '#f1f3f5'; + } + }} + onMouseLeave={(e) => { + if (selectedPage !== pageIndex + 1) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > + {/* Thumbnail Image */} + {thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? ( + {`Page + ) : thumbnails[pageIndex] === 'error' ? ( +
+ Failed +
+ ) : ( +
+ Loading... +
+ )} + + {/* Page Number */} +
+ Page {pageIndex + 1} +
+
+ ))} +
+
+
+
+ )} + + ); +} \ No newline at end of file