pdf layer toggle (#6028)

This commit is contained in:
Anthony Stirling
2026-03-30 17:04:53 +01:00
committed by GitHub
parent cdc288e78d
commit a06b6a4bac
12 changed files with 924 additions and 147 deletions
@@ -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"
@@ -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 {
@@ -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 (
<Box
className="attachment-sidebar"
className="sidebar-base attachment-sidebar"
style={{
position: 'fixed',
right: `${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`,
@@ -294,9 +295,9 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
zIndex: 998,
}}
>
<div className="attachment-sidebar__header">
<div className="attachment-sidebar__header-title">
<span className="attachment-sidebar__header-icon">
<div className="sidebar-base__header attachment-sidebar__header">
<div className="sidebar-base__header-title attachment-sidebar__header-title">
<span className="sidebar-base__header-icon attachment-sidebar__header-icon">
<AttachmentIcon />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
@@ -305,7 +306,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
</div>
</div>
<Box px="sm" pb="sm" className="attachment-sidebar__search">
<Box px="sm" pb="sm" className="sidebar-base__search attachment-sidebar__search">
<TextInput
value={searchTerm}
placeholder={t('viewer.attachments.searchPlaceholder', 'Search attachments')}
@@ -316,9 +317,9 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
</Box>
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="attachment-sidebar__content">
<Box p="sm" className="sidebar-base__content attachment-sidebar__content">
{!attachmentSupport && (
<div className="attachment-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
{t('viewer.attachments.noSupport', 'Attachment support is unavailable for this viewer.')}
</Text>
@@ -326,7 +327,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
)}
{attachmentSupport && showNoDocument && (
<div className="attachment-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
{t('viewer.attachments.noDocument', 'Open a PDF to view its attachments.')}
</Text>
@@ -334,7 +335,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
)}
{attachmentSupport && documentCacheKey && currentError && (
<Stack gap="xs" align="center" className="attachment-sidebar__error">
<Stack gap="xs" align="center" className="sidebar-base__error">
<Text size="sm" c="red" ta="center">
{currentError}
</Text>
@@ -345,7 +346,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
)}
{attachmentSupport && documentCacheKey && isLocalLoading && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="attachment-sidebar__loading">
<Stack gap="md" align="center" c="dimmed" py="xl" className="sidebar-base__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
{t('viewer.attachments.loading', 'Loading attachments...')}
@@ -354,7 +355,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
)}
{showEmptyState && (
<div className="attachment-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
{t('viewer.attachments.empty', 'No attachments in this document')}
</Text>
@@ -368,7 +369,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible,
)}
{showSearchEmpty && (
<div className="attachment-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
{t('viewer.attachments.noMatch', 'No attachments match your search')}
</Text>
@@ -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;
}
}
@@ -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 (
<Box
className="bookmark-sidebar"
className="sidebar-base bookmark-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
@@ -439,9 +440,9 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
zIndex: 998,
}}
>
<div className="bookmark-sidebar__header">
<div className="bookmark-sidebar__header-title">
<span className="bookmark-sidebar__header-icon">
<div className="sidebar-base__header bookmark-sidebar__header">
<div className="sidebar-base__header-title bookmark-sidebar__header-title">
<span className="sidebar-base__header-icon bookmark-sidebar__header-icon">
<BookmarksIcon />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
@@ -475,7 +476,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
</div>
<Box px="sm" pb="sm" className="bookmark-sidebar__search">
<Box px="sm" pb="sm" className="sidebar-base__search bookmark-sidebar__search">
<TextInput
value={searchTerm}
placeholder="Search bookmarks"
@@ -486,9 +487,9 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
</Box>
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="bookmark-sidebar__content">
<Box p="sm" className="sidebar-base__content bookmark-sidebar__content">
{!bookmarkSupport && (
<div className="bookmark-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
Bookmark support is unavailable for this viewer.
</Text>
@@ -496,7 +497,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
{bookmarkSupport && showNoDocument && (
<div className="bookmark-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
Open a PDF to view its bookmarks.
</Text>
@@ -504,7 +505,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
{bookmarkSupport && documentCacheKey && currentError && (
<Stack gap="xs" align="center" className="bookmark-sidebar__error">
<Stack gap="xs" align="center" className="sidebar-base__error">
<Text size="sm" c="red" ta="center">
{currentError}
</Text>
@@ -515,7 +516,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
{bookmarkSupport && documentCacheKey && isLocalLoading && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="bookmark-sidebar__loading">
<Stack gap="md" align="center" c="dimmed" py="xl" className="sidebar-base__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
Loading bookmarks...
@@ -524,7 +525,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
{showEmptyState && (
<div className="bookmark-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks in this document
</Text>
@@ -538,7 +539,7 @@ export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, p
)}
{showSearchEmpty && (
<div className="bookmark-sidebar__empty-state">
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks match your search
</Text>
@@ -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<RedactionPendingTrackerAPI>}
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}
/>
<LayerSidebar
visible={isLayerSidebarVisible}
rightOffset={
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
(isBookmarkSidebarVisible ? sidebarWidthRem : 0) +
(isAttachmentSidebarVisible ? sidebarWidthRem : 0)
}
file={effectiveFile?.file ?? null}
documentCacheKey={bookmarkCacheKey}
onApplyLayers={handleLayerApply}
onLayersDetected={setHasLayers}
/>
{/* Navigation Warning Modal */}
{!previewFile && (
@@ -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;
}
}
@@ -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<void>;
/** 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<LayerInfo[]>([]);
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
const [status, setStatus] = useState<LoadStatus>('idle');
const [loadError, setLoadError] = useState<string | null>(null);
const [isApplying, setIsApplying] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const loadedKeyRef = useRef<string | null>(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<string, boolean> = {};
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<string, boolean> = {};
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<string, boolean> = {};
for (const id of Object.keys(prev)) {
updated[id] = true;
}
return updated;
});
}, []);
const hideAll = useCallback(() => {
userChangedRef.current = true;
setVisibility(prev => {
const updated: Record<string, boolean> = {};
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 (
<div
key={layer.id}
className="layer-item-wrapper"
style={{ marginLeft: layer.depth > 0 ? `${layer.depth * 0.875}rem` : '0' }}
>
<div
className={`layer-item ${!isVisible ? 'layer-item--hidden' : ''}`}
onClick={() => {
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 ? (
<span
className="layer-item__expand-btn"
onClick={e => {
e.stopPropagation();
toggleExpanded(layer.id);
}}
>
{isExpanded ? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 4l4 4 4-4z" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4z" />
</svg>
)}
</span>
) : (
<span className="layer-item__expand-placeholder" />
)}
{isLeaf && (
<Checkbox
size="xs"
checked={visibility[layer.id] ?? layer.visible}
onChange={() => toggleLayerVisibility(layer.id)}
onClick={e => e.stopPropagation()}
style={{ flexShrink: 0 }}
/>
)}
<Tooltip
label={layer.name}
position="left"
withinPortal
disabled={layer.name.length < 20}
>
<span className="layer-item__label">
{layer.name}
</span>
</Tooltip>
</div>
{hasChildren && isExpanded && (
<div className="layer-item__children">
{(layer.children ?? []).map(child => renderLayer({ ...child, depth: 0 }))}
</div>
)}
</div>
);
};
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 (
<Box
className="sidebar-base layer-sidebar"
style={{
position: 'fixed',
right: `${rightOffset}rem`,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
zIndex: 998,
}}
>
{/* Header */}
<div className="sidebar-base__header">
<div className="sidebar-base__header-title">
<span className="sidebar-base__header-icon">
<LayersIcon fontSize="small" />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5} style={{ flex: 1 }}>
Layers
</Text>
{isApplying && <Loader size="xs" type="dots" />}
</div>
{status === 'ready' && leafIds.length > 0 && (
<div className="layer-sidebar__header-actions">
<ActionIcon
variant="subtle"
size="sm"
onClick={showAll}
disabled={allVisible || isApplying}
aria-label="Show all layers"
title="Show all"
>
<VisibilityIcon sx={{ fontSize: '1rem' }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label="Hide all layers"
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: '1rem' }} />
</ActionIcon>
</div>
)}
</div>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="sidebar-base__content">
{status === 'idle' && (
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
Open a PDF to view its layers.
</Text>
</div>
)}
{status === 'loading' && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="sidebar-base__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
Loading layers...
</Text>
</Stack>
)}
{status === 'error' && (
<div className="sidebar-base__error">
<Text size="sm" c="red" ta="center">
{loadError ?? 'Failed to load layers.'}
</Text>
</div>
)}
{status === 'no-layers' && (
<div className="sidebar-base__empty-state">
<Text size="sm" c="dimmed" ta="center">
This document has no layers.
</Text>
</div>
)}
{status === 'ready' && layers.length > 0 && (
<div className="layer-list">
{layers.map(layer => renderLayer({ ...layer, depth: 0 }))}
</div>
)}
</Box>
</ScrollArea>
</Box>
);
}
@@ -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;
}
@@ -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<LayerInfo[]> {
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<string, any> = {};
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<string, any>,
visited = new Set<string>()
): 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<string, boolean>
): Promise<Uint8Array> {
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;
}
@@ -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<boolean>(() => 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: <LayersIcon sx={{ fontSize: '1.5rem' }} />,
tooltip: layersLabel,
ariaLabel: layersLabel,
section: 'top' as const,
order: 56.3,
active: isLayerSidebarVisible,
onClick: () => {
viewer.toggleLayerSidebar();
}
}] : []),
{
id: 'viewer-toggle-comments',
icon: <LocalIcon icon="comment" width="1.5rem" height="1.5rem" />,
@@ -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,
@@ -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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
setIsAttachmentSidebarVisible(prev => !prev);
};
const toggleLayerSidebar = () => {
setIsLayerSidebarVisible(prev => !prev);
};
const setCommentsSidebarVisible = (visible: boolean) => {
setIsCommentsSidebarVisible(visible);
};
@@ -454,6 +464,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleBookmarkSidebar,
isAttachmentSidebarVisible,
toggleAttachmentSidebar,
isLayerSidebarVisible,
toggleLayerSidebar,
hasLayers,
setHasLayers,
isCommentsSidebarVisible,
setCommentsSidebarVisible,
toggleCommentsSidebar,