Feature/v2/bookmarks (#4949)

bookmark panel on viewer
This commit is contained in:
Reece Browne 2025-11-21 11:33:49 +00:00 committed by GitHub
parent fca8470637
commit 4fd336c26c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 990 additions and 3 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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<BookmarkState>({
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<BookmarkAPIWrapper | null>(() => {
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;
}

View File

@ -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;
}
}

View File

@ -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> = {}): 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<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState('');
const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport());
const [activeEntry, setActiveEntry] = useState<BookmarkCacheEntry>(() => createEntry());
const cacheRef = useRef<Map<string, BookmarkCacheEntry>>(new Map());
const [fetchNonce, setFetchNonce] = useState(0);
const currentKeyRef = useRef<string | null>(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<string>();
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<string, boolean> = {};
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<string, boolean> = {};
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 (
<div
key={node.id}
className="bookmark-item-wrapper"
style={{
marginLeft: depth > 0 ? `${depth * 0.75}rem` : '0',
}}
>
<div
className={`bookmark-item ${pageNumber ? 'bookmark-item--clickable' : ''}`}
onClick={(event) => 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 ? (
<ActionIcon
variant="subtle"
size="sm"
className="bookmark-item__expand-icon"
onClick={(event) => {
event.stopPropagation();
toggleNode(node.id);
}}
>
<LocalIcon
icon={isNodeExpanded ? 'keyboard-arrow-up' : 'keyboard-arrow-down'}
width="1rem"
height="1rem"
/>
</ActionIcon>
) : (
<span className="bookmark-item__dash">-</span>
)}
<div className="bookmark-item__content">
<Text
size="sm"
fw={500}
className="bookmark-item__title"
>
{node.title || 'Untitled'}
</Text>
{pageNumber && (
<Text size="xs" c="dimmed" className="bookmark-item__page">
Page {pageNumber}
</Text>
)}
</div>
</div>
{hasChildren && isNodeExpanded && (
<div className="bookmark-item__children">
{renderBookmarks(node.children as BookmarkNode[], depth + 1)}
</div>
)}
</div>
);
});
};
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 (
<Box
className="bookmark-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
zIndex: 998,
}}
>
<div className="bookmark-sidebar__header">
<div className="bookmark-sidebar__header-title">
<span className="bookmark-sidebar__header-icon">
<BookmarksIcon />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
Bookmarks
</Text>
</div>
{bookmarkSupport && bookmarksWithIds.length > 0 && (
<>
{Object.values(expanded).some(val => val === false) ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={expandAll}
aria-label="Expand all bookmarks"
title="Expand all"
>
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
</ActionIcon>
) : (
<ActionIcon
variant="subtle"
size="sm"
onClick={collapseAll}
aria-label="Collapse all bookmarks"
title="Collapse all"
>
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</>
)}
</div>
<Box px="sm" pb="sm" className="bookmark-sidebar__search">
<TextInput
value={searchTerm}
placeholder="Search bookmarks"
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={<LocalIcon icon="search" width="1.1rem" height="1.1rem" />}
size="xs"
/>
</Box>
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="bookmark-sidebar__content">
{!bookmarkSupport && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Bookmark support is unavailable for this viewer.
</Text>
</div>
)}
{bookmarkSupport && showNoDocument && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Open a PDF to view its bookmarks.
</Text>
</div>
)}
{bookmarkSupport && documentCacheKey && currentError && (
<Stack gap="xs" align="center" className="bookmark-sidebar__error">
<Text size="sm" c="red" ta="center">
{currentError}
</Text>
<Button size="xs" variant="light" onClick={requestReload}>
Retry
</Button>
</Stack>
)}
{bookmarkSupport && documentCacheKey && isLocalLoading && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="bookmark-sidebar__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
Loading bookmarks...
</Text>
</Stack>
)}
{showEmptyState && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks in this document
</Text>
</div>
)}
{showBookmarkList && (
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
)}
{showSearchEmpty && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks match your search
</Text>
</div>
)}
</Box>
</ScrollArea>
</Box>
);
};

View File

@ -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<HTMLDivElement>(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 (
<Box
ref={viewerRef}
@ -260,7 +313,7 @@ const EmbedPdfViewerContent = ({
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
marginRight: `${totalRightMargin}rem`,
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
@ -315,6 +368,12 @@ const EmbedPdfViewerContent = ({
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
/>
<BookmarkSidebar
visible={isBookmarkSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
{/* Navigation Warning Modal */}
{!previewFile && (

View File

@ -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 && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{

View File

@ -19,6 +19,7 @@ export function useViewerRightRailButtons() {
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@ -111,6 +112,17 @@ export function useViewerRightRailButtons() {
viewer.toggleThumbnailSidebar();
}
},
{
id: 'viewer-toggle-bookmarks',
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
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);
}

View File

@ -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: <K extends BridgeKey>(
@ -135,6 +142,7 @@ interface ViewerProviderProps {
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleBookmarkSidebar = () => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@ -242,6 +254,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
@ -263,6 +288,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Annotation controls
isAnnotationsVisible,
@ -285,6 +312,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getSearchState,
getThumbnailAPI,
getExportState,
getBookmarkState,
hasBookmarkSupport,
// Immediate updates
registerImmediateZoomUpdate,
@ -303,6 +332,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
// Bridge registration
registerBridge,

View File

@ -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<ArrayBuffer | null>;
}
export interface BookmarkActions {
fetchBookmarks: () => Promise<PdfBookmarkObject[] | null>;
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);
},
},
};
}

View File

@ -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<ArrayBuffer> };
}
export interface BookmarkAPIWrapper {
fetchBookmarks: () => Promise<PdfBookmarkObject[]>;
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<TState = unknown, TApi = unknown> {
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<K extends BridgeKey>(