feat(attachments): add attachment support with sidebar and API integration (#5673)

This commit is contained in:
Balázs Szücs
2026-02-13 13:41:15 +01:00
committed by GitHub
parent e01734fb7d
commit 5a1ed50e2b
12 changed files with 805 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@@ -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<number>(1);
const pendingScrollRestoreRef = useRef<number | null>(null);
const scrollRestoreAttemptsRef = useRef<number>(0);
// Track the file ID we should be viewing after a save (to handle list reordering)
const pendingFileIdRef = useRef<string | null>(null);
// Get redaction context
const { redactionsApplied, setRedactionsApplied } = useRedaction();
// Ref for redaction pending tracker API
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(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 (
<Box
@@ -649,6 +653,12 @@ const EmbedPdfViewerContent = ({
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
<AttachmentSidebar
visible={isAttachmentSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
{/* Navigation Warning Modal */}
{!previewFile && (

View File

@@ -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,
<ExportAPIBridge />
<BookmarkAPIBridge />
<AttachmentAPIBridge />
<PrintAPIBridge />
<DocumentPermissionsAPIBridge />
<DocumentReadyWrapper

View File

@@ -17,7 +17,7 @@ import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
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 { 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: <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',
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
@@ -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,

View File

@@ -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: <K extends BridgeKey>(
type: 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
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<ViewerProviderProps> = ({ children }) => {
// Apply changes function - registered by EmbedPdfViewer
const applyChangesRef = useRef<(() => Promise<void>) | null>(null);
const setApplyChanges = useCallback((fn: (() => Promise<void>) | null) => {
applyChangesRef.current = fn;
}, []);
const applyChanges = useCallback(async () => {
if (applyChangesRef.current) {
await applyChangesRef.current();
@@ -249,6 +257,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
searchActions,
exportActions,
bookmarkActions,
attachmentActions,
printActions,
} = createViewerActions({
registry: bridgeRefs,
@@ -365,6 +390,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
isAttachmentSidebarVisible,
toggleAttachmentSidebar,
// Search interface
isSearchInterfaceVisible,
@@ -392,6 +419,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getExportState,
getBookmarkState,
hasBookmarkSupport,
getAttachmentState,
hasAttachmentSupport,
getDocumentPermissions,
hasPermission,
@@ -415,6 +444,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
searchActions,
exportActions,
bookmarkActions,
attachmentActions,
printActions,
// Bridge registration

View File

@@ -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<PdfAttachmentObject[] | null>;
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;

View File

@@ -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<PdfAttachmentObject[]>;
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<TState = unknown, TApi = unknown> {
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,
});