diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index bde7c6d1d..c35791ca6 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4896,6 +4896,7 @@ toggleAttachments = "Toggle Attachments" toggleTheme = "Toggle Theme" language = "Language" toggleAnnotations = "Toggle Annotations Visibility" +toggleLayers = "Toggle Layers" search = "Search PDF" panMode = "Pan Mode" applyRedactionsFirst = "Apply redactions first" diff --git a/frontend/src/core/components/viewer/AttachmentSidebar.css b/frontend/src/core/components/viewer/AttachmentSidebar.css index 753632fe3..03f1dbd43 100644 --- a/frontend/src/core/components/viewer/AttachmentSidebar.css +++ b/frontend/src/core/components/viewer/AttachmentSidebar.css @@ -1,63 +1,4 @@ -/* 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 Sidebar - Attachment-specific styles (base styles in SidebarBase.css) */ /* Attachment List */ .attachment-list { diff --git a/frontend/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/src/core/components/viewer/AttachmentSidebar.tsx index 2498fe84c..1e5d6cd91 100644 --- a/frontend/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/src/core/components/viewer/AttachmentSidebar.tsx @@ -6,6 +6,7 @@ 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/SidebarBase.css'; import '@app/components/viewer/AttachmentSidebar.css'; interface AttachmentSidebarProps { @@ -284,7 +285,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, return ( -
-
- +
+
+ @@ -305,7 +306,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
- + - + {!attachmentSupport && ( -
+
{t('viewer.attachments.noSupport', 'Attachment support is unavailable for this viewer.')} @@ -326,7 +327,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, )} {attachmentSupport && showNoDocument && ( -
+
{t('viewer.attachments.noDocument', 'Open a PDF to view its attachments.')} @@ -334,7 +335,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, )} {attachmentSupport && documentCacheKey && currentError && ( - + {currentError} @@ -345,7 +346,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, )} {attachmentSupport && documentCacheKey && isLocalLoading && ( - + {t('viewer.attachments.loading', 'Loading attachments...')} @@ -354,7 +355,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, )} {showEmptyState && ( -
+
{t('viewer.attachments.empty', 'No attachments in this document')} @@ -368,7 +369,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, )} {showSearchEmpty && ( -
+
{t('viewer.attachments.noMatch', 'No attachments match your search')} diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.css b/frontend/src/core/components/viewer/BookmarkSidebar.css index da130a957..8e4fb0328 100644 --- a/frontend/src/core/components/viewer/BookmarkSidebar.css +++ b/frontend/src/core/components/viewer/BookmarkSidebar.css @@ -1,63 +1,4 @@ -/* Bookmark Sidebar - Modern styling to match AllTools section */ - -.bookmark-sidebar { - display: flex; - flex-direction: column; - background: linear-gradient( - 135deg, - color-mix(in srgb, var(--bg-toolbar) 96%, transparent), - color-mix(in srgb, var(--bg-background) 90%, transparent) - ); - border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent); - box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent); - backdrop-filter: blur(12px); -} - -/* Header Section */ -.bookmark-sidebar__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 0.875rem; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border-subtle); -} - -.bookmark-sidebar__header-title { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.bookmark-sidebar__header-icon { - display: flex; - align-items: center; - justify-content: center; - color: var(--mantine-color-blue-6); - font-size: 1.1rem; -} - -/* Search Section */ -.bookmark-sidebar__search { - background: var(--tool-panel-search-bg, var(--bg-toolbar)); - border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle)); - padding-top: 0.75rem !important; -} - -/* Content Section */ -.bookmark-sidebar__content { - padding-top: 0.5rem !important; -} - -.bookmark-sidebar__empty-state, -.bookmark-sidebar__error { - padding: 2rem 1rem; - text-align: center; -} - -.bookmark-sidebar__loading { - padding: 2rem 1rem !important; -} +/* Bookmark Sidebar - Bookmark-specific styles (base styles in SidebarBase.css) */ /* Bookmark List */ .bookmark-list { @@ -193,4 +134,3 @@ animation: none; } } - diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/src/core/components/viewer/BookmarkSidebar.tsx index 5c845878b..505ccaca6 100644 --- a/frontend/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/src/core/components/viewer/BookmarkSidebar.tsx @@ -4,6 +4,7 @@ import LocalIcon from '@app/components/shared/LocalIcon'; import { useViewer } from '@app/contexts/ViewerContext'; import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models'; import BookmarksIcon from '@mui/icons-material/BookmarksRounded'; +import '@app/components/viewer/SidebarBase.css'; import '@app/components/viewer/BookmarkSidebar.css'; interface BookmarkSidebarProps { @@ -429,7 +430,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p return ( -
-
- +
+
+ @@ -475,7 +476,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )}
- + - + {!bookmarkSupport && ( -
+
Bookmark support is unavailable for this viewer. @@ -496,7 +497,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )} {bookmarkSupport && showNoDocument && ( -
+
Open a PDF to view its bookmarks. @@ -504,7 +505,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )} {bookmarkSupport && documentCacheKey && currentError && ( - + {currentError} @@ -515,7 +516,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )} {bookmarkSupport && documentCacheKey && isLocalLoading && ( - + Loading bookmarks... @@ -524,7 +525,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )} {showEmptyState && ( -
+
No bookmarks in this document @@ -538,7 +539,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p )} {showSearchEmpty && ( -
+
No bookmarks match your search diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 7655bfc06..6d525be5c 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -10,6 +10,7 @@ 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 { LayerSidebar } from '@app/components/viewer/LayerSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { useRedaction } from '@app/contexts/RedactionContext'; @@ -125,6 +126,8 @@ const EmbedPdfViewerContent = ({ toggleThumbnailSidebar, isBookmarkSidebarVisible, isAttachmentSidebarVisible, + isLayerSidebarVisible, + setHasLayers, isCommentsSidebarVisible, isSearchInterfaceVisible, searchInterfaceActions, @@ -700,6 +703,46 @@ const EmbedPdfViewerContent = ({ return () => window.removeEventListener('formfill:apply', handler); }, [handleFormApply]); + // Apply layer visibility changes - reload the modified PDF into the viewer + const layerApplyInProgressRef = useRef(false); + const handleLayerApply = useCallback(async (modifiedBlob: Blob) => { + if (layerApplyInProgressRef.current) return; + if (!currentFile || activeFileIds.length === 0) return; + + layerApplyInProgressRef.current = true; + try { + const pageToRestore = lastKnownScrollPageRef.current; + const currentRotation = rotationState.rotation ?? 0; + + const filename = currentFile.name || 'document.pdf'; + const file = new File([modifiedBlob], filename, { type: 'application/pdf' }); + + 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'); + + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + + pendingScrollRestoreRef.current = pageToRestore; + scrollRestoreAttemptsRef.current = 0; + pendingRotationRestoreRef.current = currentRotation; + rotationRestoreAttemptsRef.current = 0; + + const newFileId = stubs[0]?.id; + if (newFileId) { + pendingFileIdRef.current = newFileId; + } + + await actions.consumeFiles([currentFileId], stirlingFiles, stubs); + } catch (error) { + console.error('[Viewer] Apply layer changes failed:', error); + } finally { + layerApplyInProgressRef.current = false; + } + }, [currentFile, activeFiles, activeFileIndex, actions, selectors, activeFileIds.length, rotationState.rotation]); + // Discard pending redactions but save already-applied ones // This is called when user clicks "Discard & Leave" - we want to: // 1. NOT commit pending redaction marks (they get discarded) @@ -919,6 +962,7 @@ const EmbedPdfViewerContent = ({ (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0) + + (isLayerSidebarVisible ? sidebarWidthRem : 0) + (isCommentsSidebarVisible ? commentsSidebarWidthRem : 0); return ( @@ -987,7 +1031,7 @@ const EmbedPdfViewerContent = ({ redactionTrackerRef={redactionTrackerRef as React.RefObject} fileId={currentFileId} isCommentsSidebarVisible={isCommentsSidebarVisible} - commentsSidebarRightOffset={`${(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0)}rem`} + commentsSidebarRightOffset={`${(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0) + (isLayerSidebarVisible ? sidebarWidthRem : 0)}rem`} onSignatureAdded={() => { // Handle signature added - for debugging, enable console logs as needed // Future: Handle signature completion @@ -1057,6 +1101,18 @@ const EmbedPdfViewerContent = ({ documentCacheKey={bookmarkCacheKey} preloadCacheKeys={allBookmarkCacheKeys} /> + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LayerSidebar.css b/frontend/src/core/components/viewer/LayerSidebar.css new file mode 100644 index 000000000..66d0d9086 --- /dev/null +++ b/frontend/src/core/components/viewer/LayerSidebar.css @@ -0,0 +1,140 @@ +/* Layer Sidebar - Layer-specific styles (base styles in SidebarBase.css) */ + +/* Header actions (show/hide all buttons) */ +.layer-sidebar__header-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +/* Layer List */ +.layer-list { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +/* Layer Items */ +.layer-item-wrapper { + display: flex; + flex-direction: column; +} + +.layer-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.65rem; + border-radius: 0.5rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + background: transparent; + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; +} + +.layer-item:hover { + background: color-mix(in srgb, var(--text-primary) 6%, var(--bg-toolbar)); + border-color: color-mix(in srgb, var(--text-primary) 14%, var(--border-subtle)); +} + +.layer-item:active { + background: color-mix(in srgb, var(--text-primary) 10%, var(--bg-toolbar)); +} + +.layer-item__expand-btn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + cursor: pointer; + color: var(--text-muted); + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.layer-item__expand-btn:hover { + opacity: 1; +} + +.layer-item__expand-placeholder { + flex-shrink: 0; + width: 1.25rem; +} + +.layer-item__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + line-height: 1.4; + cursor: pointer; + user-select: none; +} + +.layer-item--hidden .layer-item__label { + color: var(--text-muted); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--text-muted) 60%, transparent); +} + +/* Children Container */ +.layer-item__children { + display: flex; + flex-direction: column; + gap: 0.125rem; + margin-top: 0.125rem; + border-left: 2px solid color-mix(in srgb, var(--border-subtle) 60%, transparent); + margin-left: 0.875rem; + padding-left: 0.25rem; +} + +/* Footer with Apply button */ +.layer-sidebar__footer { + padding: 0.625rem 0.875rem; + background: var(--bg-toolbar); + border-top: 1px solid var(--border-subtle); +} + +/* Dirty indicator badge */ +.layer-sidebar__dirty-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--mantine-color-orange-5); + flex-shrink: 0; +} + +/* Animation */ +@keyframes layer-fade-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.layer-item__children { + animation: layer-fade-in 0.15s ease; +} + +/* Accessibility & Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .layer-item, + .layer-item__children { + transition: none; + animation: none; + } +} diff --git a/frontend/src/core/components/viewer/LayerSidebar.tsx b/frontend/src/core/components/viewer/LayerSidebar.tsx new file mode 100644 index 000000000..0234b4931 --- /dev/null +++ b/frontend/src/core/components/viewer/LayerSidebar.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Box, ScrollArea, Text, Checkbox, Stack, Loader, ActionIcon, Tooltip } from '@mantine/core'; +import LayersIcon from '@mui/icons-material/Layers'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import '@app/components/viewer/SidebarBase.css'; +import '@app/components/viewer/LayerSidebar.css'; +import { readPdfLayers, applyOCGVisibilityToPdf, collectLeafIds } from '@app/components/viewer/layerUtils'; + +import type { LayerInfo } from '@app/components/viewer/layerUtils'; +export type { LayerInfo }; + +interface LayerSidebarProps { + visible: boolean; + /** Right offset in rem (how far from the right edge). */ + rightOffset: number; + /** The current PDF file to read layers from. */ + file?: Blob | null; + /** Stable key that changes when the document changes (used to avoid re-fetching). */ + documentCacheKey?: string; + /** Called when the user applies layer visibility changes. Receives the modified PDF blob. */ + onApplyLayers: (modifiedBlob: Blob) => Promise; + /** Called when layer detection completes, reporting whether the PDF has layers. */ + onLayersDetected?: (hasLayers: boolean) => void; +} + +const SIDEBAR_WIDTH = '15rem'; + +type LoadStatus = 'idle' | 'loading' | 'ready' | 'no-layers' | 'error'; + +export function LayerSidebar({ + visible, + rightOffset, + file, + documentCacheKey, + onApplyLayers, + onLayersDetected, +}: LayerSidebarProps) { + const [layers, setLayers] = useState([]); + const [visibility, setVisibility] = useState>({}); + const [status, setStatus] = useState('idle'); + const [loadError, setLoadError] = useState(null); + const [isApplying, setIsApplying] = useState(false); + const [expanded, setExpanded] = useState>({}); + const loadedKeyRef = useRef(null); + // Track whether visibility was set by user interaction (not initial load) + const userChangedRef = useRef(false); + + // Load layers when the document changes + useEffect(() => { + if (!file || !documentCacheKey) { + setStatus('idle'); + setLayers([]); + setVisibility({}); + loadedKeyRef.current = null; + userChangedRef.current = false; + onLayersDetected?.(false); + return; + } + + if (loadedKeyRef.current === documentCacheKey) return; + + setStatus('loading'); + setLoadError(null); + userChangedRef.current = false; + + let cancelled = false; + + readPdfLayers(file) + .then(layerList => { + if (cancelled) return; + + if (layerList.length === 0) { + setStatus('no-layers'); + setLayers([]); + setVisibility({}); + loadedKeyRef.current = documentCacheKey; + onLayersDetected?.(false); + return; + } + + // Build visibility map from the layer defaults (all leaf IDs) + const visMap: Record = {}; + const populateVisibility = (items: LayerInfo[]) => { + for (const item of items) { + if (item.children && item.children.length > 0) { + populateVisibility(item.children); + } else { + visMap[item.id] = item.visible; + } + } + }; + populateVisibility(layerList); + + setLayers(layerList); + setVisibility(visMap); + setStatus('ready'); + loadedKeyRef.current = documentCacheKey; + onLayersDetected?.(true); + }) + .catch(err => { + if (cancelled) return; + setStatus('error'); + setLoadError(err instanceof Error ? err.message : 'Failed to read PDF layers'); + onLayersDetected?.(false); + }); + + return () => { + cancelled = true; + }; + }, [file, documentCacheKey, onLayersDetected]); + + // Reset when document changes + useEffect(() => { + setExpanded({}); + userChangedRef.current = false; + }, [documentCacheKey]); + + // Auto-apply: debounce visibility changes from user interaction + useEffect(() => { + if (!userChangedRef.current || !file || isApplying || layers.length === 0) return; + + const timer = setTimeout(async () => { + setIsApplying(true); + try { + const nameVisibility: Record = {}; + const collectNames = (items: LayerInfo[]) => { + for (const item of items) { + if (item.children && item.children.length > 0) { + collectNames(item.children); + } else { + nameVisibility[item.name] = visibility[item.id] ?? item.visible; + } + } + }; + collectNames(layers); + + const arrayBuffer = await file.arrayBuffer(); + const modifiedBytes = await applyOCGVisibilityToPdf(arrayBuffer, nameVisibility); + const blob = new Blob([new Uint8Array(modifiedBytes)], { type: 'application/pdf' }); + + await onApplyLayers(blob); + } catch (err) { + console.error('[LayerSidebar] Failed to apply layer changes:', err); + } finally { + setIsApplying(false); + userChangedRef.current = false; + } + }, 300); + + return () => clearTimeout(timer); + }, [visibility, file, layers, isApplying, onApplyLayers]); + + const toggleLayerVisibility = useCallback((id: string) => { + userChangedRef.current = true; + setVisibility(prev => ({ ...prev, [id]: !prev[id] })); + }, []); + + const showAll = useCallback(() => { + userChangedRef.current = true; + setVisibility(prev => { + const updated: Record = {}; + for (const id of Object.keys(prev)) { + updated[id] = true; + } + return updated; + }); + }, []); + + const hideAll = useCallback(() => { + userChangedRef.current = true; + setVisibility(prev => { + const updated: Record = {}; + for (const id of Object.keys(prev)) { + updated[id] = false; + } + return updated; + }); + }, []); + + const toggleExpanded = useCallback((id: string) => { + setExpanded(prev => ({ ...prev, [id]: !(prev[id] ?? true) })); + }, []); + + const renderLayer = (layer: LayerInfo & { depth: number }) => { + const hasChildren = Boolean(layer.children && layer.children.length > 0); + const isExpanded = expanded[layer.id] !== false; // default expanded + const isLeaf = !hasChildren; + + const isVisible = isLeaf + ? visibility[layer.id] ?? layer.visible + : collectLeafIds(layer.children ?? []).every(id => visibility[id] ?? true); + + return ( +
0 ? `${layer.depth * 0.875}rem` : '0' }} + > +
{ + if (hasChildren) { + toggleExpanded(layer.id); + } else { + toggleLayerVisibility(layer.id); + } + }} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (hasChildren) { + toggleExpanded(layer.id); + } else { + toggleLayerVisibility(layer.id); + } + } + }} + > + {hasChildren ? ( + { + e.stopPropagation(); + toggleExpanded(layer.id); + }} + > + {isExpanded ? ( + + + + ) : ( + + + + )} + + ) : ( + + )} + + {isLeaf && ( + toggleLayerVisibility(layer.id)} + onClick={e => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + )} + + + + {layer.name} + + +
+ + {hasChildren && isExpanded && ( +
+ {(layer.children ?? []).map(child => renderLayer({ ...child, depth: 0 }))} +
+ )} +
+ ); + }; + + if (!visible) return null; + + const leafIds = collectLeafIds(layers); + const allVisible = leafIds.every(id => visibility[id] !== false); + const allHidden = leafIds.every(id => visibility[id] === false); + + return ( + + {/* Header */} +
+
+ + + + + Layers + + {isApplying && } +
+ + {status === 'ready' && leafIds.length > 0 && ( +
+ + + + + + +
+ )} +
+ + {/* Content */} + + + {status === 'idle' && ( +
+ + Open a PDF to view its layers. + +
+ )} + + {status === 'loading' && ( + + + + Loading layers... + + + )} + + {status === 'error' && ( +
+ + {loadError ?? 'Failed to load layers.'} + +
+ )} + + {status === 'no-layers' && ( +
+ + This document has no layers. + +
+ )} + + {status === 'ready' && layers.length > 0 && ( +
+ {layers.map(layer => renderLayer({ ...layer, depth: 0 }))} +
+ )} +
+
+ +
+ ); +} diff --git a/frontend/src/core/components/viewer/SidebarBase.css b/frontend/src/core/components/viewer/SidebarBase.css new file mode 100644 index 000000000..7eef71965 --- /dev/null +++ b/frontend/src/core/components/viewer/SidebarBase.css @@ -0,0 +1,61 @@ +/* Shared Sidebar Base - Common styles for all viewer sidebars */ + +/* Container */ +.sidebar-base { + 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 */ +.sidebar-base__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); +} + +.sidebar-base__header-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sidebar-base__header-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-blue-6); + font-size: 1.1rem; +} + +/* Search Section (used by bookmark & attachment sidebars) */ +.sidebar-base__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 */ +.sidebar-base__content { + padding-top: 0.5rem !important; +} + +.sidebar-base__empty-state, +.sidebar-base__error { + padding: 2rem 1rem; + text-align: center; +} + +.sidebar-base__loading { + padding: 2rem 1rem !important; +} diff --git a/frontend/src/core/components/viewer/layerUtils.ts b/frontend/src/core/components/viewer/layerUtils.ts new file mode 100644 index 000000000..d7923b3d4 --- /dev/null +++ b/frontend/src/core/components/viewer/layerUtils.ts @@ -0,0 +1,228 @@ +export interface LayerInfo { + id: string; + name: string; + visible: boolean; + children?: LayerInfo[]; +} + +/** + * Reads OCG layer info from a PDF file using pdfjs-dist. + * Returns a flat list of all OCG groups with their names and default visibility. + */ +export async function readPdfLayers(file: Blob): Promise { + const { getDocument, GlobalWorkerOptions } = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + if (!GlobalWorkerOptions.workerSrc) { + GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/legacy/build/pdf.worker.min.mjs', + import.meta.url + ).toString(); + } + + const arrayBuffer = await file.arrayBuffer(); + const loadingTask = getDocument({ data: arrayBuffer, verbosity: 0 }); + const pdfDoc = await loadingTask.promise; + + try { + const ocConfig = await pdfDoc.getOptionalContentConfig(); + + if (!ocConfig) return []; + + // pdfjs v5 uses [Symbol.iterator] and getGroup(id), not getGroups() + const groups: Record = {}; + for (const [id, group] of ocConfig as any) { + groups[id] = group; + } + if (Object.keys(groups).length === 0) return []; + + // Use getOrder() for hierarchical display + let order: any[] | null = null; + try { + order = ocConfig.getOrder?.() ?? null; + } catch { + // getOrder not available + } + + if (order && Array.isArray(order) && order.length > 0) { + return buildLayerTree(order, groups); + } + + // Fallback: flat list in enumeration order + return Object.entries(groups).map(([id, group]) => ({ + id, + name: (group as any).name ?? id, + visible: (group as any).visible ?? true, + })); + } finally { + await pdfDoc.destroy(); + } +} + +/** + * Recursively builds a LayerInfo tree from pdfjs OCG order array. + * The order array can contain: + * - string: an OCG id + * - { name: string, order: any[] }: a named group with children + * - array: a nested group + */ +function buildLayerTree( + order: any[], + groups: Record, + visited = new Set() +): LayerInfo[] { + const result: LayerInfo[] = []; + + for (const item of order) { + if (typeof item === 'string') { + // It's an OCG id + if (visited.has(item)) continue; + visited.add(item); + const group = groups[item]; + if (group) { + result.push({ + id: item, + name: (group as any).name ?? item, + visible: (group as any).visible ?? true, + }); + } + } else if (Array.isArray(item)) { + // Nested group (unlabeled) + const children = buildLayerTree(item, groups, visited); + result.push(...children); + } else if (item && typeof item === 'object') { + // Named group with nested items + const { name, order: subOrder } = item as { name?: string; order?: any[] }; + const children = subOrder ? buildLayerTree(subOrder, groups, visited) : []; + if (name && children.length > 0) { + // Use the first child's id as a synthetic group id + result.push({ + id: `group-${name}`, + name: name, + visible: children.every(c => c.visible), + children, + }); + } else { + result.push(...children); + } + } + } + + return result; +} + +/** + * Modifies OCG visibility in a PDF using @cantoo/pdf-lib. + * Accepts a flat map of { layerName -> visible } and rewrites the /D config. + * + * Strategy: set /BaseState to /OFF and only list visible layers in /ON. + * This is the most unambiguous approach and avoids conflicts between + * /BaseState, /ON, and /OFF that can confuse some viewers. + * Also removes /AS (auto-state) entries that can override visibility. + */ +export async function applyOCGVisibilityToPdf( + pdfBytes: ArrayBuffer, + layerVisibility: Record +): Promise { + const { PDFDocument, PDFDict, PDFName, PDFArray, PDFString, PDFHexString } = + await import('@cantoo/pdf-lib'); + + const doc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); + const context = doc.context; + + // Access the catalog via the trailer's Root reference + const catalogRef = context.trailerInfo.Root; + const catalog = context.lookup(catalogRef) as unknown as typeof PDFDict.prototype; + + // Get OCProperties dict (may be a direct dict or an indirect reference) + const ocPropsRaw = (catalog as any).lookup(PDFName.of('OCProperties')); + if (!ocPropsRaw) { + return doc.save(); + } + const ocProps = (ocPropsRaw instanceof PDFDict + ? ocPropsRaw + : context.lookup(ocPropsRaw)) as unknown as typeof PDFDict.prototype; + + // Get the /OCGs array + const ocgsRaw = (ocProps as any).lookup(PDFName.of('OCGs')); + if (!(ocgsRaw instanceof PDFArray)) { + return doc.save(); + } + const ocgsArray = ocgsRaw as unknown as typeof PDFArray.prototype; + + // Get or create the /D (default config) dict + const dRaw = (ocProps as any).lookup(PDFName.of('D')); + if (!dRaw) { + return doc.save(); + } + const dDict = (dRaw instanceof PDFDict + ? dRaw + : context.lookup(dRaw)) as unknown as typeof PDFDict.prototype; + + // Collect OCG refs for ON vs OFF based on user visibility settings + const onRefs: any[] = []; + const offRefs: any[] = []; + + const size = (ocgsArray as any).size() as number; + for (let i = 0; i < size; i++) { + const ocgRef = (ocgsArray as any).get(i); + const ocgDict = context.lookup(ocgRef) as unknown as typeof PDFDict.prototype; + if (!ocgDict) continue; + + // Get the OCG name + const nameRaw = (ocgDict as any).lookup(PDFName.of('Name')); + let ocgName = ''; + if (nameRaw instanceof PDFString || nameRaw instanceof PDFHexString) { + ocgName = (nameRaw as any).decodeText?.() ?? (nameRaw as any).asString?.() ?? ''; + } else if (nameRaw) { + ocgName = String(nameRaw); + } + + // Look up visibility by name + const shouldBeVisible = layerVisibility[ocgName] ?? true; + + if (shouldBeVisible) { + onRefs.push(ocgRef); + } else { + offRefs.push(ocgRef); + } + } + + // Set /BaseState to /OFF so all layers start hidden, then /ON lists visible ones. + // This is unambiguous and avoids conflicts between /BaseState and /ON//OFF. + (dDict as any).set(PDFName.of('BaseState'), PDFName.of('OFF')); + + // Set /ON to only the visible layers + if (onRefs.length > 0) { + (dDict as any).set(PDFName.of('ON'), context.obj(onRefs)); + } else { + (dDict as any).delete?.(PDFName.of('ON')); + } + + // Set /OFF to only the hidden layers (for viewers that check it) + if (offRefs.length > 0) { + (dDict as any).set(PDFName.of('OFF'), context.obj(offRefs)); + } else { + (dDict as any).delete?.(PDFName.of('OFF')); + } + + // Remove /AS (auto-state) array — it can contain usage-based overrides + // (e.g., print vs view) that conflict with our explicit visibility settings. + (dDict as any).delete?.(PDFName.of('AS')); + + return doc.save(); +} + +/** + * Collects all leaf-level layer IDs (those that are actual OCGs, not synthetic groups). + */ +export function collectLeafIds(layers: LayerInfo[]): string[] { + const ids: string[] = []; + for (const layer of layers) { + if (layer.children && layer.children.length > 0) { + ids.push(...collectLeafIds(layer.children)); + } else { + ids.push(layer.id); + } + } + return ids; +} diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index e9ef2ee3d..1e20542a6 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -16,6 +16,7 @@ import { BASE_PATH, withBasePath } from '@app/constants/app'; import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; import TextFieldsIcon from '@mui/icons-material/TextFields'; import StraightenIcon from '@mui/icons-material/Straighten'; +import LayersIcon from '@mui/icons-material/Layers'; import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import StopIcon from '@mui/icons-material/Stop'; import { useViewerReadAloud } from '@app/components/viewer/useViewerReadAloud'; @@ -26,7 +27,7 @@ export function useViewerRightRailButtons( ) { const { t, i18n } = useTranslation(); const viewer = useViewer(); - const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isCommentsSidebarVisible, toggleCommentsSidebar, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer; + const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isLayerSidebarVisible, hasLayers, isCommentsSidebarVisible, toggleCommentsSidebar, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer; const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12); @@ -83,6 +84,7 @@ export function useViewerRightRailButtons( const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks'); const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments'); + const layersLabel = t('rightRail.toggleLayers', 'Toggle Layers'); const commentsLabel = t('rightRail.toggleComments', 'Comments'); const printLabel = t('rightRail.print', 'Print PDF'); const annotationsLabel = t('rightRail.annotations', 'Annotations'); @@ -236,6 +238,18 @@ export function useViewerRightRailButtons( viewer.toggleAttachmentSidebar(); } }, + ...(hasLayers ? [{ + id: 'viewer-toggle-layers', + icon: , + tooltip: layersLabel, + ariaLabel: layersLabel, + section: 'top' as const, + order: 56.3, + active: isLayerSidebarVisible, + onClick: () => { + viewer.toggleLayerSidebar(); + } + }] : []), { id: 'viewer-toggle-comments', icon: , @@ -424,6 +438,8 @@ export function useViewerRightRailButtons( isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, + isLayerSidebarVisible, + hasLayers, isSearchInterfaceVisible, isPanning, searchLabel, @@ -434,6 +450,7 @@ export function useViewerRightRailButtons( sidebarLabel, bookmarkLabel, attachmentLabel, + layersLabel, printLabel, tooltipPosition, annotationsLabel, diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index bf80ce08b..566288c84 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -86,6 +86,10 @@ export interface ViewerContextType { toggleBookmarkSidebar: () => void; isAttachmentSidebarVisible: boolean; toggleAttachmentSidebar: () => void; + isLayerSidebarVisible: boolean; + toggleLayerSidebar: () => void; + hasLayers: boolean; + setHasLayers: (value: boolean) => void; isCommentsSidebarVisible: boolean; setCommentsSidebarVisible: (visible: boolean) => void; toggleCommentsSidebar: () => void; @@ -188,6 +192,8 @@ export const ViewerProvider: React.FC = ({ children }) => { const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false); + const [isLayerSidebarVisible, setIsLayerSidebarVisible] = useState(false); + const [hasLayers, setHasLayers] = useState(false); const [isCommentsSidebarVisible, setIsCommentsSidebarVisible] = useState(false); const [highlightCommentRequest, setHighlightCommentRequest] = useState<{ documentId: string; @@ -289,6 +295,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsAttachmentSidebarVisible(prev => !prev); }; + const toggleLayerSidebar = () => { + setIsLayerSidebarVisible(prev => !prev); + }; + const setCommentsSidebarVisible = (visible: boolean) => { setIsCommentsSidebarVisible(visible); }; @@ -454,6 +464,10 @@ export const ViewerProvider: React.FC = ({ children }) => { toggleBookmarkSidebar, isAttachmentSidebarVisible, toggleAttachmentSidebar, + isLayerSidebarVisible, + toggleLayerSidebar, + hasLayers, + setHasLayers, isCommentsSidebarVisible, setCommentsSidebarVisible, toggleCommentsSidebar,