mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
feat(attachments): add attachment support with sidebar and API integration (#5673)
This commit is contained in:
99
frontend/src/core/components/viewer/AttachmentAPIBridge.tsx
Normal file
99
frontend/src/core/components/viewer/AttachmentAPIBridge.tsx
Normal 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;
|
||||
}
|
||||
163
frontend/src/core/components/viewer/AttachmentSidebar.css
Normal file
163
frontend/src/core/components/viewer/AttachmentSidebar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
380
frontend/src/core/components/viewer/AttachmentSidebar.tsx
Normal file
380
frontend/src/core/components/viewer/AttachmentSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user