mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(attachments): add attachment support with sidebar and API integration (#5673)
This commit is contained in:
Generated
+18
@@ -15,6 +15,7 @@
|
|||||||
"@embedpdf/engines": "^2.5.0",
|
"@embedpdf/engines": "^2.5.0",
|
||||||
"@embedpdf/models": "^2.5.0",
|
"@embedpdf/models": "^2.5.0",
|
||||||
"@embedpdf/plugin-annotation": "^2.5.0",
|
"@embedpdf/plugin-annotation": "^2.5.0",
|
||||||
|
"@embedpdf/plugin-attachment": "^2.5.0",
|
||||||
"@embedpdf/plugin-bookmark": "^2.5.0",
|
"@embedpdf/plugin-bookmark": "^2.5.0",
|
||||||
"@embedpdf/plugin-document-manager": "^2.5.0",
|
"@embedpdf/plugin-document-manager": "^2.5.0",
|
||||||
"@embedpdf/plugin-export": "^2.5.0",
|
"@embedpdf/plugin-export": "^2.5.0",
|
||||||
@@ -668,6 +669,23 @@
|
|||||||
"vue": ">=3.2.0"
|
"vue": ">=3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@embedpdf/plugin-attachment": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-dVVnklI2V1Tsnkf2Ob1PY/R8U6bImhaYwiWm8TwJnNzXXT3aTHQ81lGQOv+pIA9f02L8Y4J5OOKkVrKIpqBHug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@embedpdf/models": "2.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@embedpdf/core": "2.5.0",
|
||||||
|
"preact": "^10.26.4",
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0",
|
||||||
|
"svelte": ">=5 <6",
|
||||||
|
"vue": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@embedpdf/plugin-bookmark": {
|
"node_modules/@embedpdf/plugin-bookmark": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@embedpdf/engines": "^2.5.0",
|
"@embedpdf/engines": "^2.5.0",
|
||||||
"@embedpdf/models": "^2.5.0",
|
"@embedpdf/models": "^2.5.0",
|
||||||
"@embedpdf/plugin-annotation": "^2.5.0",
|
"@embedpdf/plugin-annotation": "^2.5.0",
|
||||||
|
"@embedpdf/plugin-attachment": "^2.5.0",
|
||||||
"@embedpdf/plugin-bookmark": "^2.5.0",
|
"@embedpdf/plugin-bookmark": "^2.5.0",
|
||||||
"@embedpdf/plugin-export": "^2.5.0",
|
"@embedpdf/plugin-export": "^2.5.0",
|
||||||
"@embedpdf/plugin-history": "^2.5.0",
|
"@embedpdf/plugin-history": "^2.5.0",
|
||||||
|
|||||||
@@ -5304,6 +5304,7 @@ selectAll = "Select All"
|
|||||||
selectByNumber = "Select by Page Numbers"
|
selectByNumber = "Select by Page Numbers"
|
||||||
toggleAnnotations = "Toggle Annotations Visibility"
|
toggleAnnotations = "Toggle Annotations Visibility"
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
|
toggleAttachments = "Toggle Attachments"
|
||||||
toggleSidebar = "Toggle Sidebar"
|
toggleSidebar = "Toggle Sidebar"
|
||||||
toggleTheme = "Toggle Theme"
|
toggleTheme = "Toggle Theme"
|
||||||
|
|
||||||
@@ -6437,6 +6438,15 @@ unknownFile = "Unknown file"
|
|||||||
zoomIn = "Zoom In"
|
zoomIn = "Zoom In"
|
||||||
zoomOut = "Zoom Out"
|
zoomOut = "Zoom Out"
|
||||||
|
|
||||||
|
[viewer.attachments]
|
||||||
|
title = "Attachments"
|
||||||
|
searchPlaceholder = "Search attachments"
|
||||||
|
noSupport = "Attachment support is unavailable for this viewer."
|
||||||
|
noDocument = "Open a PDF to view its attachments."
|
||||||
|
loading = "Loading attachments..."
|
||||||
|
empty = "No attachments in this document"
|
||||||
|
noMatch = "No attachments match your search"
|
||||||
|
|
||||||
[viewPdf]
|
[viewPdf]
|
||||||
header = "View PDF"
|
header = "View PDF"
|
||||||
tags = "view,read,annotate,text,image,highlight,edit"
|
tags = "view,read,annotate,text,image,highlight,edit"
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
|
import { useAttachmentCapability } from '@embedpdf/plugin-attachment/react';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
import { AttachmentState, AttachmentAPIWrapper } from '@app/contexts/viewer/viewerBridges';
|
||||||
|
import { PdfAttachmentObject } from '@embedpdf/models';
|
||||||
|
|
||||||
|
export function AttachmentAPIBridge() {
|
||||||
|
const { provides: attachmentCapability } = useAttachmentCapability();
|
||||||
|
const { registerBridge } = useViewer();
|
||||||
|
const [state, setState] = useState<AttachmentState>({
|
||||||
|
attachments: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAttachments = useCallback(
|
||||||
|
async () => {
|
||||||
|
if (!attachmentCapability) return [];
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const task = attachmentCapability.getAttachments();
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Attachment fetch timeout after 10 seconds')), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.race([task.toPromise(), timeoutPromise]);
|
||||||
|
setState({
|
||||||
|
attachments: result ?? [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return result ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load attachments';
|
||||||
|
setState({
|
||||||
|
attachments: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attachmentCapability]
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useMemo<AttachmentAPIWrapper | null>(() => {
|
||||||
|
if (!attachmentCapability) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAttachments: fetchAttachments,
|
||||||
|
downloadAttachment: async (attachment: PdfAttachmentObject) => {
|
||||||
|
try {
|
||||||
|
const task = attachmentCapability.downloadAttachment(attachment);
|
||||||
|
const buffer = await task.toPromise();
|
||||||
|
|
||||||
|
// Create a blob and trigger download
|
||||||
|
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = attachment.name || 'attachment';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download attachment:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearAttachments: () => {
|
||||||
|
setState({
|
||||||
|
attachments: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setLocalAttachments: (attachments, error = null) => {
|
||||||
|
setState({
|
||||||
|
attachments,
|
||||||
|
isLoading: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [attachmentCapability, fetchAttachments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
registerBridge('attachment', {
|
||||||
|
state,
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
}, [api, state, registerBridge]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/* Attachment Sidebar - Modern styling to match AllTools section */
|
||||||
|
|
||||||
|
.attachment-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 */
|
||||||
|
.attachment-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-sidebar__header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-sidebar__header-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--mantine-color-blue-6);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Section */
|
||||||
|
.attachment-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 */
|
||||||
|
.attachment-sidebar__content {
|
||||||
|
padding-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-sidebar__empty-state,
|
||||||
|
.attachment-sidebar__error {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-sidebar__loading {
|
||||||
|
padding: 2rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment List */
|
||||||
|
.attachment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment Items */
|
||||||
|
.attachment-item-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item:active {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Icon */
|
||||||
|
.attachment-item__download-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item:hover .attachment-item__download-icon {
|
||||||
|
color: var(--mantine-color-blue-6);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.attachment-item__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item__title {
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item__meta {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth fade-in animation */
|
||||||
|
@keyframes attachment-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
animation: attachment-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility & Reduced Motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.attachment-item,
|
||||||
|
.attachment-list {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput } from '@mantine/core';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
import { PdfAttachmentObject } from '@embedpdf/models';
|
||||||
|
import AttachmentIcon from '@mui/icons-material/AttachmentRounded';
|
||||||
|
import DownloadIcon from '@mui/icons-material/DownloadRounded';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import '@app/components/viewer/AttachmentSidebar.css';
|
||||||
|
|
||||||
|
interface AttachmentSidebarProps {
|
||||||
|
visible: boolean;
|
||||||
|
thumbnailVisible: boolean;
|
||||||
|
documentCacheKey?: string;
|
||||||
|
preloadCacheKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = '15rem';
|
||||||
|
|
||||||
|
interface AttachmentCacheEntry {
|
||||||
|
status: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
attachments: PdfAttachmentObject[] | null;
|
||||||
|
error: string | null;
|
||||||
|
lastFetched: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEntry = (overrides: Partial<AttachmentCacheEntry> = {}): AttachmentCacheEntry => ({
|
||||||
|
status: 'idle',
|
||||||
|
attachments: null,
|
||||||
|
error: null,
|
||||||
|
lastFetched: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AttachmentSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: AttachmentSidebarProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { attachmentActions, hasAttachmentSupport } = useViewer();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [attachmentSupport, setAttachmentSupport] = useState(() => hasAttachmentSupport());
|
||||||
|
const [activeEntry, setActiveEntry] = useState<AttachmentCacheEntry>(() => createEntry());
|
||||||
|
const cacheRef = useRef<Map<string, AttachmentCacheEntry>>(new Map());
|
||||||
|
const [fetchNonce, setFetchNonce] = useState(0);
|
||||||
|
const currentKeyRef = useRef<string | null>(documentCacheKey ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentKeyRef.current = documentCacheKey ?? null;
|
||||||
|
}, [documentCacheKey]);
|
||||||
|
|
||||||
|
// Poll once until the attachment bridge registers
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachmentSupport) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (!cancelled && hasAttachmentSupport()) {
|
||||||
|
setAttachmentSupport(true);
|
||||||
|
clearInterval(id);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [attachmentSupport, hasAttachmentSupport]);
|
||||||
|
|
||||||
|
// Reset UI and load cached entry (if any) when switching documents
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
|
||||||
|
if (!documentCacheKey) {
|
||||||
|
setActiveEntry(createEntry());
|
||||||
|
attachmentActions.clearAttachments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = cacheRef.current.get(documentCacheKey);
|
||||||
|
if (cached) {
|
||||||
|
setActiveEntry(cached);
|
||||||
|
if (cached.status === 'success') {
|
||||||
|
attachmentActions.setLocalAttachments(cached.attachments ?? [], null);
|
||||||
|
} else if (cached.status === 'error') {
|
||||||
|
attachmentActions.setLocalAttachments(cached.attachments ?? null, cached.error);
|
||||||
|
} else {
|
||||||
|
attachmentActions.clearAttachments();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setActiveEntry(createEntry());
|
||||||
|
attachmentActions.clearAttachments();
|
||||||
|
}
|
||||||
|
}, [documentCacheKey, attachmentActions]);
|
||||||
|
|
||||||
|
// 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 attachments for the active document when needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attachmentSupport || !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: AttachmentCacheEntry) => {
|
||||||
|
cacheRef.current.set(key, entry);
|
||||||
|
if (!cancelled && currentKeyRef.current === key) {
|
||||||
|
setActiveEntry(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateEntry(createEntry({
|
||||||
|
status: 'loading',
|
||||||
|
attachments: cached?.attachments ?? null,
|
||||||
|
lastFetched: cached?.lastFetched ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchWithRetry = async () => {
|
||||||
|
const maxAttempts = 10;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const result = await attachmentActions.getAttachments();
|
||||||
|
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(attachments => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const entry = createEntry({
|
||||||
|
status: 'success',
|
||||||
|
attachments,
|
||||||
|
lastFetched: Date.now(),
|
||||||
|
});
|
||||||
|
updateEntry(entry);
|
||||||
|
if (currentKeyRef.current === key) {
|
||||||
|
attachmentActions.setLocalAttachments(attachments, null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load attachments';
|
||||||
|
const fallback = cacheRef.current.get(key);
|
||||||
|
const entry = createEntry({
|
||||||
|
status: 'error',
|
||||||
|
attachments: fallback?.attachments ?? null,
|
||||||
|
error: message,
|
||||||
|
lastFetched: fallback?.lastFetched ?? null,
|
||||||
|
});
|
||||||
|
updateEntry(entry);
|
||||||
|
if (currentKeyRef.current === key) {
|
||||||
|
attachmentActions.setLocalAttachments(null, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [attachmentSupport, documentCacheKey, fetchNonce, attachmentActions]);
|
||||||
|
|
||||||
|
const requestReload = useCallback(() => {
|
||||||
|
if (!documentCacheKey) return;
|
||||||
|
cacheRef.current.delete(documentCacheKey);
|
||||||
|
setActiveEntry(createEntry());
|
||||||
|
attachmentActions.clearAttachments();
|
||||||
|
setFetchNonce(value => value + 1);
|
||||||
|
}, [documentCacheKey, attachmentActions]);
|
||||||
|
|
||||||
|
const handleDownload = (attachment: PdfAttachmentObject, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
attachmentActions.downloadAttachment(attachment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAttachments = useMemo(() => {
|
||||||
|
const attachments = Array.isArray(activeEntry.attachments) ? activeEntry.attachments : [];
|
||||||
|
if (!searchTerm.trim()) return attachments;
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
return attachments.filter(a => a.name?.toLowerCase().includes(term));
|
||||||
|
}, [activeEntry.attachments, searchTerm]);
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number) => {
|
||||||
|
if (bytes === undefined) return '';
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAttachments = (attachments: PdfAttachmentObject[]) => {
|
||||||
|
return attachments.map((attachment, index) => (
|
||||||
|
<div key={`${attachment.name}-${index}`} className="attachment-item-wrapper">
|
||||||
|
<div
|
||||||
|
className="attachment-item"
|
||||||
|
onClick={(event) => handleDownload(attachment, event)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDownload(attachment, event as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="attachment-item__content">
|
||||||
|
<Text size="sm" fw={500} className="attachment-item__title">
|
||||||
|
{attachment.name || 'Untitled'}
|
||||||
|
</Text>
|
||||||
|
{(attachment.size !== undefined || attachment.description) && (
|
||||||
|
<Text size="xs" c="dimmed" className="attachment-item__meta">
|
||||||
|
{[formatFileSize(attachment.size), attachment.description].filter(Boolean).join(' • ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
className="attachment-item__download-icon"
|
||||||
|
onClick={(event) => handleDownload(attachment, event)}
|
||||||
|
>
|
||||||
|
<DownloadIcon sx={{ fontSize: '1.2rem' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSearchActive = searchTerm.trim().length > 0;
|
||||||
|
const hasAttachments = Array.isArray(activeEntry.attachments) && activeEntry.attachments.length > 0;
|
||||||
|
const isLocalLoading = attachmentSupport && activeEntry.status === 'loading';
|
||||||
|
const currentError = attachmentSupport && activeEntry.status === 'error' ? activeEntry.error : null;
|
||||||
|
|
||||||
|
const showAttachmentList = attachmentSupport && documentCacheKey && filteredAttachments.length > 0;
|
||||||
|
const showEmptyState =
|
||||||
|
attachmentSupport &&
|
||||||
|
documentCacheKey &&
|
||||||
|
!isLocalLoading &&
|
||||||
|
!currentError &&
|
||||||
|
activeEntry.status === 'success' &&
|
||||||
|
!hasAttachments;
|
||||||
|
const showSearchEmpty =
|
||||||
|
attachmentSupport &&
|
||||||
|
documentCacheKey &&
|
||||||
|
isSearchActive &&
|
||||||
|
hasAttachments &&
|
||||||
|
filteredAttachments.length === 0;
|
||||||
|
const showNoDocument = attachmentSupport && !documentCacheKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="attachment-sidebar"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: SIDEBAR_WIDTH,
|
||||||
|
zIndex: 998,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="attachment-sidebar__header">
|
||||||
|
<div className="attachment-sidebar__header-title">
|
||||||
|
<span className="attachment-sidebar__header-icon">
|
||||||
|
<AttachmentIcon />
|
||||||
|
</span>
|
||||||
|
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||||
|
{t('viewer.attachments.title', 'Attachments')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box px="sm" pb="sm" className="attachment-sidebar__search">
|
||||||
|
<TextInput
|
||||||
|
value={searchTerm}
|
||||||
|
placeholder={t('viewer.attachments.searchPlaceholder', 'Search attachments')}
|
||||||
|
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="attachment-sidebar__content">
|
||||||
|
{!attachmentSupport && (
|
||||||
|
<div className="attachment-sidebar__empty-state">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t('viewer.attachments.noSupport', 'Attachment support is unavailable for this viewer.')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachmentSupport && showNoDocument && (
|
||||||
|
<div className="attachment-sidebar__empty-state">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t('viewer.attachments.noDocument', 'Open a PDF to view its attachments.')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachmentSupport && documentCacheKey && currentError && (
|
||||||
|
<Stack gap="xs" align="center" className="attachment-sidebar__error">
|
||||||
|
<Text size="sm" c="red" ta="center">
|
||||||
|
{currentError}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon variant="light" onClick={requestReload}>
|
||||||
|
<LocalIcon icon="refresh" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachmentSupport && documentCacheKey && isLocalLoading && (
|
||||||
|
<Stack gap="md" align="center" c="dimmed" py="xl" className="attachment-sidebar__loading">
|
||||||
|
<Loader size="md" type="dots" />
|
||||||
|
<Text size="sm" ta="center">
|
||||||
|
{t('viewer.attachments.loading', 'Loading attachments...')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmptyState && (
|
||||||
|
<div className="attachment-sidebar__empty-state">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t('viewer.attachments.empty', 'No attachments in this document')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAttachmentList && (
|
||||||
|
<div className="attachment-list">
|
||||||
|
{renderAttachments(filteredAttachments)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSearchEmpty && (
|
||||||
|
<div className="attachment-sidebar__empty-state">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t('viewer.attachments.noMatch', 'No attachments match your search')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ 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 { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
|
||||||
|
import { AttachmentSidebar } from '@app/components/viewer/AttachmentSidebar';
|
||||||
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 { useRedaction } from '@app/contexts/RedactionContext';
|
import { useRedaction } from '@app/contexts/RedactionContext';
|
||||||
@@ -45,6 +46,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
isThumbnailSidebarVisible,
|
isThumbnailSidebarVisible,
|
||||||
toggleThumbnailSidebar,
|
toggleThumbnailSidebar,
|
||||||
isBookmarkSidebarVisible,
|
isBookmarkSidebarVisible,
|
||||||
|
isAttachmentSidebarVisible,
|
||||||
isSearchInterfaceVisible,
|
isSearchInterfaceVisible,
|
||||||
searchInterfaceActions,
|
searchInterfaceActions,
|
||||||
zoomActions,
|
zoomActions,
|
||||||
@@ -83,13 +85,13 @@ const EmbedPdfViewerContent = ({
|
|||||||
const lastKnownScrollPageRef = useRef<number>(1);
|
const lastKnownScrollPageRef = useRef<number>(1);
|
||||||
const pendingScrollRestoreRef = useRef<number | null>(null);
|
const pendingScrollRestoreRef = useRef<number | null>(null);
|
||||||
const scrollRestoreAttemptsRef = useRef<number>(0);
|
const scrollRestoreAttemptsRef = useRef<number>(0);
|
||||||
|
|
||||||
// Track the file ID we should be viewing after a save (to handle list reordering)
|
// Track the file ID we should be viewing after a save (to handle list reordering)
|
||||||
const pendingFileIdRef = useRef<string | null>(null);
|
const pendingFileIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Get redaction context
|
// Get redaction context
|
||||||
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
||||||
|
|
||||||
// Ref for redaction pending tracker API
|
// Ref for redaction pending tracker API
|
||||||
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Track previous annotation/redaction state to detect tool switches
|
// Track previous annotation/redaction state to detect tool switches
|
||||||
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
|
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
|
||||||
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
|
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
|
||||||
|
|
||||||
// Track scroll position whenever scrollState changes from the context
|
// Track scroll position whenever scrollState changes from the context
|
||||||
// This ensures we always have the most up-to-date position
|
// This ensures we always have the most up-to-date position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,27 +128,27 @@ const EmbedPdfViewerContent = ({
|
|||||||
lastKnownScrollPageRef.current = scrollState.currentPage;
|
lastKnownScrollPageRef.current = scrollState.currentPage;
|
||||||
}
|
}
|
||||||
}, [scrollState.currentPage]);
|
}, [scrollState.currentPage]);
|
||||||
|
|
||||||
// Preserve scroll position when switching between annotation and redaction tools
|
// Preserve scroll position when switching between annotation and redaction tools
|
||||||
// Using useLayoutEffect to capture synchronously before DOM updates
|
// Using useLayoutEffect to capture synchronously before DOM updates
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations;
|
const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations;
|
||||||
const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction;
|
const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction;
|
||||||
|
|
||||||
if (annotationsChanged || redactionChanged) {
|
if (annotationsChanged || redactionChanged) {
|
||||||
// Read scroll state directly AND use the tracked value - take whichever is valid
|
// Read scroll state directly AND use the tracked value - take whichever is valid
|
||||||
const currentScrollState = getScrollState();
|
const currentScrollState = getScrollState();
|
||||||
const pageFromState = currentScrollState.currentPage;
|
const pageFromState = currentScrollState.currentPage;
|
||||||
const pageFromRef = lastKnownScrollPageRef.current;
|
const pageFromRef = lastKnownScrollPageRef.current;
|
||||||
|
|
||||||
// Use the current state if valid, otherwise fall back to tracked ref
|
// Use the current state if valid, otherwise fall back to tracked ref
|
||||||
const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef;
|
const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef;
|
||||||
|
|
||||||
if (pageToRestore > 0) {
|
if (pageToRestore > 0) {
|
||||||
pendingScrollRestoreRef.current = pageToRestore;
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
scrollRestoreAttemptsRef.current = 0;
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevEnableAnnotationsRef.current = shouldEnableAnnotations;
|
prevEnableAnnotationsRef.current = shouldEnableAnnotations;
|
||||||
prevEnableRedactionRef.current = shouldEnableRedaction;
|
prevEnableRedactionRef.current = shouldEnableRedaction;
|
||||||
}
|
}
|
||||||
@@ -338,10 +340,10 @@ const EmbedPdfViewerContent = ({
|
|||||||
const checkForChanges = () => {
|
const checkForChanges = () => {
|
||||||
// Check for annotation history changes (using ref that's updated by useEffect)
|
// Check for annotation history changes (using ref that's updated by useEffect)
|
||||||
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
||||||
|
|
||||||
// Check for pending redactions
|
// Check for pending redactions
|
||||||
const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
||||||
|
|
||||||
// Always consider applied redactions as unsaved until export
|
// Always consider applied redactions as unsaved until export
|
||||||
const hasAppliedRedactions = redactionsApplied;
|
const hasAppliedRedactions = redactionsApplied;
|
||||||
|
|
||||||
@@ -367,13 +369,13 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
// Step 0: Commit any pending redactions before export
|
// Step 0: Commit any pending redactions before export
|
||||||
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
||||||
|
|
||||||
// Mark redactions as applied BEFORE committing, so the button stays enabled during the save process
|
// Mark redactions as applied BEFORE committing, so the button stays enabled during the save process
|
||||||
// This ensures the button doesn't become disabled when pendingCount becomes 0
|
// This ensures the button doesn't become disabled when pendingCount becomes 0
|
||||||
if (hadPendingRedactions || redactionsApplied) {
|
if (hadPendingRedactions || redactionsApplied) {
|
||||||
setRedactionsApplied(true);
|
setRedactionsApplied(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hadPendingRedactions) {
|
if (hadPendingRedactions) {
|
||||||
console.log('[Viewer] Committing pending redactions before export');
|
console.log('[Viewer] Committing pending redactions before export');
|
||||||
redactionTrackerRef.current?.commitAllPending();
|
redactionTrackerRef.current?.commitAllPending();
|
||||||
@@ -396,7 +398,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Only consume the current file, not all active files
|
// Only consume the current file, not all active files
|
||||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||||
if (!currentFileId) throw new Error('Current file ID not found');
|
if (!currentFileId) throw new Error('Current file ID not found');
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
if (!parentStub) throw new Error('Parent stub not found');
|
if (!parentStub) throw new Error('Parent stub not found');
|
||||||
|
|
||||||
@@ -405,7 +407,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Store the page to restore after file replacement triggers re-render
|
// Store the page to restore after file replacement triggers re-render
|
||||||
pendingScrollRestoreRef.current = pageToRestore;
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
scrollRestoreAttemptsRef.current = 0;
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
// Store the new file ID so we can track it after the list reorders
|
// Store the new file ID so we can track it after the list reorders
|
||||||
const newFileId = stubs[0]?.id;
|
const newFileId = stubs[0]?.id;
|
||||||
if (newFileId) {
|
if (newFileId) {
|
||||||
@@ -451,7 +453,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Create StirlingFiles and stubs for version history
|
// Create StirlingFiles and stubs for version history
|
||||||
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||||
if (!currentFileId) throw new Error('Current file ID not found');
|
if (!currentFileId) throw new Error('Current file ID not found');
|
||||||
|
|
||||||
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
if (!parentStub) throw new Error('Parent stub not found');
|
if (!parentStub) throw new Error('Parent stub not found');
|
||||||
|
|
||||||
@@ -463,7 +465,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Clear flags
|
// Clear flags
|
||||||
hasAnnotationChangesRef.current = false;
|
hasAnnotationChangesRef.current = false;
|
||||||
setRedactionsApplied(false);
|
setRedactionsApplied(false);
|
||||||
|
|
||||||
console.log('[Viewer] Applied redactions saved, pending marks discarded');
|
console.log('[Viewer] Applied redactions saved, pending marks discarded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save applied redactions:', error);
|
console.error('Failed to save applied redactions:', error);
|
||||||
@@ -474,19 +476,19 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Uses polling with retries to ensure the scroll succeeds
|
// Uses polling with retries to ensure the scroll succeeds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingScrollRestoreRef.current === null) return;
|
if (pendingScrollRestoreRef.current === null) return;
|
||||||
|
|
||||||
const pageToRestore = pendingScrollRestoreRef.current;
|
const pageToRestore = pendingScrollRestoreRef.current;
|
||||||
const maxAttempts = 10;
|
const maxAttempts = 10;
|
||||||
const attemptInterval = 100; // ms between attempts
|
const attemptInterval = 100; // ms between attempts
|
||||||
|
|
||||||
const attemptScroll = () => {
|
const attemptScroll = () => {
|
||||||
const currentState = getScrollState();
|
const currentState = getScrollState();
|
||||||
const targetPage = Math.min(pageToRestore, currentState.totalPages);
|
const targetPage = Math.min(pageToRestore, currentState.totalPages);
|
||||||
|
|
||||||
// Only attempt if we have valid state (totalPages > 0 means PDF is loaded)
|
// Only attempt if we have valid state (totalPages > 0 means PDF is loaded)
|
||||||
if (currentState.totalPages > 0 && targetPage > 0) {
|
if (currentState.totalPages > 0 && targetPage > 0) {
|
||||||
scrollActions.scrollToPage(targetPage, 'instant');
|
scrollActions.scrollToPage(targetPage, 'instant');
|
||||||
|
|
||||||
// Check if scroll succeeded after a brief delay
|
// Check if scroll succeeded after a brief delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const afterState = getScrollState();
|
const afterState = getScrollState();
|
||||||
@@ -516,7 +518,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
scrollRestoreAttemptsRef.current = 0;
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start attempting after initial delay
|
// Start attempting after initial delay
|
||||||
const timer = setTimeout(attemptScroll, 150);
|
const timer = setTimeout(attemptScroll, 150);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -535,7 +537,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
const sidebarWidthRem = 15;
|
const sidebarWidthRem = 15;
|
||||||
const totalRightMargin =
|
const totalRightMargin =
|
||||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
|
||||||
|
(isBookmarkSidebarVisible ? sidebarWidthRem : 0) +
|
||||||
|
(isAttachmentSidebarVisible ? sidebarWidthRem : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -649,6 +653,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
documentCacheKey={bookmarkCacheKey}
|
documentCacheKey={bookmarkCacheKey}
|
||||||
preloadCacheKeys={allBookmarkCacheKeys}
|
preloadCacheKeys={allBookmarkCacheKeys}
|
||||||
/>
|
/>
|
||||||
|
<AttachmentSidebar
|
||||||
|
visible={isAttachmentSidebarVisible}
|
||||||
|
thumbnailVisible={isThumbnailSidebarVisible}
|
||||||
|
documentCacheKey={bookmarkCacheKey}
|
||||||
|
preloadCacheKeys={allBookmarkCacheKeys}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Navigation Warning Modal */}
|
{/* Navigation Warning Modal */}
|
||||||
{!previewFile && (
|
{!previewFile && (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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/react';
|
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark/react';
|
||||||
|
import { AttachmentPluginPackage } from '@embedpdf/plugin-attachment/react';
|
||||||
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
||||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||||
@@ -42,6 +43,7 @@ import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
|||||||
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
import type { SignatureAPI, AnnotationAPI, 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';
|
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||||
|
import { AttachmentAPIBridge } from '@app/components/viewer/AttachmentAPIBridge';
|
||||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||||
import { isPdfFile } from '@app/utils/fileUtils';
|
import { isPdfFile } from '@app/utils/fileUtils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -184,6 +186,9 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
// Register bookmark plugin for PDF outline support
|
// Register bookmark plugin for PDF outline support
|
||||||
createPluginRegistration(BookmarkPluginPackage),
|
createPluginRegistration(BookmarkPluginPackage),
|
||||||
|
|
||||||
|
// Register attachment plugin for PDF attachments support
|
||||||
|
createPluginRegistration(AttachmentPluginPackage),
|
||||||
|
|
||||||
// Register rotate plugin
|
// Register rotate plugin
|
||||||
createPluginRegistration(RotatePluginPackage),
|
createPluginRegistration(RotatePluginPackage),
|
||||||
|
|
||||||
@@ -664,6 +669,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
|
|
||||||
<ExportAPIBridge />
|
<ExportAPIBridge />
|
||||||
<BookmarkAPIBridge />
|
<BookmarkAPIBridge />
|
||||||
|
<AttachmentAPIBridge />
|
||||||
<PrintAPIBridge />
|
<PrintAPIBridge />
|
||||||
<DocumentPermissionsAPIBridge />
|
<DocumentPermissionsAPIBridge />
|
||||||
<DocumentReadyWrapper
|
<DocumentReadyWrapper
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
|||||||
export function useViewerRightRailButtons() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||||
@@ -74,6 +74,7 @@ export function useViewerRightRailButtons() {
|
|||||||
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 bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||||
|
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
|
||||||
const printLabel = t('rightRail.print', 'Print PDF');
|
const printLabel = t('rightRail.print', 'Print PDF');
|
||||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||||
|
|
||||||
@@ -178,6 +179,18 @@ export function useViewerRightRailButtons() {
|
|||||||
viewer.toggleBookmarkSidebar();
|
viewer.toggleBookmarkSidebar();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'viewer-toggle-attachments',
|
||||||
|
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
tooltip: attachmentLabel,
|
||||||
|
ariaLabel: attachmentLabel,
|
||||||
|
section: 'top' as const,
|
||||||
|
order: 56,
|
||||||
|
active: isAttachmentSidebarVisible,
|
||||||
|
onClick: () => {
|
||||||
|
viewer.toggleAttachmentSidebar();
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'viewer-print',
|
id: 'viewer-print',
|
||||||
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
|
||||||
@@ -251,6 +264,7 @@ export function useViewerRightRailButtons() {
|
|||||||
viewer,
|
viewer,
|
||||||
isThumbnailSidebarVisible,
|
isThumbnailSidebarVisible,
|
||||||
isBookmarkSidebarVisible,
|
isBookmarkSidebarVisible,
|
||||||
|
isAttachmentSidebarVisible,
|
||||||
isSearchInterfaceVisible,
|
isSearchInterfaceVisible,
|
||||||
isPanning,
|
isPanning,
|
||||||
searchLabel,
|
searchLabel,
|
||||||
@@ -260,6 +274,7 @@ export function useViewerRightRailButtons() {
|
|||||||
rotateRightLabel,
|
rotateRightLabel,
|
||||||
sidebarLabel,
|
sidebarLabel,
|
||||||
bookmarkLabel,
|
bookmarkLabel,
|
||||||
|
attachmentLabel,
|
||||||
printLabel,
|
printLabel,
|
||||||
tooltipPosition,
|
tooltipPosition,
|
||||||
annotationsLabel,
|
annotationsLabel,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SearchActions,
|
SearchActions,
|
||||||
ExportActions,
|
ExportActions,
|
||||||
BookmarkActions,
|
BookmarkActions,
|
||||||
|
AttachmentActions,
|
||||||
PrintActions,
|
PrintActions,
|
||||||
} from '@app/contexts/viewer/viewerActions';
|
} from '@app/contexts/viewer/viewerActions';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
ExportState,
|
ExportState,
|
||||||
ThumbnailAPIWrapper,
|
ThumbnailAPIWrapper,
|
||||||
BookmarkState,
|
BookmarkState,
|
||||||
|
AttachmentState,
|
||||||
DocumentPermissionsState,
|
DocumentPermissionsState,
|
||||||
PdfPermissionFlag,
|
PdfPermissionFlag,
|
||||||
} from '@app/contexts/viewer/viewerBridges';
|
} from '@app/contexts/viewer/viewerBridges';
|
||||||
@@ -81,6 +83,8 @@ interface ViewerContextType {
|
|||||||
toggleThumbnailSidebar: () => void;
|
toggleThumbnailSidebar: () => void;
|
||||||
isBookmarkSidebarVisible: boolean;
|
isBookmarkSidebarVisible: boolean;
|
||||||
toggleBookmarkSidebar: () => void;
|
toggleBookmarkSidebar: () => void;
|
||||||
|
isAttachmentSidebarVisible: boolean;
|
||||||
|
toggleAttachmentSidebar: () => void;
|
||||||
|
|
||||||
// Search interface visibility
|
// Search interface visibility
|
||||||
isSearchInterfaceVisible: boolean;
|
isSearchInterfaceVisible: boolean;
|
||||||
@@ -114,6 +118,8 @@ interface ViewerContextType {
|
|||||||
getExportState: () => ExportState;
|
getExportState: () => ExportState;
|
||||||
getBookmarkState: () => BookmarkState;
|
getBookmarkState: () => BookmarkState;
|
||||||
hasBookmarkSupport: () => boolean;
|
hasBookmarkSupport: () => boolean;
|
||||||
|
getAttachmentState: () => AttachmentState;
|
||||||
|
hasAttachmentSupport: () => boolean;
|
||||||
getDocumentPermissions: () => DocumentPermissionsState;
|
getDocumentPermissions: () => DocumentPermissionsState;
|
||||||
hasPermission: (flag: PdfPermissionFlag) => boolean;
|
hasPermission: (flag: PdfPermissionFlag) => boolean;
|
||||||
|
|
||||||
@@ -139,9 +145,10 @@ interface ViewerContextType {
|
|||||||
searchActions: SearchActions;
|
searchActions: SearchActions;
|
||||||
exportActions: ExportActions;
|
exportActions: ExportActions;
|
||||||
bookmarkActions: BookmarkActions;
|
bookmarkActions: BookmarkActions;
|
||||||
|
attachmentActions: AttachmentActions;
|
||||||
printActions: PrintActions;
|
printActions: PrintActions;
|
||||||
|
|
||||||
// Bridge registration - internal use by bridges
|
// Bridge registration - internal use by bridges
|
||||||
registerBridge: <K extends BridgeKey>(
|
registerBridge: <K extends BridgeKey>(
|
||||||
type: K,
|
type: K,
|
||||||
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
|
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
|
||||||
@@ -162,6 +169,7 @@ 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 [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
|
||||||
|
const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false);
|
||||||
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
||||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||||
@@ -175,11 +183,11 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Apply changes function - registered by EmbedPdfViewer
|
// Apply changes function - registered by EmbedPdfViewer
|
||||||
const applyChangesRef = useRef<(() => Promise<void>) | null>(null);
|
const applyChangesRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
|
||||||
const setApplyChanges = useCallback((fn: (() => Promise<void>) | null) => {
|
const setApplyChanges = useCallback((fn: (() => Promise<void>) | null) => {
|
||||||
applyChangesRef.current = fn;
|
applyChangesRef.current = fn;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applyChanges = useCallback(async () => {
|
const applyChanges = useCallback(async () => {
|
||||||
if (applyChangesRef.current) {
|
if (applyChangesRef.current) {
|
||||||
await applyChangesRef.current();
|
await applyChangesRef.current();
|
||||||
@@ -249,6 +257,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
setIsBookmarkSidebarVisible(prev => !prev);
|
setIsBookmarkSidebarVisible(prev => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAttachmentSidebar = () => {
|
||||||
|
setIsAttachmentSidebarVisible(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
const searchInterfaceActions = {
|
const searchInterfaceActions = {
|
||||||
open: () => setSearchInterfaceVisible(true),
|
open: () => setSearchInterfaceVisible(true),
|
||||||
close: () => setSearchInterfaceVisible(false),
|
close: () => setSearchInterfaceVisible(false),
|
||||||
@@ -312,6 +324,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
|
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
|
||||||
|
|
||||||
|
const getAttachmentState = (): AttachmentState => {
|
||||||
|
return (
|
||||||
|
bridgeRefs.current.attachment?.state || {
|
||||||
|
attachments: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAttachmentSupport = () => Boolean(bridgeRefs.current.attachment);
|
||||||
|
|
||||||
const getDocumentPermissions = (): DocumentPermissionsState => {
|
const getDocumentPermissions = (): DocumentPermissionsState => {
|
||||||
return bridgeRefs.current.permissions?.state || {
|
return bridgeRefs.current.permissions?.state || {
|
||||||
isEncrypted: false,
|
isEncrypted: false,
|
||||||
@@ -351,6 +375,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
searchActions,
|
searchActions,
|
||||||
exportActions,
|
exportActions,
|
||||||
bookmarkActions,
|
bookmarkActions,
|
||||||
|
attachmentActions,
|
||||||
printActions,
|
printActions,
|
||||||
} = createViewerActions({
|
} = createViewerActions({
|
||||||
registry: bridgeRefs,
|
registry: bridgeRefs,
|
||||||
@@ -365,6 +390,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
toggleThumbnailSidebar,
|
toggleThumbnailSidebar,
|
||||||
isBookmarkSidebarVisible,
|
isBookmarkSidebarVisible,
|
||||||
toggleBookmarkSidebar,
|
toggleBookmarkSidebar,
|
||||||
|
isAttachmentSidebarVisible,
|
||||||
|
toggleAttachmentSidebar,
|
||||||
|
|
||||||
// Search interface
|
// Search interface
|
||||||
isSearchInterfaceVisible,
|
isSearchInterfaceVisible,
|
||||||
@@ -392,6 +419,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
getExportState,
|
getExportState,
|
||||||
getBookmarkState,
|
getBookmarkState,
|
||||||
hasBookmarkSupport,
|
hasBookmarkSupport,
|
||||||
|
getAttachmentState,
|
||||||
|
hasAttachmentSupport,
|
||||||
getDocumentPermissions,
|
getDocumentPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
|
||||||
@@ -415,6 +444,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
searchActions,
|
searchActions,
|
||||||
exportActions,
|
exportActions,
|
||||||
bookmarkActions,
|
bookmarkActions,
|
||||||
|
attachmentActions,
|
||||||
printActions,
|
printActions,
|
||||||
|
|
||||||
// Bridge registration
|
// Bridge registration
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ScrollState,
|
ScrollState,
|
||||||
ZoomState,
|
ZoomState,
|
||||||
} from '@app/contexts/viewer/viewerBridges';
|
} from '@app/contexts/viewer/viewerBridges';
|
||||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
import { PdfBookmarkObject, PdfAttachmentObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export interface ScrollActions {
|
export interface ScrollActions {
|
||||||
scrollToPage: (page: number, behavior?: 'smooth' | 'instant') => void;
|
scrollToPage: (page: number, behavior?: 'smooth' | 'instant') => void;
|
||||||
@@ -66,6 +66,13 @@ export interface BookmarkActions {
|
|||||||
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentActions {
|
||||||
|
getAttachments: () => Promise<PdfAttachmentObject[] | null>;
|
||||||
|
downloadAttachment: (attachment: PdfAttachmentObject) => void;
|
||||||
|
clearAttachments: () => void;
|
||||||
|
setLocalAttachments: (attachments: PdfAttachmentObject[] | null, error?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrintActions {
|
export interface PrintActions {
|
||||||
print: () => void;
|
print: () => void;
|
||||||
}
|
}
|
||||||
@@ -80,6 +87,7 @@ export interface ViewerActionsBundle {
|
|||||||
searchActions: SearchActions;
|
searchActions: SearchActions;
|
||||||
exportActions: ExportActions;
|
exportActions: ExportActions;
|
||||||
bookmarkActions: BookmarkActions;
|
bookmarkActions: BookmarkActions;
|
||||||
|
attachmentActions: AttachmentActions;
|
||||||
printActions: PrintActions;
|
printActions: PrintActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +383,27 @@ export function createViewerActions({
|
|||||||
api?.setLocalBookmarks?.(bookmarks ?? null, error);
|
api?.setLocalBookmarks?.(bookmarks ?? null, error);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachmentActions: {
|
||||||
|
getAttachments: async () => {
|
||||||
|
const api = registry.current.attachment?.api;
|
||||||
|
if (!api?.getAttachments) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return api.getAttachments();
|
||||||
|
},
|
||||||
|
downloadAttachment: (attachment) => {
|
||||||
|
const api = registry.current.attachment?.api;
|
||||||
|
api?.downloadAttachment?.(attachment);
|
||||||
|
},
|
||||||
|
clearAttachments: () => {
|
||||||
|
const api = registry.current.attachment?.api;
|
||||||
|
api?.clearAttachments?.();
|
||||||
|
},
|
||||||
|
setLocalAttachments: (attachments, error = null) => {
|
||||||
|
const api = registry.current.attachment?.api;
|
||||||
|
api?.setLocalAttachments?.(attachments ?? null, error);
|
||||||
|
},
|
||||||
|
},
|
||||||
printActions: {
|
printActions: {
|
||||||
print: () => {
|
print: () => {
|
||||||
const api = registry.current.print?.api;
|
const api = registry.current.print?.api;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
import { PdfBookmarkObject, PdfAttachmentObject } from '@embedpdf/models';
|
||||||
|
|
||||||
export enum PdfPermissionFlag {
|
export enum PdfPermissionFlag {
|
||||||
Print = 0x0004,
|
Print = 0x0004,
|
||||||
@@ -102,6 +102,13 @@ export interface BookmarkAPIWrapper {
|
|||||||
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentAPIWrapper {
|
||||||
|
getAttachments: () => Promise<PdfAttachmentObject[]>;
|
||||||
|
downloadAttachment: (attachment: PdfAttachmentObject) => void;
|
||||||
|
clearAttachments: () => void;
|
||||||
|
setLocalAttachments: (attachments: PdfAttachmentObject[] | null, error?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScrollState {
|
export interface ScrollState {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@@ -152,6 +159,12 @@ export interface BookmarkState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentState {
|
||||||
|
attachments: PdfAttachmentObject[] | 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;
|
||||||
@@ -168,6 +181,7 @@ export interface BridgeStateMap {
|
|||||||
thumbnail: unknown;
|
thumbnail: unknown;
|
||||||
export: ExportState;
|
export: ExportState;
|
||||||
bookmark: BookmarkState;
|
bookmark: BookmarkState;
|
||||||
|
attachment: AttachmentState;
|
||||||
print: unknown;
|
print: unknown;
|
||||||
permissions: DocumentPermissionsState;
|
permissions: DocumentPermissionsState;
|
||||||
}
|
}
|
||||||
@@ -183,6 +197,7 @@ export interface BridgeApiMap {
|
|||||||
thumbnail: ThumbnailAPIWrapper;
|
thumbnail: ThumbnailAPIWrapper;
|
||||||
export: ExportAPIWrapper;
|
export: ExportAPIWrapper;
|
||||||
bookmark: BookmarkAPIWrapper;
|
bookmark: BookmarkAPIWrapper;
|
||||||
|
attachment: AttachmentAPIWrapper;
|
||||||
print: PrintAPIWrapper;
|
print: PrintAPIWrapper;
|
||||||
permissions: DocumentPermissionsAPIWrapper;
|
permissions: DocumentPermissionsAPIWrapper;
|
||||||
}
|
}
|
||||||
@@ -204,6 +219,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
|
|||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
export: null,
|
export: null,
|
||||||
bookmark: null,
|
bookmark: null,
|
||||||
|
attachment: null,
|
||||||
print: null,
|
print: null,
|
||||||
permissions: null,
|
permissions: null,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user