mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
pdf layer toggle (#6028)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user