From 4fd336c26c049472004df1a866151832fac600c8 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:33:49 +0000 Subject: [PATCH] Feature/v2/bookmarks (#4949) bookmark panel on viewer --- frontend/package-lock.json | 17 + frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 1 + .../components/viewer/BookmarkAPIBridge.tsx | 73 +++ .../components/viewer/BookmarkSidebar.css | 196 +++++++ .../components/viewer/BookmarkSidebar.tsx | 551 ++++++++++++++++++ .../core/components/viewer/EmbedPdfViewer.tsx | 63 +- .../core/components/viewer/LocalEmbedPDF.tsx | 6 + .../viewer/useViewerRightRailButtons.tsx | 14 +- frontend/src/core/contexts/ViewerContext.tsx | 30 + .../src/core/contexts/viewer/viewerActions.ts | 25 + .../src/core/contexts/viewer/viewerBridges.ts | 16 + 12 files changed, 990 insertions(+), 3 deletions(-) create mode 100644 frontend/src/core/components/viewer/BookmarkAPIBridge.tsx create mode 100644 frontend/src/core/components/viewer/BookmarkSidebar.css create mode 100644 frontend/src/core/components/viewer/BookmarkSidebar.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86f3f9a36..296fa51a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", @@ -633,6 +634,22 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-bookmark": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz", + "integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.4.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.4.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-export": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbfd9d347..ba96f1d04 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 7ff36357c..dcaaed974 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3825,6 +3825,7 @@ "downloadSelected": "Download Selected Files", "downloadAll": "Download All", "toggleTheme": "Toggle Theme", + "toggleBookmarks": "Toggle Bookmarks", "language": "Language", "search": "Search PDF", "panMode": "Pan Mode", diff --git a/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx new file mode 100644 index 000000000..019b80920 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx @@ -0,0 +1,73 @@ +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react'; +import { BookmarkCapability } from '@embedpdf/plugin-bookmark'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges'; + +export function BookmarkAPIBridge() { + const { provides: bookmarkCapability } = useBookmarkCapability(); + const { registerBridge } = useViewer(); + const [state, setState] = useState({ + bookmarks: null, + isLoading: false, + error: null, + }); + + const fetchBookmarks = useCallback( + async (capability: BookmarkCapability) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + try { + const task = capability.getBookmarks(); + const result = await task.toPromise(); + setState({ + bookmarks: result.bookmarks ?? [], + isLoading: false, + error: null, + }); + return result.bookmarks ?? []; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + setState({ + bookmarks: null, + isLoading: false, + error: message, + }); + throw error; + } + }, + [] + ); + + const api = useMemo(() => { + if (!bookmarkCapability) return null; + + return { + fetchBookmarks: () => fetchBookmarks(bookmarkCapability), + clearBookmarks: () => { + setState({ + bookmarks: null, + isLoading: false, + error: null, + }); + }, + setLocalBookmarks: (bookmarks, error = null) => { + setState({ + bookmarks, + isLoading: false, + error, + }); + }, + }; + }, [bookmarkCapability, fetchBookmarks]); + + useEffect(() => { + if (!api) return; + + registerBridge('bookmark', { + state, + api, + }); + }, [api, state, registerBridge]); + + return null; +} diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.css b/frontend/src/core/components/viewer/BookmarkSidebar.css new file mode 100644 index 000000000..da130a957 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.css @@ -0,0 +1,196 @@ +/* Bookmark Sidebar - Modern styling to match AllTools section */ + +.bookmark-sidebar { + display: flex; + flex-direction: column; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-toolbar) 96%, transparent), + color-mix(in srgb, var(--bg-background) 90%, transparent) + ); + border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent); + box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent); + backdrop-filter: blur(12px); +} + +/* Header Section */ +.bookmark-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.875rem; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-subtle); +} + +.bookmark-sidebar__header-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.bookmark-sidebar__header-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-blue-6); + font-size: 1.1rem; +} + +/* Search Section */ +.bookmark-sidebar__search { + background: var(--tool-panel-search-bg, var(--bg-toolbar)); + border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle)); + padding-top: 0.75rem !important; +} + +/* Content Section */ +.bookmark-sidebar__content { + padding-top: 0.5rem !important; +} + +.bookmark-sidebar__empty-state, +.bookmark-sidebar__error { + padding: 2rem 1rem; + text-align: center; +} + +.bookmark-sidebar__loading { + padding: 2rem 1rem !important; +} + +/* Bookmark List */ +.bookmark-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Bookmark Items */ +.bookmark-item-wrapper { + display: flex; + flex-direction: column; +} + +.bookmark-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-radius: 0.65rem; + cursor: default; + transition: all 0.2s ease; + background: transparent; + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; + position: relative; + text-align: left; + outline: none; +} + +.bookmark-item--clickable { + cursor: pointer; + background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); +} + +.bookmark-item--clickable:hover { + background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); + border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); + transform: translateX(2px); +} + +.bookmark-item--clickable:active { + transform: translateX(3px); +} + +.bookmark-item--clickable:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + outline-offset: 2px; +} + +/* Expand Icon */ +.bookmark-item__expand-icon { + flex-shrink: 0; + transition: transform 0.2s ease; + width: 2rem; + height: 2rem; +} + +.bookmark-item:hover .bookmark-item__expand-icon { + transform: scale(1.05); +} + +/* Dash for items without children */ +.bookmark-item__dash { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + color: var(--text-muted); + font-size: 1.25rem; + opacity: 0.5; + font-weight: 300; +} + +/* Content */ +.bookmark-item__content { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-width: 0; +} + +.bookmark-item__title { + line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--text-primary); +} + +.bookmark-item--clickable:hover .bookmark-item__title { + color: var(--text-primary); +} + +.bookmark-item__page { + line-height: 1.3; + font-size: 0.75rem; +} + +/* Children Container */ +.bookmark-item__children { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.25rem; +} + +/* Smooth expand/collapse animation */ +@keyframes bookmark-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bookmark-item__children { + animation: bookmark-fade-in 0.2s ease; +} + +/* Accessibility & Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .bookmark-item, + .bookmark-item__expand-icon, + .bookmark-item__children { + transition: none; + animation: none; + } +} + diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/src/core/components/viewer/BookmarkSidebar.tsx new file mode 100644 index 000000000..5c845878b --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.tsx @@ -0,0 +1,551 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models'; +import BookmarksIcon from '@mui/icons-material/BookmarksRounded'; +import '@app/components/viewer/BookmarkSidebar.css'; + +interface BookmarkSidebarProps { + visible: boolean; + thumbnailVisible: boolean; + documentCacheKey?: string; + preloadCacheKeys?: string[]; +} + +const SIDEBAR_WIDTH = '15rem'; + +type BookmarkNode = PdfBookmarkObject & { id: string }; + +type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface BookmarkCacheEntry { + status: BookmarkCacheStatus; + bookmarks: PdfBookmarkObject[] | null; + error: string | null; + lastFetched: number | null; +} + +const createEntry = (overrides: Partial = {}): BookmarkCacheEntry => ({ + status: 'idle', + bookmarks: null, + error: null, + lastFetched: null, + ...overrides, +}); + +const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => { + const target = bookmark.target; + if (!target) return null; + + if (target.type === 'destination') { + return target.destination.pageIndex + 1; + } + + if (target.type === 'action') { + const action = target.action; + if ( + action.type === PdfActionType.Goto || + action.type === PdfActionType.RemoteGoto + ) { + return action.destination?.pageIndex !== undefined + ? action.destination.pageIndex + 1 + : null; + } + } + + return null; +}; + +export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => { + const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer(); + const [expanded, setExpanded] = useState>({}); + const [searchTerm, setSearchTerm] = useState(''); + const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport()); + const [activeEntry, setActiveEntry] = useState(() => createEntry()); + const cacheRef = useRef>(new Map()); + const [fetchNonce, setFetchNonce] = useState(0); + const currentKeyRef = useRef(documentCacheKey ?? null); + + useEffect(() => { + currentKeyRef.current = documentCacheKey ?? null; + }, [documentCacheKey]); + + // Poll once until the bookmark bridge registers + useEffect(() => { + if (bookmarkSupport) return; + let cancelled = false; + const id = setInterval(() => { + if (!cancelled && hasBookmarkSupport()) { + setBookmarkSupport(true); + clearInterval(id); + } + }, 250); + + return () => { + cancelled = true; + clearInterval(id); + }; + }, [bookmarkSupport, hasBookmarkSupport]); + + // Reset UI and load cached entry (if any) when switching documents + useEffect(() => { + setExpanded({}); + setSearchTerm(''); + + if (!documentCacheKey) { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + return; + } + + const cached = cacheRef.current.get(documentCacheKey); + if (cached) { + setActiveEntry(cached); + if (cached.status === 'success') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null); + } else if (cached.status === 'error') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error); + } else { + bookmarkActions.clearBookmarks(); + } + } else { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + } + }, [documentCacheKey, bookmarkActions]); + + // Keep cache bounded to the currently relevant keys + useEffect(() => { + const allowed = new Set(); + if (documentCacheKey) { + allowed.add(documentCacheKey); + } + preloadCacheKeys.forEach(key => { + if (key) { + allowed.add(key); + } + }); + + cacheRef.current.forEach((_entry, key) => { + if (!allowed.has(key)) { + cacheRef.current.delete(key); + } + }); + }, [documentCacheKey, preloadCacheKeys]); + + // Fetch bookmarks for the active document when needed + useEffect(() => { + if (!bookmarkSupport || !documentCacheKey) return; + + const key = documentCacheKey; + const cached = cacheRef.current.get(key); + if (cached && (cached.status === 'loading' || cached.status === 'success')) { + return; + } + + let cancelled = false; + const updateEntry = (entry: BookmarkCacheEntry) => { + cacheRef.current.set(key, entry); + if (!cancelled && currentKeyRef.current === key) { + setActiveEntry(entry); + } + }; + + updateEntry(createEntry({ + status: 'loading', + bookmarks: cached?.bookmarks ?? null, + lastFetched: cached?.lastFetched ?? null, + })); + + const fetchWithRetry = async () => { + const maxAttempts = 10; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const result = await bookmarkActions.fetchBookmarks(); + return Array.isArray(result) ? result : []; + } catch (error: any) { + const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; + const notReady = + message.includes('document') && + message.includes('not') && + message.includes('open'); + + if (!notReady || attempt === maxAttempts - 1) { + throw error; + } + + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + return []; + }; + + fetchWithRetry() + .then(bookmarks => { + if (cancelled) return; + const entry = createEntry({ + status: 'success', + bookmarks, + lastFetched: Date.now(), + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(bookmarks, null); + } + }) + .catch(error => { + if (cancelled) return; + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + const fallback = cacheRef.current.get(key); + const entry = createEntry({ + status: 'error', + bookmarks: fallback?.bookmarks ?? null, + error: message, + lastFetched: fallback?.lastFetched ?? null, + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(null, message); + } + }); + + return () => { + cancelled = true; + }; + }, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]); + + const requestReload = useCallback(() => { + if (!documentCacheKey) return; + cacheRef.current.delete(documentCacheKey); + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + setFetchNonce(value => value + 1); + }, [documentCacheKey, bookmarkActions]); + + const bookmarksWithIds = useMemo(() => { + const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => { + if (!Array.isArray(nodes)) { + return []; + } + + return nodes.map((node, index) => { + const id = `${prefix}-${index}`; + return { + ...node, + id, + children: node.children ? assignIds(node.children, id) : undefined, + }; + }); + }; + + const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : []; + return assignIds(bookmarks); + }, [activeEntry.bookmarks]); + + const currentStatus = activeEntry.status; + const isLocalLoading = bookmarkSupport && currentStatus === 'loading'; + const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null; + + const toggleNode = (nodeId: string) => { + setExpanded(prev => ({ + ...prev, + [nodeId]: !(prev[nodeId] ?? true), + })); + }; + + const expandAll = useCallback(() => { + const allExpanded: Record = {}; + const expandRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allExpanded[node.id] = true; + expandRecursive(node.children as BookmarkNode[]); + } + }); + }; + expandRecursive(bookmarksWithIds); + setExpanded(allExpanded); + }, [bookmarksWithIds]); + + const collapseAll = useCallback(() => { + const allCollapsed: Record = {}; + const collapseRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allCollapsed[node.id] = false; + collapseRecursive(node.children as BookmarkNode[]); + } + }); + }; + collapseRecursive(bookmarksWithIds); + setExpanded(allCollapsed); + }, [bookmarksWithIds]); + + const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => { + const target = bookmark.target; + if (target?.type === 'action') { + const action = target.action; + if (action.type === PdfActionType.URI && action.uri) { + event.preventDefault(); + window.open(action.uri, '_blank', 'noopener'); + return; + } + if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) { + event.preventDefault(); + window.open(action.path, '_blank', 'noopener'); + return; + } + } + + const pageNumber = resolvePageNumber(bookmark); + if (pageNumber) { + scrollActions.scrollToPage(pageNumber); + } + }; + + const filteredBookmarks = useMemo(() => { + if (!searchTerm.trim()) return bookmarksWithIds; + const term = searchTerm.trim().toLowerCase(); + + const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => { + const results: BookmarkNode[] = []; + + for (const node of nodeList) { + const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : []; + const matchesSelf = node.title?.toLowerCase().includes(term) ?? false; + + if (matchesSelf || childMatches.length > 0) { + results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children }); + } + } + + return results; + }; + + return applyFilter(bookmarksWithIds); + }, [bookmarksWithIds, searchTerm]); + + const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => { + if (!nodes || !Array.isArray(nodes)) { + return null; + } + + return nodes.map((node, _index) => { + if (!node || !node.id) { + return null; + } + + const hasChildren = Array.isArray(node.children) && node.children.length > 0; + const isNodeExpanded = expanded[node.id] ?? true; + + const pageNumber = resolvePageNumber(node); + + return ( +
0 ? `${depth * 0.75}rem` : '0', + }} + > +
handleBookmarkClick(node, event)} + role={pageNumber ? "button" : undefined} + tabIndex={pageNumber ? 0 : undefined} + onKeyDown={pageNumber ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleBookmarkClick(node, event as any); + } + } : undefined} + > + {hasChildren ? ( + { + event.stopPropagation(); + toggleNode(node.id); + }} + > + + + ) : ( + - + )} +
+ + {node.title || 'Untitled'} + + {pageNumber && ( + + Page {pageNumber} + + )} +
+
+ {hasChildren && isNodeExpanded && ( +
+ {renderBookmarks(node.children as BookmarkNode[], depth + 1)} +
+ )} +
+ ); + }); + }; + + const isSearchActive = searchTerm.trim().length > 0; + const hasBookmarks = bookmarksWithIds.length > 0; + const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0; + const showEmptyState = + bookmarkSupport && + documentCacheKey && + !isLocalLoading && + !currentError && + currentStatus === 'success' && + !hasBookmarks; + const showSearchEmpty = + bookmarkSupport && + documentCacheKey && + isSearchActive && + hasBookmarks && + filteredBookmarks.length === 0; + const showNoDocument = bookmarkSupport && !documentCacheKey; + + if (!visible) { + return null; + } + + return ( + +
+
+ + + + + Bookmarks + +
+ {bookmarkSupport && bookmarksWithIds.length > 0 && ( + <> + {Object.values(expanded).some(val => val === false) ? ( + + + + ) : ( + + + + )} + + )} +
+ + + setSearchTerm(event.currentTarget.value)} + leftSection={} + size="xs" + /> + + + + + {!bookmarkSupport && ( +
+ + Bookmark support is unavailable for this viewer. + +
+ )} + + {bookmarkSupport && showNoDocument && ( +
+ + Open a PDF to view its bookmarks. + +
+ )} + + {bookmarkSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + )} + + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + + {showEmptyState && ( +
+ + No bookmarks in this document + +
+ )} + + {showBookmarkList && ( +
+ {renderBookmarks(filteredBookmarks)} +
+ )} + + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index bf9af4eca..10386b2e8 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF'; import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar'; import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar'; +import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; @@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({ const pdfContainerRef = useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { + isThumbnailSidebarVisible, + toggleThumbnailSidebar, + isBookmarkSidebarVisible, + zoomActions, + panActions: _panActions, + rotationActions: _rotationActions, + getScrollState, + getRotationState, + isAnnotationMode, + isAnnotationsVisible, + exportActions, + } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); @@ -128,6 +141,42 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); + const bookmarkCacheKey = React.useMemo(() => { + if (currentFile && isStirlingFile(currentFile)) { + return currentFile.fileId; + } + + if (previewFile) { + const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`; + return `preview-${uniquePreviewId}`; + } + + if (effectiveFile?.url) { + return effectiveFile.url; + } + + if (effectiveFile?.file instanceof File) { + const fileObj = effectiveFile.file; + return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`; + } + + return undefined; + }, [currentFile, effectiveFile, previewFile]); + + // Generate cache keys for all active files to enable preloading + const allBookmarkCacheKeys = React.useMemo(() => { + if (previewFile) { + return [bookmarkCacheKey].filter(Boolean) as string[]; + } + + return activeFiles.map(file => { + if (isStirlingFile(file)) { + return file.fileId; + } + return undefined; + }).filter(Boolean) as string[]; + }, [activeFiles, previewFile, bookmarkCacheKey]); + useWheelZoom({ ref: viewerRef, onZoomIn: zoomActions.zoomIn, @@ -219,6 +268,10 @@ const EmbedPdfViewerContent = ({ } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + const sidebarWidthRem = 15; + const totalRightMargin = + (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); + return ( + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index f20b68b9a..c08eabcbb 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark'; // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; @@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; +import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register thumbnail plugin for page thumbnails createPluginRegistration(ThumbnailPluginPackage), + // Register bookmark plugin for PDF outline support + createPluginRegistration(BookmarkPluginPackage), + // Register rotate plugin createPluginRegistration(RotatePluginPackage), @@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {enableAnnotations && } {enableAnnotations && } + (() => { return [ @@ -111,6 +112,17 @@ export function useViewerRightRailButtons() { viewer.toggleThumbnailSidebar(); } }, + { + id: 'viewer-toggle-bookmarks', + icon: , + tooltip: bookmarkLabel, + ariaLabel: bookmarkLabel, + section: 'top' as const, + order: 55, + onClick: () => { + viewer.toggleBookmarkSidebar(); + } + }, { id: 'viewer-annotation-controls', section: 'top' as const, @@ -120,7 +132,7 @@ export function useViewerRightRailButtons() { ) } ]; - }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]); + }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]); useRightRailButtons(viewerButtons); } diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index fc0f1ab70..fc1eaf346 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -17,6 +17,7 @@ import { RotationActions, SearchActions, ExportActions, + BookmarkActions, } from '@app/contexts/viewer/viewerActions'; import { BridgeRef, @@ -35,6 +36,7 @@ import { SearchState, ExportState, ThumbnailAPIWrapper, + BookmarkState, } from '@app/contexts/viewer/viewerBridges'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; @@ -74,6 +76,8 @@ interface ViewerContextType { // UI state managed by this context isThumbnailSidebarVisible: boolean; toggleThumbnailSidebar: () => void; + isBookmarkSidebarVisible: boolean; + toggleBookmarkSidebar: () => void; // Annotation visibility toggle isAnnotationsVisible: boolean; @@ -98,6 +102,8 @@ interface ViewerContextType { getSearchState: () => SearchState; getThumbnailAPI: () => ThumbnailAPIWrapper | null; getExportState: () => ExportState; + getBookmarkState: () => BookmarkState; + hasBookmarkSupport: () => boolean; // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void; @@ -118,6 +124,7 @@ interface ViewerContextType { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; // Bridge registration - internal use by bridges registerBridge: ( @@ -135,6 +142,7 @@ interface ViewerProviderProps { export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); + const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -193,6 +201,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsThumbnailSidebarVisible(prev => !prev); }; + const toggleBookmarkSidebar = () => { + setIsBookmarkSidebarVisible(prev => !prev); + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -242,6 +254,18 @@ export const ViewerProvider: React.FC = ({ children }) => { 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); + // Action handlers - call APIs directly const { scrollActions, @@ -252,6 +276,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, } = createViewerActions({ registry: bridgeRefs, getScrollState, @@ -263,6 +288,8 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state isThumbnailSidebarVisible, toggleThumbnailSidebar, + isBookmarkSidebarVisible, + toggleBookmarkSidebar, // Annotation controls isAnnotationsVisible, @@ -285,6 +312,8 @@ export const ViewerProvider: React.FC = ({ children }) => { getSearchState, getThumbnailAPI, getExportState, + getBookmarkState, + hasBookmarkSupport, // Immediate updates registerImmediateZoomUpdate, @@ -303,6 +332,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, // Bridge registration registerBridge, diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index daa9cb8c9..05a38440a 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -5,6 +5,7 @@ import { ScrollState, ZoomState, } from '@app/contexts/viewer/viewerBridges'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollActions { scrollToPage: (page: number) => void; @@ -58,6 +59,12 @@ export interface ExportActions { saveAsCopy: () => Promise; } +export interface BookmarkActions { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ViewerActionsBundle { scrollActions: ScrollActions; zoomActions: ZoomActions; @@ -67,6 +74,7 @@ export interface ViewerActionsBundle { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; } interface ViewerActionDependencies { @@ -307,5 +315,22 @@ export function createViewerActions({ rotationActions, searchActions, exportActions, + bookmarkActions: { + fetchBookmarks: async () => { + const api = registry.current.bookmark?.api; + if (!api?.fetchBookmarks) { + return null; + } + return api.fetchBookmarks(); + }, + clearBookmarks: () => { + const api = registry.current.bookmark?.api; + api?.clearBookmarks?.(); + }, + setLocalBookmarks: (bookmarks, error = null) => { + const api = registry.current.bookmark?.api; + api?.setLocalBookmarks?.(bookmarks ?? null, error); + }, + }, }; } diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index 032a05e18..e7f1ebf3c 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -1,4 +1,5 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollAPIWrapper { scrollToPage: (params: { pageNumber: number }) => void; @@ -59,6 +60,12 @@ export interface ExportAPIWrapper { saveAsCopy: () => { toPromise: () => Promise }; } +export interface BookmarkAPIWrapper { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ScrollState { currentPage: number; totalPages: number; @@ -103,6 +110,12 @@ export interface ExportState { canExport: boolean; } +export interface BookmarkState { + bookmarks: PdfBookmarkObject[] | null; + isLoading: boolean; + error: string | null; +} + export interface BridgeRef { state: TState; api: TApi; @@ -118,6 +131,7 @@ export interface BridgeStateMap { search: SearchState; thumbnail: unknown; export: ExportState; + bookmark: BookmarkState; } export interface BridgeApiMap { @@ -130,6 +144,7 @@ export interface BridgeApiMap { search: SearchAPIWrapper; thumbnail: ThumbnailAPIWrapper; export: ExportAPIWrapper; + bookmark: BookmarkAPIWrapper; } export type BridgeKey = keyof BridgeStateMap; @@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ search: null, thumbnail: null, export: null, + bookmark: null, }); export function registerBridge(