mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
parent
fca8470637
commit
4fd336c26c
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
73
frontend/src/core/components/viewer/BookmarkAPIBridge.tsx
Normal file
73
frontend/src/core/components/viewer/BookmarkAPIBridge.tsx
Normal 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;
|
||||
}
|
||||
196
frontend/src/core/components/viewer/BookmarkSidebar.css
Normal file
196
frontend/src/core/components/viewer/BookmarkSidebar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
551
frontend/src/core/components/viewer/BookmarkSidebar.tsx
Normal file
551
frontend/src/core/components/viewer/BookmarkSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 && (
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user