From 5a1ed50e2bf548f8bde34b15c428d043d5a5cf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:41:15 +0100 Subject: [PATCH] feat(attachments): add attachment support with sidebar and API integration (#5673) --- frontend/package-lock.json | 18 + frontend/package.json | 1 + .../public/locales/en-GB/translation.toml | 10 + .../components/viewer/AttachmentAPIBridge.tsx | 99 +++++ .../components/viewer/AttachmentSidebar.css | 163 ++++++++ .../components/viewer/AttachmentSidebar.tsx | 380 ++++++++++++++++++ .../core/components/viewer/EmbedPdfViewer.tsx | 54 ++- .../core/components/viewer/LocalEmbedPDF.tsx | 6 + .../viewer/useViewerRightRailButtons.tsx | 17 +- frontend/src/core/contexts/ViewerContext.tsx | 36 +- .../src/core/contexts/viewer/viewerActions.ts | 31 +- .../src/core/contexts/viewer/viewerBridges.ts | 18 +- 12 files changed, 805 insertions(+), 28 deletions(-) create mode 100644 frontend/src/core/components/viewer/AttachmentAPIBridge.tsx create mode 100644 frontend/src/core/components/viewer/AttachmentSidebar.css create mode 100644 frontend/src/core/components/viewer/AttachmentSidebar.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 007f74a26..46c65fe28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@embedpdf/engines": "^2.5.0", "@embedpdf/models": "^2.5.0", "@embedpdf/plugin-annotation": "^2.5.0", + "@embedpdf/plugin-attachment": "^2.5.0", "@embedpdf/plugin-bookmark": "^2.5.0", "@embedpdf/plugin-document-manager": "^2.5.0", "@embedpdf/plugin-export": "^2.5.0", @@ -668,6 +669,23 @@ "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": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index aa9c12696..fac874d13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "@embedpdf/engines": "^2.5.0", "@embedpdf/models": "^2.5.0", "@embedpdf/plugin-annotation": "^2.5.0", + "@embedpdf/plugin-attachment": "^2.5.0", "@embedpdf/plugin-bookmark": "^2.5.0", "@embedpdf/plugin-export": "^2.5.0", "@embedpdf/plugin-history": "^2.5.0", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 772064c5f..a610b2f6e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5304,6 +5304,7 @@ selectAll = "Select All" selectByNumber = "Select by Page Numbers" toggleAnnotations = "Toggle Annotations Visibility" toggleBookmarks = "Toggle Bookmarks" +toggleAttachments = "Toggle Attachments" toggleSidebar = "Toggle Sidebar" toggleTheme = "Toggle Theme" @@ -6437,6 +6438,15 @@ unknownFile = "Unknown file" zoomIn = "Zoom In" 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] header = "View PDF" tags = "view,read,annotate,text,image,highlight,edit" diff --git a/frontend/src/core/components/viewer/AttachmentAPIBridge.tsx b/frontend/src/core/components/viewer/AttachmentAPIBridge.tsx new file mode 100644 index 000000000..ea456072e --- /dev/null +++ b/frontend/src/core/components/viewer/AttachmentAPIBridge.tsx @@ -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({ + 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((_, 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(() => { + 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; +} diff --git a/frontend/src/core/components/viewer/AttachmentSidebar.css b/frontend/src/core/components/viewer/AttachmentSidebar.css new file mode 100644 index 000000000..753632fe3 --- /dev/null +++ b/frontend/src/core/components/viewer/AttachmentSidebar.css @@ -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; + } +} diff --git a/frontend/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/src/core/components/viewer/AttachmentSidebar.tsx new file mode 100644 index 000000000..916e6625e --- /dev/null +++ b/frontend/src/core/components/viewer/AttachmentSidebar.tsx @@ -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 => ({ + 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(() => createEntry()); + const cacheRef = useRef>(new Map()); + const [fetchNonce, setFetchNonce] = useState(0); + const currentKeyRef = useRef(documentCacheKey ?? null); + + useEffect(() => { + currentKeyRef.current = documentCacheKey ?? null; + }, [documentCacheKey]); + + // Poll once until the 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(); + 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) => ( +
+
handleDownload(attachment, event)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleDownload(attachment, event as any); + } + }} + > +
+ + {attachment.name || 'Untitled'} + + {(attachment.size !== undefined || attachment.description) && ( + + {[formatFileSize(attachment.size), attachment.description].filter(Boolean).join(' • ')} + + )} +
+ handleDownload(attachment, event)} + > + + +
+
+ )); + }; + + 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 ( + +
+
+ + + + + {t('viewer.attachments.title', 'Attachments')} + +
+
+ + + setSearchTerm(event.currentTarget.value)} + leftSection={} + size="xs" + /> + + + + + {!attachmentSupport && ( +
+ + {t('viewer.attachments.noSupport', 'Attachment support is unavailable for this viewer.')} + +
+ )} + + {attachmentSupport && showNoDocument && ( +
+ + {t('viewer.attachments.noDocument', 'Open a PDF to view its attachments.')} + +
+ )} + + {attachmentSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + + + )} + + {attachmentSupport && documentCacheKey && isLocalLoading && ( + + + + {t('viewer.attachments.loading', 'Loading attachments...')} + + + )} + + {showEmptyState && ( +
+ + {t('viewer.attachments.empty', 'No attachments in this document')} + +
+ )} + + {showAttachmentList && ( +
+ {renderAttachments(filteredAttachments)} +
+ )} + + {showSearchEmpty && ( +
+ + {t('viewer.attachments.noMatch', 'No attachments match your search')} + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index d1161e0c2..30fad6f5f 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -9,6 +9,7 @@ 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 { AttachmentSidebar } from '@app/components/viewer/AttachmentSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { useRedaction } from '@app/contexts/RedactionContext'; @@ -45,6 +46,7 @@ const EmbedPdfViewerContent = ({ isThumbnailSidebarVisible, toggleThumbnailSidebar, isBookmarkSidebarVisible, + isAttachmentSidebarVisible, isSearchInterfaceVisible, searchInterfaceActions, zoomActions, @@ -83,13 +85,13 @@ const EmbedPdfViewerContent = ({ const lastKnownScrollPageRef = useRef(1); const pendingScrollRestoreRef = useRef(null); const scrollRestoreAttemptsRef = useRef(0); - + // Track the file ID we should be viewing after a save (to handle list reordering) const pendingFileIdRef = useRef(null); // Get redaction context const { redactionsApplied, setRedactionsApplied } = useRedaction(); - + // Ref for redaction pending tracker API const redactionTrackerRef = useRef(null); @@ -118,7 +120,7 @@ const EmbedPdfViewerContent = ({ // Track previous annotation/redaction state to detect tool switches const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations); const prevEnableRedactionRef = useRef(shouldEnableRedaction); - + // Track scroll position whenever scrollState changes from the context // This ensures we always have the most up-to-date position useEffect(() => { @@ -126,27 +128,27 @@ const EmbedPdfViewerContent = ({ lastKnownScrollPageRef.current = scrollState.currentPage; } }, [scrollState.currentPage]); - + // Preserve scroll position when switching between annotation and redaction tools // Using useLayoutEffect to capture synchronously before DOM updates useLayoutEffect(() => { const annotationsChanged = prevEnableAnnotationsRef.current !== shouldEnableAnnotations; const redactionChanged = prevEnableRedactionRef.current !== shouldEnableRedaction; - + if (annotationsChanged || redactionChanged) { // Read scroll state directly AND use the tracked value - take whichever is valid const currentScrollState = getScrollState(); const pageFromState = currentScrollState.currentPage; const pageFromRef = lastKnownScrollPageRef.current; - + // Use the current state if valid, otherwise fall back to tracked ref const pageToRestore = pageFromState > 0 ? pageFromState : pageFromRef; - + if (pageToRestore > 0) { pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; } - + prevEnableAnnotationsRef.current = shouldEnableAnnotations; prevEnableRedactionRef.current = shouldEnableRedaction; } @@ -338,10 +340,10 @@ const EmbedPdfViewerContent = ({ const checkForChanges = () => { // Check for annotation history changes (using ref that's updated by useEffect) const hasAnnotationChanges = hasAnnotationChangesRef.current; - + // Check for pending redactions const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; - + // Always consider applied redactions as unsaved until export const hasAppliedRedactions = redactionsApplied; @@ -367,13 +369,13 @@ const EmbedPdfViewerContent = ({ // Step 0: Commit any pending redactions before export const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; - + // 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 if (hadPendingRedactions || redactionsApplied) { setRedactionsApplied(true); } - + if (hadPendingRedactions) { console.log('[Viewer] Committing pending redactions before export'); redactionTrackerRef.current?.commitAllPending(); @@ -396,7 +398,7 @@ const EmbedPdfViewerContent = ({ // Only consume the current file, not all active files const currentFileId = activeFiles[activeFileIndex]?.fileId; if (!currentFileId) throw new Error('Current file ID not found'); - + const parentStub = selectors.getStirlingFileStub(currentFileId); 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 pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; - + // Store the new file ID so we can track it after the list reorders const newFileId = stubs[0]?.id; if (newFileId) { @@ -451,7 +453,7 @@ const EmbedPdfViewerContent = ({ // Create StirlingFiles and stubs for version history const currentFileId = activeFiles[activeFileIndex]?.fileId; if (!currentFileId) throw new Error('Current file ID not found'); - + const parentStub = selectors.getStirlingFileStub(currentFileId); if (!parentStub) throw new Error('Parent stub not found'); @@ -463,7 +465,7 @@ const EmbedPdfViewerContent = ({ // Clear flags hasAnnotationChangesRef.current = false; setRedactionsApplied(false); - + console.log('[Viewer] Applied redactions saved, pending marks discarded'); } catch (error) { console.error('Failed to save applied redactions:', error); @@ -474,19 +476,19 @@ const EmbedPdfViewerContent = ({ // Uses polling with retries to ensure the scroll succeeds useEffect(() => { if (pendingScrollRestoreRef.current === null) return; - + const pageToRestore = pendingScrollRestoreRef.current; const maxAttempts = 10; const attemptInterval = 100; // ms between attempts - + const attemptScroll = () => { const currentState = getScrollState(); const targetPage = Math.min(pageToRestore, currentState.totalPages); - + // Only attempt if we have valid state (totalPages > 0 means PDF is loaded) if (currentState.totalPages > 0 && targetPage > 0) { scrollActions.scrollToPage(targetPage, 'instant'); - + // Check if scroll succeeded after a brief delay setTimeout(() => { const afterState = getScrollState(); @@ -516,7 +518,7 @@ const EmbedPdfViewerContent = ({ scrollRestoreAttemptsRef.current = 0; } }; - + // Start attempting after initial delay const timer = setTimeout(attemptScroll, 150); return () => clearTimeout(timer); @@ -535,7 +537,9 @@ const EmbedPdfViewerContent = ({ const sidebarWidthRem = 15; const totalRightMargin = - (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); + (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + + (isAttachmentSidebarVisible ? sidebarWidthRem : 0); return ( + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 96026972c..b50fe01d7 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -20,6 +20,7 @@ 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/react'; +import { AttachmentPluginPackage } from '@embedpdf/plugin-attachment/react'; import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; import { HistoryPluginPackage } from '@embedpdf/plugin-history/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 { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; +import { AttachmentAPIBridge } from '@app/components/viewer/AttachmentAPIBridge'; import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge'; import { isPdfFile } from '@app/utils/fileUtils'; import { useTranslation } from 'react-i18next'; @@ -184,6 +186,9 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, // Register bookmark plugin for PDF outline support createPluginRegistration(BookmarkPluginPackage), + // Register attachment plugin for PDF attachments support + createPluginRegistration(AttachmentPluginPackage), + // Register rotate plugin createPluginRegistration(RotatePluginPackage), @@ -664,6 +669,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, + (() => viewer.getPanState()?.isPanning ?? false); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12); @@ -74,6 +74,7 @@ export function useViewerRightRailButtons() { const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right'); const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks'); + const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments'); const printLabel = t('rightRail.print', 'Print PDF'); const annotationsLabel = t('rightRail.annotations', 'Annotations'); @@ -178,6 +179,18 @@ export function useViewerRightRailButtons() { viewer.toggleBookmarkSidebar(); } }, + { + id: 'viewer-toggle-attachments', + icon: , + tooltip: attachmentLabel, + ariaLabel: attachmentLabel, + section: 'top' as const, + order: 56, + active: isAttachmentSidebarVisible, + onClick: () => { + viewer.toggleAttachmentSidebar(); + } + }, { id: 'viewer-print', icon: , @@ -251,6 +264,7 @@ export function useViewerRightRailButtons() { viewer, isThumbnailSidebarVisible, isBookmarkSidebarVisible, + isAttachmentSidebarVisible, isSearchInterfaceVisible, isPanning, searchLabel, @@ -260,6 +274,7 @@ export function useViewerRightRailButtons() { rotateRightLabel, sidebarLabel, bookmarkLabel, + attachmentLabel, printLabel, tooltipPosition, annotationsLabel, diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index c2429a323..997afa6f4 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -18,6 +18,7 @@ import { SearchActions, ExportActions, BookmarkActions, + AttachmentActions, PrintActions, } from '@app/contexts/viewer/viewerActions'; import { @@ -38,6 +39,7 @@ import { ExportState, ThumbnailAPIWrapper, BookmarkState, + AttachmentState, DocumentPermissionsState, PdfPermissionFlag, } from '@app/contexts/viewer/viewerBridges'; @@ -81,6 +83,8 @@ interface ViewerContextType { toggleThumbnailSidebar: () => void; isBookmarkSidebarVisible: boolean; toggleBookmarkSidebar: () => void; + isAttachmentSidebarVisible: boolean; + toggleAttachmentSidebar: () => void; // Search interface visibility isSearchInterfaceVisible: boolean; @@ -114,6 +118,8 @@ interface ViewerContextType { getExportState: () => ExportState; getBookmarkState: () => BookmarkState; hasBookmarkSupport: () => boolean; + getAttachmentState: () => AttachmentState; + hasAttachmentSupport: () => boolean; getDocumentPermissions: () => DocumentPermissionsState; hasPermission: (flag: PdfPermissionFlag) => boolean; @@ -139,9 +145,10 @@ interface ViewerContextType { searchActions: SearchActions; exportActions: ExportActions; bookmarkActions: BookmarkActions; + attachmentActions: AttachmentActions; printActions: PrintActions; - // Bridge registration - internal use by bridges + // Bridge registration - internal use by bridges registerBridge: ( type: K, ref: BridgeRef @@ -162,6 +169,7 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); + const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false); const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); @@ -175,11 +183,11 @@ export const ViewerProvider: React.FC = ({ children }) => { // Apply changes function - registered by EmbedPdfViewer const applyChangesRef = useRef<(() => Promise) | null>(null); - + const setApplyChanges = useCallback((fn: (() => Promise) | null) => { applyChangesRef.current = fn; }, []); - + const applyChanges = useCallback(async () => { if (applyChangesRef.current) { await applyChangesRef.current(); @@ -249,6 +257,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsBookmarkSidebarVisible(prev => !prev); }; + const toggleAttachmentSidebar = () => { + setIsAttachmentSidebarVisible(prev => !prev); + }; + const searchInterfaceActions = { open: () => setSearchInterfaceVisible(true), close: () => setSearchInterfaceVisible(false), @@ -312,6 +324,18 @@ export const ViewerProvider: React.FC = ({ children }) => { 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 => { return bridgeRefs.current.permissions?.state || { isEncrypted: false, @@ -351,6 +375,7 @@ export const ViewerProvider: React.FC = ({ children }) => { searchActions, exportActions, bookmarkActions, + attachmentActions, printActions, } = createViewerActions({ registry: bridgeRefs, @@ -365,6 +390,8 @@ export const ViewerProvider: React.FC = ({ children }) => { toggleThumbnailSidebar, isBookmarkSidebarVisible, toggleBookmarkSidebar, + isAttachmentSidebarVisible, + toggleAttachmentSidebar, // Search interface isSearchInterfaceVisible, @@ -392,6 +419,8 @@ export const ViewerProvider: React.FC = ({ children }) => { getExportState, getBookmarkState, hasBookmarkSupport, + getAttachmentState, + hasAttachmentSupport, getDocumentPermissions, hasPermission, @@ -415,6 +444,7 @@ export const ViewerProvider: React.FC = ({ children }) => { searchActions, exportActions, bookmarkActions, + attachmentActions, printActions, // Bridge registration diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index 85d6a1fc9..6358f6f68 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -5,7 +5,7 @@ import { ScrollState, ZoomState, } from '@app/contexts/viewer/viewerBridges'; -import { PdfBookmarkObject } from '@embedpdf/models'; +import { PdfBookmarkObject, PdfAttachmentObject } from '@embedpdf/models'; export interface ScrollActions { scrollToPage: (page: number, behavior?: 'smooth' | 'instant') => void; @@ -66,6 +66,13 @@ export interface BookmarkActions { setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; } +export interface AttachmentActions { + getAttachments: () => Promise; + downloadAttachment: (attachment: PdfAttachmentObject) => void; + clearAttachments: () => void; + setLocalAttachments: (attachments: PdfAttachmentObject[] | null, error?: string | null) => void; +} + export interface PrintActions { print: () => void; } @@ -80,6 +87,7 @@ export interface ViewerActionsBundle { searchActions: SearchActions; exportActions: ExportActions; bookmarkActions: BookmarkActions; + attachmentActions: AttachmentActions; printActions: PrintActions; } @@ -375,6 +383,27 @@ export function createViewerActions({ 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: { print: () => { const api = registry.current.print?.api; diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index ce1a61e21..cca42ff62 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -1,5 +1,5 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react'; -import { PdfBookmarkObject } from '@embedpdf/models'; +import { PdfBookmarkObject, PdfAttachmentObject } from '@embedpdf/models'; export enum PdfPermissionFlag { Print = 0x0004, @@ -102,6 +102,13 @@ export interface BookmarkAPIWrapper { setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; } +export interface AttachmentAPIWrapper { + getAttachments: () => Promise; + downloadAttachment: (attachment: PdfAttachmentObject) => void; + clearAttachments: () => void; + setLocalAttachments: (attachments: PdfAttachmentObject[] | null, error?: string | null) => void; +} + export interface ScrollState { currentPage: number; totalPages: number; @@ -152,6 +159,12 @@ export interface BookmarkState { error: string | null; } +export interface AttachmentState { + attachments: PdfAttachmentObject[] | null; + isLoading: boolean; + error: string | null; +} + export interface BridgeRef { state: TState; api: TApi; @@ -168,6 +181,7 @@ export interface BridgeStateMap { thumbnail: unknown; export: ExportState; bookmark: BookmarkState; + attachment: AttachmentState; print: unknown; permissions: DocumentPermissionsState; } @@ -183,6 +197,7 @@ export interface BridgeApiMap { thumbnail: ThumbnailAPIWrapper; export: ExportAPIWrapper; bookmark: BookmarkAPIWrapper; + attachment: AttachmentAPIWrapper; print: PrintAPIWrapper; permissions: DocumentPermissionsAPIWrapper; } @@ -204,6 +219,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ thumbnail: null, export: null, bookmark: null, + attachment: null, print: null, permissions: null, });