mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Generated
+17
@@ -14,6 +14,7 @@
|
|||||||
"@embedpdf/core": "^1.4.1",
|
"@embedpdf/core": "^1.4.1",
|
||||||
"@embedpdf/engines": "^1.4.1",
|
"@embedpdf/engines": "^1.4.1",
|
||||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||||
|
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||||
"@embedpdf/plugin-export": "^1.4.1",
|
"@embedpdf/plugin-export": "^1.4.1",
|
||||||
"@embedpdf/plugin-history": "^1.4.1",
|
"@embedpdf/plugin-history": "^1.4.1",
|
||||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||||
@@ -633,6 +634,22 @@
|
|||||||
"vue": ">=3.2.0"
|
"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": {
|
"node_modules/@embedpdf/plugin-export": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"@embedpdf/core": "^1.4.1",
|
"@embedpdf/core": "^1.4.1",
|
||||||
"@embedpdf/engines": "^1.4.1",
|
"@embedpdf/engines": "^1.4.1",
|
||||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||||
|
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||||
"@embedpdf/plugin-export": "^1.4.1",
|
"@embedpdf/plugin-export": "^1.4.1",
|
||||||
"@embedpdf/plugin-history": "^1.4.1",
|
"@embedpdf/plugin-history": "^1.4.1",
|
||||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||||
|
|||||||
@@ -3825,6 +3825,7 @@
|
|||||||
"downloadSelected": "Download Selected Files",
|
"downloadSelected": "Download Selected Files",
|
||||||
"downloadAll": "Download All",
|
"downloadAll": "Download All",
|
||||||
"toggleTheme": "Toggle Theme",
|
"toggleTheme": "Toggle Theme",
|
||||||
|
"toggleBookmarks": "Toggle Bookmarks",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"search": "Search PDF",
|
"search": "Search PDF",
|
||||||
"panMode": "Pan Mode",
|
"panMode": "Pan Mode",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
|||||||
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
|
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
|
||||||
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
|
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
|
||||||
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
|
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
|
||||||
|
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
|
||||||
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||||
@@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({
|
|||||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
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
|
// Register viewer right-rail buttons
|
||||||
useViewerRightRailButtons();
|
useViewerRightRailButtons();
|
||||||
@@ -128,6 +141,42 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}, [previewFile, fileWithUrl]);
|
}, [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({
|
useWheelZoom({
|
||||||
ref: viewerRef,
|
ref: viewerRef,
|
||||||
onZoomIn: zoomActions.zoomIn,
|
onZoomIn: zoomActions.zoomIn,
|
||||||
@@ -219,6 +268,10 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||||
|
|
||||||
|
const sidebarWidthRem = 15;
|
||||||
|
const totalRightMargin =
|
||||||
|
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={viewerRef}
|
ref={viewerRef}
|
||||||
@@ -260,7 +313,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
marginRight: `${totalRightMargin}rem`,
|
||||||
transition: 'margin-right 0.3s ease'
|
transition: 'margin-right 0.3s ease'
|
||||||
}}>
|
}}>
|
||||||
<LocalEmbedPDF
|
<LocalEmbedPDF
|
||||||
@@ -315,6 +368,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
onToggle={toggleThumbnailSidebar}
|
onToggle={toggleThumbnailSidebar}
|
||||||
activeFileIndex={activeFileIndex}
|
activeFileIndex={activeFileIndex}
|
||||||
/>
|
/>
|
||||||
|
<BookmarkSidebar
|
||||||
|
visible={isBookmarkSidebarVisible}
|
||||||
|
thumbnailVisible={isThumbnailSidebarVisible}
|
||||||
|
documentCacheKey={bookmarkCacheKey}
|
||||||
|
preloadCacheKeys={allBookmarkCacheKeys}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Navigation Warning Modal */}
|
{/* Navigation Warning Modal */}
|
||||||
{!previewFile && (
|
{!previewFile && (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
|||||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||||
|
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
||||||
|
|
||||||
// Import annotation plugins
|
// Import annotation plugins
|
||||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
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 { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||||
|
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||||
|
|
||||||
interface LocalEmbedPDFProps {
|
interface LocalEmbedPDFProps {
|
||||||
file?: File | Blob;
|
file?: File | Blob;
|
||||||
@@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
|||||||
// Register thumbnail plugin for page thumbnails
|
// Register thumbnail plugin for page thumbnails
|
||||||
createPluginRegistration(ThumbnailPluginPackage),
|
createPluginRegistration(ThumbnailPluginPackage),
|
||||||
|
|
||||||
|
// Register bookmark plugin for PDF outline support
|
||||||
|
createPluginRegistration(BookmarkPluginPackage),
|
||||||
|
|
||||||
// Register rotate plugin
|
// Register rotate plugin
|
||||||
createPluginRegistration(RotatePluginPackage),
|
createPluginRegistration(RotatePluginPackage),
|
||||||
|
|
||||||
@@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
|||||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||||
<ExportAPIBridge />
|
<ExportAPIBridge />
|
||||||
|
<BookmarkAPIBridge />
|
||||||
<GlobalPointerProvider>
|
<GlobalPointerProvider>
|
||||||
<Viewport
|
<Viewport
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function useViewerRightRailButtons() {
|
|||||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||||
|
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
return [
|
return [
|
||||||
@@ -111,6 +112,17 @@ export function useViewerRightRailButtons() {
|
|||||||
viewer.toggleThumbnailSidebar();
|
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',
|
id: 'viewer-annotation-controls',
|
||||||
section: 'top' as const,
|
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);
|
useRightRailButtons(viewerButtons);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
RotationActions,
|
RotationActions,
|
||||||
SearchActions,
|
SearchActions,
|
||||||
ExportActions,
|
ExportActions,
|
||||||
|
BookmarkActions,
|
||||||
} from '@app/contexts/viewer/viewerActions';
|
} from '@app/contexts/viewer/viewerActions';
|
||||||
import {
|
import {
|
||||||
BridgeRef,
|
BridgeRef,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SearchState,
|
SearchState,
|
||||||
ExportState,
|
ExportState,
|
||||||
ThumbnailAPIWrapper,
|
ThumbnailAPIWrapper,
|
||||||
|
BookmarkState,
|
||||||
} from '@app/contexts/viewer/viewerBridges';
|
} from '@app/contexts/viewer/viewerBridges';
|
||||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
|
|
||||||
@@ -74,6 +76,8 @@ interface ViewerContextType {
|
|||||||
// UI state managed by this context
|
// UI state managed by this context
|
||||||
isThumbnailSidebarVisible: boolean;
|
isThumbnailSidebarVisible: boolean;
|
||||||
toggleThumbnailSidebar: () => void;
|
toggleThumbnailSidebar: () => void;
|
||||||
|
isBookmarkSidebarVisible: boolean;
|
||||||
|
toggleBookmarkSidebar: () => void;
|
||||||
|
|
||||||
// Annotation visibility toggle
|
// Annotation visibility toggle
|
||||||
isAnnotationsVisible: boolean;
|
isAnnotationsVisible: boolean;
|
||||||
@@ -98,6 +102,8 @@ interface ViewerContextType {
|
|||||||
getSearchState: () => SearchState;
|
getSearchState: () => SearchState;
|
||||||
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
||||||
getExportState: () => ExportState;
|
getExportState: () => ExportState;
|
||||||
|
getBookmarkState: () => BookmarkState;
|
||||||
|
hasBookmarkSupport: () => boolean;
|
||||||
|
|
||||||
// Immediate update callbacks
|
// Immediate update callbacks
|
||||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||||
@@ -118,6 +124,7 @@ interface ViewerContextType {
|
|||||||
rotationActions: RotationActions;
|
rotationActions: RotationActions;
|
||||||
searchActions: SearchActions;
|
searchActions: SearchActions;
|
||||||
exportActions: ExportActions;
|
exportActions: ExportActions;
|
||||||
|
bookmarkActions: BookmarkActions;
|
||||||
|
|
||||||
// Bridge registration - internal use by bridges
|
// Bridge registration - internal use by bridges
|
||||||
registerBridge: <K extends BridgeKey>(
|
registerBridge: <K extends BridgeKey>(
|
||||||
@@ -135,6 +142,7 @@ interface ViewerProviderProps {
|
|||||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||||
// UI state - only state directly managed by this context
|
// UI state - only state directly managed by this context
|
||||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||||
|
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
|
||||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||||
@@ -193,6 +201,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
setIsThumbnailSidebarVisible(prev => !prev);
|
setIsThumbnailSidebarVisible(prev => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleBookmarkSidebar = () => {
|
||||||
|
setIsBookmarkSidebarVisible(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAnnotationsVisibility = () => {
|
const toggleAnnotationsVisibility = () => {
|
||||||
setIsAnnotationsVisible(prev => !prev);
|
setIsAnnotationsVisible(prev => !prev);
|
||||||
};
|
};
|
||||||
@@ -242,6 +254,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
return bridgeRefs.current.export?.state || { canExport: false };
|
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
|
// Action handlers - call APIs directly
|
||||||
const {
|
const {
|
||||||
scrollActions,
|
scrollActions,
|
||||||
@@ -252,6 +276,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
rotationActions,
|
rotationActions,
|
||||||
searchActions,
|
searchActions,
|
||||||
exportActions,
|
exportActions,
|
||||||
|
bookmarkActions,
|
||||||
} = createViewerActions({
|
} = createViewerActions({
|
||||||
registry: bridgeRefs,
|
registry: bridgeRefs,
|
||||||
getScrollState,
|
getScrollState,
|
||||||
@@ -263,6 +288,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
// UI state
|
// UI state
|
||||||
isThumbnailSidebarVisible,
|
isThumbnailSidebarVisible,
|
||||||
toggleThumbnailSidebar,
|
toggleThumbnailSidebar,
|
||||||
|
isBookmarkSidebarVisible,
|
||||||
|
toggleBookmarkSidebar,
|
||||||
|
|
||||||
// Annotation controls
|
// Annotation controls
|
||||||
isAnnotationsVisible,
|
isAnnotationsVisible,
|
||||||
@@ -285,6 +312,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
getSearchState,
|
getSearchState,
|
||||||
getThumbnailAPI,
|
getThumbnailAPI,
|
||||||
getExportState,
|
getExportState,
|
||||||
|
getBookmarkState,
|
||||||
|
hasBookmarkSupport,
|
||||||
|
|
||||||
// Immediate updates
|
// Immediate updates
|
||||||
registerImmediateZoomUpdate,
|
registerImmediateZoomUpdate,
|
||||||
@@ -303,6 +332,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
rotationActions,
|
rotationActions,
|
||||||
searchActions,
|
searchActions,
|
||||||
exportActions,
|
exportActions,
|
||||||
|
bookmarkActions,
|
||||||
|
|
||||||
// Bridge registration
|
// Bridge registration
|
||||||
registerBridge,
|
registerBridge,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ScrollState,
|
ScrollState,
|
||||||
ZoomState,
|
ZoomState,
|
||||||
} from '@app/contexts/viewer/viewerBridges';
|
} from '@app/contexts/viewer/viewerBridges';
|
||||||
|
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export interface ScrollActions {
|
export interface ScrollActions {
|
||||||
scrollToPage: (page: number) => void;
|
scrollToPage: (page: number) => void;
|
||||||
@@ -58,6 +59,12 @@ export interface ExportActions {
|
|||||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookmarkActions {
|
||||||
|
fetchBookmarks: () => Promise<PdfBookmarkObject[] | null>;
|
||||||
|
clearBookmarks: () => void;
|
||||||
|
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ViewerActionsBundle {
|
export interface ViewerActionsBundle {
|
||||||
scrollActions: ScrollActions;
|
scrollActions: ScrollActions;
|
||||||
zoomActions: ZoomActions;
|
zoomActions: ZoomActions;
|
||||||
@@ -67,6 +74,7 @@ export interface ViewerActionsBundle {
|
|||||||
rotationActions: RotationActions;
|
rotationActions: RotationActions;
|
||||||
searchActions: SearchActions;
|
searchActions: SearchActions;
|
||||||
exportActions: ExportActions;
|
exportActions: ExportActions;
|
||||||
|
bookmarkActions: BookmarkActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ViewerActionDependencies {
|
interface ViewerActionDependencies {
|
||||||
@@ -307,5 +315,22 @@ export function createViewerActions({
|
|||||||
rotationActions,
|
rotationActions,
|
||||||
searchActions,
|
searchActions,
|
||||||
exportActions,
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
|
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export interface ScrollAPIWrapper {
|
export interface ScrollAPIWrapper {
|
||||||
scrollToPage: (params: { pageNumber: number }) => void;
|
scrollToPage: (params: { pageNumber: number }) => void;
|
||||||
@@ -59,6 +60,12 @@ export interface ExportAPIWrapper {
|
|||||||
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookmarkAPIWrapper {
|
||||||
|
fetchBookmarks: () => Promise<PdfBookmarkObject[]>;
|
||||||
|
clearBookmarks: () => void;
|
||||||
|
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScrollState {
|
export interface ScrollState {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@@ -103,6 +110,12 @@ export interface ExportState {
|
|||||||
canExport: boolean;
|
canExport: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookmarkState {
|
||||||
|
bookmarks: PdfBookmarkObject[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BridgeRef<TState = unknown, TApi = unknown> {
|
export interface BridgeRef<TState = unknown, TApi = unknown> {
|
||||||
state: TState;
|
state: TState;
|
||||||
api: TApi;
|
api: TApi;
|
||||||
@@ -118,6 +131,7 @@ export interface BridgeStateMap {
|
|||||||
search: SearchState;
|
search: SearchState;
|
||||||
thumbnail: unknown;
|
thumbnail: unknown;
|
||||||
export: ExportState;
|
export: ExportState;
|
||||||
|
bookmark: BookmarkState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BridgeApiMap {
|
export interface BridgeApiMap {
|
||||||
@@ -130,6 +144,7 @@ export interface BridgeApiMap {
|
|||||||
search: SearchAPIWrapper;
|
search: SearchAPIWrapper;
|
||||||
thumbnail: ThumbnailAPIWrapper;
|
thumbnail: ThumbnailAPIWrapper;
|
||||||
export: ExportAPIWrapper;
|
export: ExportAPIWrapper;
|
||||||
|
bookmark: BookmarkAPIWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BridgeKey = keyof BridgeStateMap;
|
export type BridgeKey = keyof BridgeStateMap;
|
||||||
@@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
|
|||||||
search: null,
|
search: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
export: null,
|
export: null,
|
||||||
|
bookmark: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerBridge<K extends BridgeKey>(
|
export function registerBridge<K extends BridgeKey>(
|
||||||
|
|||||||
Reference in New Issue
Block a user