Compare commits

...
21 changed files with 2155 additions and 68 deletions
+26
View File
@@ -58,6 +58,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"modern-screenshot": "^4.4.39",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
@@ -10538,6 +10539,31 @@
"ufo": "^1.6.1"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/modern-screenshot": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.7.tgz",
"integrity": "sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==",
"license": "MIT"
},
"node_modules/module-definition": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz",
+1
View File
@@ -50,6 +50,7 @@
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^17.0.0",
"modern-screenshot": "^4.4.39",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
@@ -399,3 +399,4 @@
font-size: 0.875rem;
opacity: 0.8;
}
@@ -5,6 +5,7 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { zipFileService } from '@app/services/zipFileService';
import { detectFileExtension } from '@app/utils/fileUtils';
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
@@ -18,15 +19,15 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
interface FileEditorProps {
onOpenPageEditor?: () => void;
onMergeFiles?: (files: StirlingFile[]) => void;
onOpenViewer?: (fileId: FileId, sourceRect?: DOMRect) => void;
toolMode?: boolean;
supportedExtensions?: string[];
}
const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"]
supportedExtensions = ["pdf"],
onOpenViewer
}: FileEditorProps) => {
// Utility function to check if a file extension is supported
@@ -54,6 +55,9 @@ const FileEditor = ({
// Get file selection context
const { setSelectedFiles } = useFileSelection();
// Get viewer context for active file index
const { setActiveFileIndex } = useViewer();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
@@ -66,6 +70,7 @@ const FileEditor = ({
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
@@ -328,14 +333,28 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const handleViewFile = useCallback(async (fileId: FileId, sourceElement?: HTMLElement) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
// Find the index of the clicked file
const fileIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
// Set the file as selected and active
setSelectedFiles([fileId]);
navActions.setWorkbench('viewer');
if (fileIndex !== -1) {
setActiveFileIndex(fileIndex);
}
const sourceRect = sourceElement?.getBoundingClientRect();
// Switch to viewer - pass fileId and sourceRect (parent handles fallbacks)
if (onOpenViewer) {
onOpenViewer(fileId, sourceRect);
} else {
navActions.setWorkbench('viewer');
}
}
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions, onOpenViewer]);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
@@ -364,7 +383,12 @@ const FileEditor = ({
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" style={{ overflow: 'auto' }}>
<Box
pos="relative"
style={{
overflow: 'auto',
}}
>
<LoadingOverlay visible={state.ui.isProcessing} />
<Box p="md">
@@ -35,7 +35,7 @@ interface FileEditorThumbnailProps {
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId, sourceElement?: HTMLElement) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
@@ -92,6 +92,7 @@ const FileEditorThumbnail = ({
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
const handleRef = useRef<HTMLSpanElement | null>(null);
const thumbnailRef = useRef<HTMLImageElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
@@ -203,7 +204,7 @@ const FileEditorThumbnail = ({
label: t('openInViewer', 'Open in Viewer'),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
onViewFile(file.id, thumbnailRef.current || undefined);
},
},
{
@@ -253,7 +254,7 @@ const FileEditorThumbnail = ({
const handleCardDoubleClick = () => {
if (!isSupported) return;
onViewFile(file.id);
onViewFile(file.id, thumbnailRef.current || undefined);
};
// ---- Style helpers ----
@@ -392,6 +393,7 @@ const FileEditorThumbnail = ({
{file.thumbnailUrl ? (
<PrivateContent>
<img
ref={thumbnailRef}
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
@@ -412,6 +414,7 @@ const FileEditorThumbnail = ({
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
justifySelf: 'center',
alignSelf: 'start'
}}
/>
@@ -19,3 +19,21 @@
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-5);
}
@keyframes :global(fadeIn) {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes :global(fadeOut) {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
+223 -22
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -8,6 +8,11 @@ import { useNavigationState, useNavigationActions, useNavigationGuard } from '@a
import { isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { FileId } from '@app/types/fileContext';
import { VIEWER_TRANSITION } from '@app/constants/animations';
import { captureElementScreenshot } from '@app/utils/screenshot';
import { useViewerTransition } from '@app/hooks/useViewerTransition';
import { usePageEditorTransition } from '@app/hooks/usePageEditorTransition';
import styles from '@app/components/layout/Workbench.module.css';
import TopControls from '@app/components/shared/TopControls';
@@ -18,6 +23,10 @@ import Viewer from '@app/components/viewer/Viewer';
import LandingPage from '@app/components/shared/LandingPage';
import Footer from '@app/components/shared/Footer';
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
import { ViewerZoomTransition } from '@app/components/viewer/ViewerZoomTransition';
import { PageEditorSpreadTransition } from '@app/components/pageEditor/PageEditorSpreadTransition';
const MAX_PAGE_EDITOR_SCREENSHOT_PAGES = 24;
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -26,10 +35,12 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
const { workbench: currentView, viewerTransition } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = selectors.getFiles();
// Ref for capturing screenshot during TopControls transitions
const mainContentRef = useRef<HTMLDivElement>(null);
const {
previewFile,
pageEditorFunctions,
@@ -52,6 +63,8 @@ export default function Workbench() {
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
const activeFileId = activeFiles[activeFileIndex]?.fileId;
const lastViewerScreenshotRef = useRef<string | null>(null);
// Get navigation guard for unsaved changes check when switching files
const { requestNavigation } = useNavigationGuard();
@@ -82,11 +95,196 @@ export default function Workbench() {
handleToolSelect('convert');
sessionStorage.removeItem('previousMode');
} else {
setCurrentView('fileEditor');
navActions.setWorkbench('fileEditor');
}
};
const buildPageEditorFilter = useCallback((root: HTMLElement) => {
const rootRect = root.getBoundingClientRect();
const margin = 100;
const isRectVisible = (rect: DOMRect) =>
rect.bottom >= rootRect.top - margin &&
rect.right >= rootRect.left - margin &&
rect.top <= rootRect.bottom + margin &&
rect.left <= rootRect.right + margin;
const pageElements = Array.from(root.querySelectorAll('[data-page-id]')) as HTMLElement[];
const visiblePages = pageElements.filter((page) => isRectVisible(page.getBoundingClientRect()));
const allowedPages = new Set(visiblePages.slice(0, MAX_PAGE_EDITOR_SCREENSHOT_PAGES));
return (node: Node) => {
if (node === root) return true;
if (!(node instanceof Element)) return true;
const pageElement = node.closest('[data-page-id]') as HTMLElement | null;
if (pageElement) {
return allowedPages.has(pageElement);
}
return isRectVisible(node.getBoundingClientRect());
};
}, []);
// Capture screenshot helper for TopControls transitions
const captureMainContentScreenshot = useCallback(async (): Promise<string | null> => {
const root = mainContentRef.current;
if (!root) return null;
if (currentView === 'pageEditor') {
const rect = root.getBoundingClientRect();
const filter = buildPageEditorFilter(root);
return captureElementScreenshot(root, {
filter,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
restoreScrollPosition: false,
});
}
return captureElementScreenshot(root);
}, [currentView, buildPageEditorFilter]);
const capturePageEditorScreenshot = useCallback(async (): Promise<string | null> => {
const root = mainContentRef.current;
if (!root) return null;
const rootRect = root.getBoundingClientRect();
const filter = buildPageEditorFilter(root);
return captureElementScreenshot(root, {
filter,
width: Math.max(1, Math.round(rootRect.width)),
height: Math.max(1, Math.round(rootRect.height)),
restoreScrollPosition: false,
});
}, [buildPageEditorFilter]);
const getMainContentRect = useCallback((): DOMRect | null => {
const root = mainContentRef.current;
return root ? root.getBoundingClientRect() : null;
}, []);
useEffect(() => {
const currentUrl = viewerTransition.editorScreenshotUrl;
const previousUrl = lastViewerScreenshotRef.current;
if (currentUrl && currentUrl !== previousUrl) {
if (previousUrl?.startsWith('blob:')) {
URL.revokeObjectURL(previousUrl);
}
lastViewerScreenshotRef.current = currentUrl;
}
if (!viewerTransition.isAnimating && lastViewerScreenshotRef.current) {
const urlToRevoke = lastViewerScreenshotRef.current;
if (urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
lastViewerScreenshotRef.current = null;
}
return () => {
if (lastViewerScreenshotRef.current) {
const urlToRevoke = lastViewerScreenshotRef.current;
if (urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
lastViewerScreenshotRef.current = null;
}
};
}, [viewerTransition.editorScreenshotUrl, viewerTransition.isAnimating]);
// Get transition handlers
const { handleEntryTransition, handleExitTransition } = useViewerTransition({
activeFileIndex,
currentView,
captureScreenshot: captureMainContentScreenshot,
getScreenshotRect: getMainContentRect,
});
// Get page editor transition handlers
const { handleEntryTransition: handlePageEditorEntry, handleExitTransition: handlePageEditorExit } = usePageEditorTransition({
currentView,
captureScreenshot: capturePageEditorScreenshot,
activeFileId,
getScreenshotRect: getMainContentRect,
});
// Wrapper for setCurrentView that adds transition when switching to/from viewer
const setCurrentView = useCallback(async (view: typeof currentView, fileId?: FileId, sourceRect?: DOMRect) => {
// Handle entry transition (fileEditor/pageEditor → viewer)
if (view === 'viewer' && (currentView === 'fileEditor' || currentView === 'pageEditor')) {
await handleEntryTransition(fileId, sourceRect);
}
// Handle exit transition (viewer → fileEditor/pageEditor)
if ((view === 'fileEditor' || view === 'pageEditor') && currentView === 'viewer') {
handleExitTransition(view);
}
// Handle page editor entry (fileEditor → pageEditor)
if (view === 'pageEditor' && (currentView === 'fileEditor' || currentView === 'viewer')) {
await handlePageEditorEntry(currentView === 'viewer' ? { sourceFileId: activeFileId } : undefined);
}
// Handle page editor exit (pageEditor → fileEditor)
if (view === 'fileEditor' && currentView === 'pageEditor') {
handlePageEditorExit();
}
navActions.setWorkbench(view);
}, [currentView, navActions, handleEntryTransition, handleExitTransition, handlePageEditorEntry, handlePageEditorExit, activeFileId]);
const renderMainContent = () => {
// During viewer transition with screenshot, show screenshot overlay
if (viewerTransition.isAnimating && viewerTransition.editorScreenshotUrl) {
const viewerContent = (
<Viewer
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
);
// Screenshot fades out when zoom starts
const screenshotRect = viewerTransition.editorScreenshotRect;
const screenshotOverlay = (
<div
style={{
position: screenshotRect ? 'fixed' : 'absolute',
top: screenshotRect ? `${screenshotRect.top}px` : 0,
left: screenshotRect ? `${screenshotRect.left}px` : 0,
width: screenshotRect ? `${screenshotRect.width}px` : window.innerWidth,
height: screenshotRect ? `${screenshotRect.height}px` : window.innerHeight,
opacity: viewerTransition.isZooming ? 0 : 1,
transition: viewerTransition.isZooming
? `opacity ${VIEWER_TRANSITION.SCREENSHOT_FADE_DURATION}ms ease-out`
: 'none',
pointerEvents: 'none',
}}
>
<img
src={viewerTransition.editorScreenshotUrl}
alt="Loading..."
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
display: 'block',
}}
/>
</div>
);
return (
<>
{viewerContent}
{screenshotOverlay}
</>
);
}
// Check for custom workbench views first
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
@@ -115,15 +313,9 @@ export default function Workbench() {
<FileEditor
toolMode={!!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolId && {
onOpenPageEditor: () => {
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
addFiles(filesToMerge);
setCurrentView("viewer");
}
})}
onOpenViewer={(fileId, sourceRect) => {
setCurrentView("viewer", fileId, sourceRect);
}}
/>
);
@@ -207,22 +399,31 @@ export default function Workbench() {
{/* Main content area */}
<Box
ref={mainContentRef}
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
}}
>
{renderMainContent()}
{renderMainContent()}
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
{/* Viewer Zoom Transition Overlay */}
<ViewerZoomTransition />
{/* Page Editor Spread Transition Overlay */}
<PageEditorSpreadTransition />
<Box style={{ position: 'relative', zIndex: 100 }}>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
</Box>
);
}
@@ -60,6 +60,17 @@
}
}
@keyframes pageAppear {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Action styles */
.actionRow:hover {
background: var(--hover-bg);
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useState, useCallback, useRef, useEffect, useMemo, useLayoutEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext";
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
@@ -12,6 +12,7 @@ import SkeletonLoader from '@app/components/shared/SkeletonLoader';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { FileId } from "@app/types/file";
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import { PAGE_EDITOR_TRANSITION } from '@app/constants/animations';
import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
@@ -38,6 +39,7 @@ const PageEditor = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
const { pageEditorTransition } = useNavigationState();
// Get PageEditor coordination functions
const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor();
@@ -77,6 +79,7 @@ const PageEditor = ({
const fileObjectsRef = useRef(new Map<FileId, any>());
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
const burstAnimatedPagesRef = useRef<Set<string>>(new Set());
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
@@ -338,8 +341,146 @@ const PageEditor = ({
};
}, [isContainerHovered, zoomIn, zoomOut]);
// Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || [];
// Progressive page loading for smooth animation
const [visiblePageCount, setVisiblePageCount] = useState(1);
const [firstPageVisible, setFirstPageVisible] = useState(false);
const [animatingPages, setAnimatingPages] = useState(false);
const allPages = displayDocument?.pages || [];
// Animation sequence coordination
useEffect(() => {
let cancelled = false;
let fadeDelay: number | null = null;
let remainingDelay: number | null = null;
let glideListener: (() => void) | null = null;
if (allPages.length <= 1) {
setFirstPageVisible(true);
setVisiblePageCount(allPages.length);
return;
}
const waitForGlideCompletion = () => new Promise<void>((resolve) => {
if (!pageEditorTransition?.isAnimating) {
resolve();
return;
}
let resolved = false;
glideListener = () => {
if (resolved) return;
resolved = true;
resolve();
};
window.addEventListener(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT, glideListener, { once: true });
const tick = () => {
if (resolved) return;
if (cancelled) {
resolved = true;
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
const runAnimation = async () => {
await waitForGlideCompletion();
if (cancelled) return;
// Phase 2: Fade in first page (300ms)
setFirstPageVisible(true);
fadeDelay = window.setTimeout(() => {
// Phase 3: Show first 20 pages for burst (or all if < 20)
const burstPageCount = Math.min(20, allPages.length);
setVisiblePageCount(burstPageCount);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimatingPages(true);
});
});
// Phase 4: Load remaining pages without animation after burst completes
if (allPages.length > 20) {
remainingDelay = window.setTimeout(() => {
setVisiblePageCount(allPages.length);
}, 600); // After burst animation completes
}
}, 300);
};
runAnimation();
return () => {
cancelled = true;
if (glideListener) {
window.removeEventListener(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT, glideListener);
}
if (fadeDelay !== null) clearTimeout(fadeDelay);
if (remainingDelay !== null) clearTimeout(remainingDelay);
};
}, [allPages.length, pageEditorTransition?.isAnimating]);
// Reset on document change
useEffect(() => {
setVisiblePageCount(1);
setFirstPageVisible(false);
setAnimatingPages(false);
burstAnimatedPagesRef.current.clear();
}, [allPages.length]);
const displayedPages = allPages.slice(0, visiblePageCount);
useLayoutEffect(() => {
if (!animatingPages) {
return;
}
const firstPageElement = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
if (!firstPageElement) {
return;
}
const firstRect = firstPageElement.getBoundingClientRect();
const burstLimit = Math.min(20, allPages.length);
const pagesToAnimate = allPages.slice(1, Math.min(visiblePageCount, burstLimit));
pagesToAnimate.forEach((page) => {
if (burstAnimatedPagesRef.current.has(page.id)) {
return;
}
const currentPageElement = document.querySelector(`[data-page-id="${page.id}"]`) as HTMLElement | null;
if (!currentPageElement) {
return;
}
const currentRect = currentPageElement.getBoundingClientRect();
const deltaX = firstRect.left - currentRect.left;
const deltaY = firstRect.top - currentRect.top;
const scaleX = firstRect.width / currentRect.width;
const scaleY = firstRect.height / currentRect.height;
currentPageElement.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`;
currentPageElement.style.transformOrigin = 'top left';
currentPageElement.style.opacity = '1';
currentPageElement.style.transition = 'none';
requestAnimationFrame(() => {
currentPageElement.style.transform = 'translate(0, 0) scale(1)';
currentPageElement.style.transition = 'transform 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
});
burstAnimatedPagesRef.current.add(page.id);
});
}, [animatingPages, allPages, visiblePageCount]);
// Track color assignments by insertion order (files keep their color)
const fileColorIndexMap = useFileColorMap(orderedFileIds);
@@ -462,32 +603,68 @@ const PageEditor = ({
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
const isFirstPage = index === 0;
const isSecondaryPage = index > 0;
const pageStyle: React.CSSProperties = {};
if (isFirstPage) {
// Fade in first page after glide completes
if (!firstPageVisible) {
pageStyle.opacity = 0;
} else {
pageStyle.opacity = 1;
pageStyle.transition = 'opacity 300ms ease-in-out';
}
// Keep first page on top during burst
pageStyle.zIndex = 10;
pageStyle.position = 'relative';
} else if (isSecondaryPage) {
// Burst from behind the real first page
if (!animatingPages) {
// Before animation: hide behind first page
// Use data attributes to calculate in useLayoutEffect
pageStyle.opacity = 0;
pageStyle.transition = 'none';
pageStyle.zIndex = -1;
} else {
// Burst animation is applied once in useLayoutEffect
pageStyle.opacity = 1;
pageStyle.zIndex = 0;
}
}
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
<div
data-page-id={page.id}
data-page-number={index + 1}
data-original-file-id={page.originalFileId}
style={pageStyle}
>
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument}
@@ -496,6 +673,7 @@ const PageEditor = ({
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevel}
/>
</div>
);
}}
/>
@@ -0,0 +1,46 @@
/* PageEditorSpreadTransition.module.css - Spreading animation styles */
:root {
--spread-duration: 600ms;
--spread-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Background screenshot that fades out during animation */
.backgroundScreenshot {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-size: 100% 100%;
background-position: top left;
background-repeat: no-repeat;
z-index: 10000;
pointer-events: none;
transition: opacity 200ms ease-out;
}
/* Individual page thumbnail that animates from file card to grid position */
.spreadingPage {
will-change: transform;
transform-origin: top left;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 0;
transition: transform var(--spread-duration) var(--spread-easing);
}
/* Accessibility - respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
:root {
--spread-duration: 0ms;
}
.backgroundScreenshot {
transition: none;
}
.spreadingPage {
transition: none !important;
}
}
@@ -0,0 +1,505 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useFileState } from '@app/contexts/FileContext';
import { PAGE_EDITOR_TRANSITION } from '@app/constants/animations';
import { getContainedImageRect, getCenteredFallbackRect } from '@app/utils/dom';
import styles from '@app/components/pageEditor/PageEditorSpreadTransition.module.css';
type AnimationPhase = 'idle' | 'ready' | 'gliding';
const nextPaint = () => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const waitForElement = (
selector: string,
timeoutMs: number,
shouldAbort?: () => boolean
): Promise<HTMLElement | null> => {
const existing = document.querySelector(selector) as HTMLElement | null;
if (existing) {
return Promise.resolve(existing);
}
return new Promise(resolve => {
let resolved = false;
const finish = (value: HTMLElement | null) => {
if (resolved) return;
resolved = true;
observer.disconnect();
resolve(value);
};
const observer = new MutationObserver(() => {
const found = document.querySelector(selector) as HTMLElement | null;
if (found) {
finish(found);
}
});
observer.observe(document.body, { childList: true, subtree: true });
const start = performance.now();
const tick = () => {
if (resolved) return;
if (shouldAbort?.()) {
finish(null);
return;
}
if (performance.now() - start >= timeoutMs) {
finish(null);
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
};
const waitForStableRect = (
element: Element,
options: { stableDurationMs: number; timeoutMs: number },
shouldAbort?: () => boolean
): Promise<DOMRect> => {
return new Promise(resolve => {
let lastRect = element.getBoundingClientRect();
let lastChange = performance.now();
const start = lastChange;
let rafId: number | null = null;
const observer = new ResizeObserver(() => {
scheduleCheck();
});
const cleanup = () => {
observer.disconnect();
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = null;
};
const check = () => {
rafId = null;
if (shouldAbort?.()) {
cleanup();
resolve(element.getBoundingClientRect());
return;
}
const rect = element.getBoundingClientRect();
const sizeChanged = Math.abs(rect.width - lastRect.width) > 1 ||
Math.abs(rect.height - lastRect.height) > 1;
if (sizeChanged) {
lastRect = rect;
lastChange = performance.now();
}
const now = performance.now();
const stableEnough = now - lastChange >= options.stableDurationMs;
const timedOut = now - start >= options.timeoutMs;
if (stableEnough || timedOut) {
cleanup();
resolve(rect);
return;
}
scheduleCheck();
};
const scheduleCheck = () => {
if (rafId === null) {
rafId = requestAnimationFrame(check);
}
};
observer.observe(element);
scheduleCheck();
});
};
const waitForTransitionEnd = (
element: HTMLElement,
options: { propertyName?: string; timeoutMs: number },
shouldAbort?: () => boolean
): Promise<void> => {
return new Promise(resolve => {
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
element.removeEventListener('transitionend', handleEnd);
resolve();
};
const handleEnd = (event: TransitionEvent) => {
if (event.target !== element) return;
if (options.propertyName && event.propertyName !== options.propertyName) return;
finish();
};
element.addEventListener('transitionend', handleEnd);
const start = performance.now();
const tick = () => {
if (resolved) return;
if (shouldAbort?.()) {
finish();
return;
}
if (performance.now() - start >= options.timeoutMs) {
finish();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
};
const waitForOpacity = (
element: HTMLElement,
options: { targetOpacity: number; timeoutMs: number },
shouldAbort?: () => boolean
): Promise<void> => {
return new Promise(resolve => {
const start = performance.now();
const tick = () => {
if (shouldAbort?.()) {
resolve();
return;
}
const opacity = Number.parseFloat(getComputedStyle(element).opacity || '1');
if (opacity >= options.targetOpacity) {
resolve();
return;
}
if (performance.now() - start >= options.timeoutMs) {
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
};
/**
* PageEditorSpreadTransition - First page glide animation
*
* Animation sequence:
* 1. First page thumbnail glides from file card to its position in page editor grid
* 2. Smoothly transitions size and position like viewer zoom
*/
export const PageEditorSpreadTransition: React.FC = () => {
const { pageEditorTransition } = useNavigationState();
const { actions } = useNavigationActions();
const { selectors } = useFileState();
const [animationPhase, setAnimationPhase] = useState<AnimationPhase>('idle');
const [firstPageTargetRect, setFirstPageTargetRect] = useState<DOMRect | null>(null);
const transitionRef = useRef<HTMLDivElement | null>(null);
const lastScreenshotRef = useRef<string | null>(null);
const isActive = pageEditorTransition?.isAnimating ?? false;
const direction = pageEditorTransition?.direction ?? 'enter';
const fileCardRects = pageEditorTransition?.fileCardRects ?? new Map();
const filePageCounts = pageEditorTransition?.filePageCounts ?? new Map();
const pageThumbnails = pageEditorTransition?.pageThumbnails ?? new Map();
const screenshotRect = pageEditorTransition?.editorScreenshotRect ?? null;
const shouldMaskLoading = Boolean(pageEditorTransition?.editorScreenshotUrl);
const spreadDuration = shouldMaskLoading
? PAGE_EDITOR_TRANSITION.SPREAD_DURATION
: PAGE_EDITOR_TRANSITION.SPREAD_DURATION_FAST;
useEffect(() => {
const currentUrl = pageEditorTransition?.editorScreenshotUrl ?? null;
const previousUrl = lastScreenshotRef.current;
if (currentUrl && currentUrl !== previousUrl) {
if (previousUrl?.startsWith('blob:')) {
URL.revokeObjectURL(previousUrl);
}
lastScreenshotRef.current = currentUrl;
}
if (!isActive && lastScreenshotRef.current) {
const urlToRevoke = lastScreenshotRef.current;
if (urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
lastScreenshotRef.current = null;
}
return () => {
if (lastScreenshotRef.current) {
const urlToRevoke = lastScreenshotRef.current;
if (urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
lastScreenshotRef.current = null;
}
};
}, [pageEditorTransition?.editorScreenshotUrl, isActive]);
// Build animation data for all pages
const pageAnimations = useMemo(() => {
if (!isActive) return [];
// For burst pages, we need the discovered first page rect
if (!firstPageTargetRect) return [];
const animations: Array<{
pageIndex: number;
fileId: string;
sourceRect: DOMRect;
targetRect: DOMRect | null; // Will be set after pages render
staggerDelay: number;
thumbnailUrl: string | null;
isFirstPage: boolean;
}> = [];
let cumulativePageIndex = 0;
const activeFiles = selectors.getFiles();
activeFiles.forEach((file, fileIndex) => {
const fileId = file.fileId;
const cardRect = fileCardRects.get(fileId);
const pageCount = filePageCounts.get(fileId) || 0;
if (pageCount === 0) return;
if (cardRect) {
for (let i = 0; i < pageCount; i++) {
const pageIndex = cumulativePageIndex + i;
const isFirstPage = i === 0;
// Get actual page thumbnail from the captured map
const thumbnailUrl = isFirstPage ? pageThumbnails.get(fileId) || null : null;
// Target rect will be queried from actual DOM elements
const targetRect = null;
// For the first page, source is the file card
// For other pages, source is the first page's target position (they burst from there)
const sourceRect = isFirstPage ? cardRect : firstPageTargetRect;
// No stagger - all pages burst simultaneously
const staggerDelay = 0;
animations.push({
pageIndex,
fileId,
sourceRect,
targetRect,
staggerDelay,
thumbnailUrl,
isFirstPage,
});
}
}
cumulativePageIndex += pageCount;
});
return animations.slice(0, PAGE_EDITOR_TRANSITION.MAX_ANIMATED_PAGES);
}, [isActive, fileCardRects, filePageCounts, pageThumbnails, firstPageTargetRect, selectors]);
// Get first page animations
const firstPages = useMemo(() => pageAnimations.filter(p => p.isFirstPage), [pageAnimations]);
// Animation orchestration
useEffect(() => {
let mounted = true;
const shouldAbort = () => !mounted;
if (!isActive) {
setAnimationPhase('idle');
setFirstPageTargetRect(null);
return () => {
mounted = false;
};
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
actions.endPageEditorTransition();
return () => { mounted = false; };
}
const runAnimation = async () => {
if (!mounted) return;
// Phase 0: Wait for PageEditor to render and find first page's actual position
const firstPage = await waitForElement('[data-page-number="1"]', 1000, shouldAbort);
if (!mounted) return;
let firstPageRect: DOMRect;
if (firstPage) {
await waitForStableRect(firstPage, { stableDurationMs: 250, timeoutMs: 500 }, shouldAbort);
if (!mounted) return;
const img = firstPage.querySelector('img') as HTMLImageElement | null;
firstPageRect = img
? getContainedImageRect(img)
: firstPage.getBoundingClientRect();
} else {
firstPageRect = getCenteredFallbackRect();
}
if (!mounted) return;
// Store the discovered first page position
setFirstPageTargetRect(firstPageRect);
// Wait for pageAnimations to recalculate with the new target rect
await nextPaint();
if (!mounted) return;
// Phase 1: Ready - render element with initial transform
setAnimationPhase('ready');
// Wait for browser to paint initial state
await nextPaint();
if (!mounted) return;
// Phase 2: Gliding - apply target transform
const transitionEl = transitionRef.current;
setAnimationPhase('gliding');
if (transitionEl) {
await waitForTransitionEnd(
transitionEl,
{ propertyName: 'transform', timeoutMs: spreadDuration + 150 },
shouldAbort
);
}
if (!mounted) return;
window.dispatchEvent(new Event(PAGE_EDITOR_TRANSITION.GLIDE_COMPLETE_EVENT));
// Phase 3: Wait for first page fade in
const renderedFirstPage = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
if (renderedFirstPage) {
await waitForOpacity(renderedFirstPage, { targetOpacity: 1, timeoutMs: 350 }, shouldAbort);
}
if (!mounted) return;
actions.endPageEditorTransition();
};
runAnimation();
return () => {
mounted = false;
};
}, [isActive, actions, spreadDuration]);
if (!isActive) {
return null;
}
const isReady = animationPhase === 'ready';
const isGliding = animationPhase === 'gliding';
const shouldRenderGlide = (isReady || isGliding) && firstPageTargetRect && firstPages.length > 0;
return (
<>
{/* Optional screenshot background */}
{pageEditorTransition?.editorScreenshotUrl && (
<div
style={{
position: 'fixed',
top: `${screenshotRect?.top ?? 0}px`,
left: `${screenshotRect?.left ?? 0}px`,
width: `${screenshotRect?.width ?? window.innerWidth}px`,
height: `${screenshotRect?.height ?? window.innerHeight}px`,
opacity: isGliding ? 0 : 1,
transition: 'opacity 200ms ease-out',
pointerEvents: 'none',
zIndex: 10000,
}}
>
<img
src={pageEditorTransition.editorScreenshotUrl}
alt="Loading..."
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
display: 'block',
}}
/>
</div>
)}
{/* First page(s) gliding animation */}
{shouldRenderGlide && firstPages.map((anim, idx) => {
const { sourceRect, thumbnailUrl } = anim;
const targetRect = firstPageTargetRect;
// Calculate target transform values
const translateX = targetRect.left - sourceRect.left;
const translateY = targetRect.top - sourceRect.top;
const scaleX = targetRect.width / sourceRect.width;
const scaleY = targetRect.height / sourceRect.height;
const targetTransform = `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})`;
// Initial style - always at source position with identity transform
// First file gets highest z-index, subsequent files stack below
const initialStyle: React.CSSProperties = {
position: 'fixed',
top: sourceRect.top,
left: sourceRect.left,
width: sourceRect.width,
height: sourceRect.height,
transform: 'translate3d(0px, 0px, 0) scale(1)',
transformOrigin: 'top left',
opacity: 1,
zIndex: PAGE_EDITOR_TRANSITION.OVERLAY_Z_INDEX + 100 - idx,
};
// Target style - applied when gliding
const glideStyle: React.CSSProperties = isGliding
? { transform: targetTransform }
: {};
return (
<div
key={`first-${anim.fileId}`}
className={styles.spreadingPage}
ref={idx === 0 ? transitionRef : undefined}
style={{
['--spread-duration' as any]: `${spreadDuration}ms`,
...initialStyle,
...glideStyle,
}}
>
{thumbnailUrl && (
<img
src={thumbnailUrl}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
background: '#ffffff',
border: '1px solid var(--border-default)',
}}
/>
)}
</div>
);
})}
</>
);
};
@@ -41,6 +41,9 @@ const EmbedPdfViewerContent = ({
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
// Get viewer transition state for fade-in animation
const { viewerTransition } = useNavigationState();
const {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
@@ -563,7 +566,8 @@ const EmbedPdfViewerContent = ({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
contain: 'layout style paint'
contain: 'layout style paint',
opacity: viewerTransition.isAnimating ? 0 : 1,
}}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
@@ -635,6 +639,8 @@ const EmbedPdfViewerContent = ({
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
opacity: viewerTransition.isAnimating ? 0 : 1,
transition: 'opacity 300ms ease-in',
}}
>
<div style={{ pointerEvents: "auto" }}>
@@ -0,0 +1,67 @@
/* ViewerZoomTransition.module.css - Smooth zoom animation for viewer transitions */
/* Animation timing - synced with constants/animations.ts */
:root {
--viewer-zoom-duration: 400ms; /* VIEWER_TRANSITION.ZOOM_DURATION */
--viewer-zoom-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* VIEWER_TRANSITION.EASING */
}
/* Backdrop that fades in during transition */
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
opacity: 0;
transition: opacity var(--viewer-zoom-duration) var(--viewer-zoom-easing);
pointer-events: none;
}
.backdropVisible {
opacity: 0;
background-color: rgba(0, 0, 0, 0);
}
/* Thumbnail that zooms from source position to PDF page */
.thumbnail {
position: fixed;
transition:
transform var(--viewer-zoom-duration) var(--viewer-zoom-easing),
border-radius var(--viewer-zoom-duration) var(--viewer-zoom-easing),
box-shadow var(--viewer-zoom-duration) var(--viewer-zoom-easing);
will-change: transform;
transform-origin: top left;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.thumbnailZoomed {
/* Dimensions controlled by inline styles (calculated from PDF page element) */
box-shadow: none;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
:root {
--viewer-zoom-duration: 0ms;
}
.backdrop {
transition: none;
}
.thumbnail {
transition: none;
}
.backdropVisible {
opacity: 0;
}
.thumbnailZoomed {
/* Instant transition for reduced motion - dimensions from inline styles */
box-shadow: none;
}
}
@@ -0,0 +1,259 @@
import React, { useEffect, useState } from 'react';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { VIEWER_TRANSITION } from '@app/constants/animations';
import { getCenteredFallbackRect } from '@app/utils/dom';
import styles from '@app/components/viewer/ViewerZoomTransition.module.css';
/**
* ViewerZoomTransition - Animated overlay for smooth transitions to viewer mode
*
* Creates a "zoom in" effect from source element (file card or page thumbnail)
* to the actual rendered PDF page by animating a thumbnail overlay.
*
* Coordinates with EmbedPDF initialization to ensure smooth handoff.
*/
export const ViewerZoomTransition: React.FC = () => {
const { viewerTransition } = useNavigationState();
const { actions } = useNavigationActions();
const [animationPhase, setAnimationPhase] = useState<'idle' | 'searching' | 'zooming'>('idle');
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
const isExitTransition = viewerTransition.transitionDirection === 'exit';
const shouldMaskLoading = Boolean(viewerTransition.editorScreenshotUrl);
const zoomDuration = shouldMaskLoading
? VIEWER_TRANSITION.ZOOM_DURATION
: VIEWER_TRANSITION.ZOOM_DURATION_FAST;
const getFallbackRect = () => getCenteredFallbackRect();
useEffect(() => {
let transitionTimer: number | null = null;
let isMounted = true;
const shouldWaitForPdf = shouldMaskLoading;
const zoomDurationMs = zoomDuration;
if (!viewerTransition.isAnimating) {
setAnimationPhase('idle');
setTargetRect(null);
return () => {
isMounted = false;
if (transitionTimer !== null) {
clearTimeout(transitionTimer);
}
};
}
setAnimationPhase('searching');
if (isExitTransition) {
// EXIT: start at PDF page rect (already captured), zoom to file card
const waitForFileCard = async (): Promise<DOMRect> => {
const maxAttempts = 20;
const delayMs = 50;
const fileId = viewerTransition.exitFileId;
if (!fileId) return getFallbackRect();
let card: Element | null = null;
for (let i = 0; i < maxAttempts; i++) {
card = document.querySelector(`[data-file-id="${fileId}"]`);
if (card) break;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
if (!card) {
console.warn('ViewerZoomTransition: file card not found, using fallback rect');
return getFallbackRect();
}
const img = card.querySelector('img') as HTMLImageElement | null;
return img ? img.getBoundingClientRect() : card.getBoundingClientRect();
};
waitForFileCard().then(cardRect => {
if (!isMounted) return;
actions.startZoom();
setTargetRect(cardRect);
setAnimationPhase('zooming');
transitionTimer = window.setTimeout(() => {
if (!isMounted) return;
actions.endViewerTransition();
}, zoomDurationMs);
});
} else {
const waitForPdfPage = async (): Promise<DOMRect> => {
const maxAttempts = 20;
const delayMs = 50;
// Step 1: Find the element
let pageElement: Element | null = null;
for (let i = 0; i < maxAttempts; i++) {
pageElement = document.querySelector('[data-page-index="0"]');
if (pageElement) break;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
if (!pageElement) {
console.warn('ViewerZoomTransition: PDF page not found, using fallback rect');
return viewerTransition.sourceRect || getFallbackRect();
}
// Step 2: Wait for size to stabilize
let stableRect: DOMRect | null = null;
let previousSize = { width: 0, height: 0 };
const stabilityChecks = 5; // Check 5 times to ensure stability
let stableCount = 0;
for (let i = 0; i < 10; i++) { // Max 500ms to stabilize
const currentRect = pageElement.getBoundingClientRect();
const currentSize = {
width: currentRect.width,
height: currentRect.height
};
// Check if size has changed from previous measurement
const sizeChanged = Math.abs(currentSize.width - previousSize.width) > 1 ||
Math.abs(currentSize.height - previousSize.height) > 1;
if (sizeChanged) {
// Size changed, reset stability counter
stableCount = 0;
} else {
// Size unchanged, increment stability counter
stableCount++;
if (stableCount >= stabilityChecks) {
// Size has been stable for multiple checks - use this
stableRect = currentRect;
break;
}
}
previousSize = currentSize;
await new Promise(resolve => setTimeout(resolve, 50));
}
// Use stable rect if found, otherwise use latest measurement
return stableRect || pageElement.getBoundingClientRect();
};
waitForPdfPage().then(pdfPageRect => {
if (!isMounted) {
return;
}
// Found PDF page - trigger screenshot fade and start animation
actions.startZoom(); // Triggers 200ms fade of screenshot
setTargetRect(pdfPageRect);
setAnimationPhase('zooming');
// After zoom completes, wait for PDF to be fully rendered before removing thumbnail
transitionTimer = window.setTimeout(async () => {
if (!isMounted) return;
if (shouldWaitForPdf) {
// Wait for PDF to be ready (check for rendered canvas or content)
for (let i = 0; i < 10; i++) {
const pageElement = document.querySelector('[data-page-index="0"]');
if (pageElement) {
// Check if page has actual content rendered (canvas or img)
const hasContent = pageElement.querySelector('canvas, img');
if (hasContent) {
break;
}
}
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Remove thumbnail now that PDF is ready (or timeout reached/skipped)
actions.endViewerTransition();
}, zoomDurationMs);
});
}
return () => {
isMounted = false;
if (transitionTimer !== null) {
clearTimeout(transitionTimer);
}
};
}, [
viewerTransition.isAnimating,
viewerTransition.exitFileId,
viewerTransition.sourceRect,
isExitTransition,
actions,
shouldMaskLoading,
zoomDuration
]);
// Don't render if not animating or missing thumbnail
if (!viewerTransition.isAnimating || !viewerTransition.sourceThumbnailUrl) {
return null;
}
const { sourceThumbnailUrl } = viewerTransition;
const initialRect = isExitTransition
? (viewerTransition.exitTargetRect || viewerTransition.sourceRect || getFallbackRect())
: (viewerTransition.sourceRect || getFallbackRect());
const initialRadius = '0';
const targetRadius = '0';
const initialWidth = Math.max(initialRect.width, 1);
const initialHeight = Math.max(initialRect.height, 1);
// Calculate initial styles based on source element position
const initialStyle: React.CSSProperties = {
position: 'fixed',
top: `${initialRect.top}px`,
left: `${initialRect.left}px`,
width: `${initialWidth}px`,
height: `${initialHeight}px`,
borderRadius: initialRadius,
overflow: 'hidden',
zIndex: VIEWER_TRANSITION.OVERLAY_Z_INDEX,
transform: 'translate3d(0px, 0px, 0) scale(1)',
transformOrigin: 'top left',
['--viewer-zoom-duration' as any]: `${zoomDuration}ms`,
};
const targetTransform = targetRect
? `translate3d(${targetRect.left - initialRect.left}px, ${targetRect.top - initialRect.top}px, 0) scale(${targetRect.width / initialWidth}, ${targetRect.height / initialHeight})`
: null;
// Determine if we should apply zoom animation
const shouldZoom = animationPhase === 'zooming' && targetTransform !== null;
const zoomStyle: React.CSSProperties = shouldZoom && targetTransform
? { transform: targetTransform, borderRadius: targetRadius }
: {};
// Build class names for thumbnail
const thumbnailClasses = [
styles.thumbnail,
shouldZoom && styles.thumbnailZoomed,
].filter(Boolean).join(' ');
return (
<>
{/* Animated thumbnail - no backdrop needed */}
<div
className={thumbnailClasses}
style={{
...initialStyle,
...zoomStyle,
}}
aria-hidden="true"
>
<img
src={sourceThumbnailUrl}
alt="Transitioning to viewer"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
background: '#ffffff',
}}
/>
</div>
</>
);
};
+51
View File
@@ -0,0 +1,51 @@
/**
* Shared animation constants for viewer transitions
* Single source of truth for timing and easing across CSS and JS
*/
export const VIEWER_TRANSITION = {
/** Duration of zoom animation in milliseconds */
ZOOM_DURATION: 400,
/** Faster zoom when not masking loading */
ZOOM_DURATION_FAST: 250,
/** Duration of screenshot fade in milliseconds */
SCREENSHOT_FADE_DURATION: 200,
/** Cubic bezier easing function (easeOutQuad) */
EASING: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
/** Z-index for transition overlays */
OVERLAY_Z_INDEX: 10000,
} as const;
/**
* Page editor spreading animation constants
* Used for fileEditor ↔ pageEditor transitions
*/
export const PAGE_EDITOR_TRANSITION = {
/** Duration of spreading animation in milliseconds */
SPREAD_DURATION: 300,
/** Faster spread when not masking loading */
SPREAD_DURATION_FAST: 200,
/** Stagger delay between individual page animations in milliseconds */
PAGE_STAGGER_DELAY: 30,
/** Maximum total stagger delay in milliseconds */
MAX_TOTAL_STAGGER: 300,
/** Cubic bezier easing function (matches viewer) */
EASING: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
/** Z-index for transition overlay (above viewer transition) */
OVERLAY_Z_INDEX: 10001,
/** Maximum pages to animate individually (performance threshold) */
MAX_ANIMATED_PAGES: 100,
/** Event name fired when glide animation completes */
GLIDE_COMPLETE_EVENT: 'page-editor-glide-complete',
} as const;
@@ -11,6 +11,32 @@ import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
* maintain clear separation of concerns.
*/
// Viewer transition animation state
export interface ViewerTransitionState {
isAnimating: boolean;
sourceRect: DOMRect | null;
sourceThumbnailUrl: string | null;
transitionType: 'fileEditor' | 'pageEditor' | null;
editorScreenshotUrl: string | null;
editorScreenshotRect: DOMRect | null;
isZooming: boolean;
transitionDirection: 'enter' | 'exit' | null;
exitTargetRect: DOMRect | null;
exitFileId: string | null;
}
// Page editor spreading animation state
export interface PageEditorTransitionState {
isAnimating: boolean;
direction: 'enter' | 'exit';
fileCardRects: Map<string, DOMRect>;
filePageCounts: Map<string, number>;
pageThumbnails: Map<string, string>; // Map file ID to first page thumbnail URL
targetPageRects: Map<string, DOMRect> | null;
editorScreenshotUrl: string | null;
editorScreenshotRect: DOMRect | null;
}
// Navigation state
interface NavigationContextState {
workbench: WorkbenchType;
@@ -18,6 +44,8 @@ interface NavigationContextState {
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
viewerTransition: ViewerTransitionState;
pageEditorTransition: PageEditorTransitionState | null;
}
// Navigation actions
@@ -27,7 +55,15 @@ type NavigationAction =
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
| { type: 'START_VIEWER_TRANSITION'; payload: { sourceRect: DOMRect; sourceThumbnailUrl: string; transitionType: 'fileEditor' | 'pageEditor'; editorScreenshotUrl?: string; editorScreenshotRect?: DOMRect } }
| { type: 'END_VIEWER_TRANSITION' }
| { type: 'START_ZOOM' }
| { type: 'START_EXIT_TRANSITION'; payload: { exitTargetRect: DOMRect; sourceThumbnailUrl: string; exitFileId: string } }
| { type: 'START_PAGE_EDITOR_ENTRY'; payload: PageEditorTransitionState }
| { type: 'START_PAGE_EDITOR_EXIT'; payload: Partial<PageEditorTransitionState> }
| { type: 'UPDATE_PAGE_EDITOR_TARGETS'; payload: { targetPageRects: Map<string, DOMRect> } }
| { type: 'END_PAGE_EDITOR_TRANSITION' };
// Navigation reducer
const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
@@ -54,6 +90,92 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi
case 'SHOW_NAVIGATION_WARNING':
return { ...state, showNavigationWarning: action.payload.show };
case 'START_VIEWER_TRANSITION':
return {
...state,
viewerTransition: {
isAnimating: true,
sourceRect: action.payload.sourceRect,
sourceThumbnailUrl: action.payload.sourceThumbnailUrl,
transitionType: action.payload.transitionType,
editorScreenshotUrl: action.payload.editorScreenshotUrl || null,
editorScreenshotRect: action.payload.editorScreenshotRect || null,
isZooming: false,
transitionDirection: 'enter',
exitTargetRect: null,
exitFileId: null
}
};
case 'START_EXIT_TRANSITION':
return {
...state,
viewerTransition: {
...state.viewerTransition,
isAnimating: true,
transitionDirection: 'exit',
exitTargetRect: action.payload.exitTargetRect,
sourceThumbnailUrl: action.payload.sourceThumbnailUrl,
exitFileId: action.payload.exitFileId,
sourceRect: null, // Will be calculated after fileEditor renders
isZooming: false
}
};
case 'END_VIEWER_TRANSITION':
return {
...state,
viewerTransition: {
isAnimating: false,
sourceRect: null,
sourceThumbnailUrl: null,
transitionType: null,
editorScreenshotUrl: null,
editorScreenshotRect: null,
isZooming: false,
transitionDirection: null,
exitTargetRect: null,
exitFileId: null
}
};
case 'START_ZOOM':
return {
...state,
viewerTransition: {
...state.viewerTransition,
isZooming: true
}
};
case 'START_PAGE_EDITOR_ENTRY':
return {
...state,
pageEditorTransition: action.payload
};
case 'START_PAGE_EDITOR_EXIT':
return {
...state,
pageEditorTransition: state.pageEditorTransition
? { ...state.pageEditorTransition, ...action.payload }
: null
};
case 'UPDATE_PAGE_EDITOR_TARGETS':
return {
...state,
pageEditorTransition: state.pageEditorTransition
? { ...state.pageEditorTransition, targetPageRects: action.payload.targetPageRects }
: null
};
case 'END_PAGE_EDITOR_TRANSITION':
return {
...state,
pageEditorTransition: null
};
default:
return state;
}
@@ -65,7 +187,20 @@ const initialState: NavigationContextState = {
selectedTool: null,
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false
showNavigationWarning: false,
viewerTransition: {
isAnimating: false,
sourceRect: null,
sourceThumbnailUrl: null,
transitionType: null,
editorScreenshotUrl: null,
editorScreenshotRect: null,
isZooming: false,
transitionDirection: null,
exitTargetRect: null,
exitFileId: null
},
pageEditorTransition: null
};
// Navigation context actions interface
@@ -82,6 +217,20 @@ export interface NavigationContextActions {
cancelNavigation: () => void;
clearToolSelection: () => void;
handleToolSelect: (toolId: string) => void;
startViewerTransition: (
sourceRect: DOMRect,
sourceThumbnailUrl: string,
transitionType: 'fileEditor' | 'pageEditor',
editorScreenshotUrl?: string,
editorScreenshotRect?: DOMRect
) => void;
endViewerTransition: () => void;
startZoom: () => void;
startExitTransition: (exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => void;
startPageEditorEntryTransition: (state: PageEditorTransitionState) => void;
startPageEditorExitTransition: (state: Partial<PageEditorTransitionState>) => void;
updatePageEditorTargets: (targetPageRects: Map<string, DOMRect>) => void;
endPageEditorTransition: () => void;
}
// Context state values
@@ -91,6 +240,8 @@ export interface NavigationContextStateValue {
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
viewerTransition: ViewerTransitionState;
pageEditorTransition: PageEditorTransitionState | null;
}
export interface NavigationContextActionsValue {
@@ -252,7 +403,7 @@ export const NavigationProvider: React.FC<{
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
// If switching away from current tool and have unsaved changes, show warning
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
@@ -262,6 +413,47 @@ export const NavigationProvider: React.FC<{
}
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
const startViewerTransition = useCallback((
sourceRect: DOMRect,
sourceThumbnailUrl: string,
transitionType: 'fileEditor' | 'pageEditor',
editorScreenshotUrl?: string,
editorScreenshotRect?: DOMRect
) => {
dispatch({
type: 'START_VIEWER_TRANSITION',
payload: { sourceRect, sourceThumbnailUrl, transitionType, editorScreenshotUrl, editorScreenshotRect }
});
}, []);
const endViewerTransition = useCallback(() => {
dispatch({ type: 'END_VIEWER_TRANSITION' });
}, []);
const startZoom = useCallback(() => {
dispatch({ type: 'START_ZOOM' });
}, []);
const startExitTransition = useCallback((exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => {
dispatch({ type: 'START_EXIT_TRANSITION', payload: { exitTargetRect, sourceThumbnailUrl, exitFileId } });
}, []);
const startPageEditorEntryTransition = useCallback((transitionState: PageEditorTransitionState) => {
dispatch({ type: 'START_PAGE_EDITOR_ENTRY', payload: transitionState });
}, []);
const startPageEditorExitTransition = useCallback((transitionState: Partial<PageEditorTransitionState>) => {
dispatch({ type: 'START_PAGE_EDITOR_EXIT', payload: transitionState });
}, []);
const updatePageEditorTargets = useCallback((targetPageRects: Map<string, DOMRect>) => {
dispatch({ type: 'UPDATE_PAGE_EDITOR_TARGETS', payload: { targetPageRects } });
}, []);
const endPageEditorTransition = useCallback(() => {
dispatch({ type: 'END_PAGE_EDITOR_TRANSITION' });
}, []);
// Memoize the actions object to prevent unnecessary context updates
// This is critical to avoid infinite loops when effects depend on actions
const actions: NavigationContextActions = useMemo(() => ({
@@ -277,6 +469,14 @@ export const NavigationProvider: React.FC<{
cancelNavigation,
clearToolSelection,
handleToolSelect,
startViewerTransition,
endViewerTransition,
startZoom,
startExitTransition,
startPageEditorEntryTransition,
startPageEditorExitTransition,
updatePageEditorTargets,
endPageEditorTransition,
}), [
setWorkbench,
setSelectedTool,
@@ -290,6 +490,14 @@ export const NavigationProvider: React.FC<{
cancelNavigation,
clearToolSelection,
handleToolSelect,
startViewerTransition,
endViewerTransition,
startZoom,
startExitTransition,
startPageEditorEntryTransition,
startPageEditorExitTransition,
updatePageEditorTargets,
endPageEditorTransition,
]);
const stateValue: NavigationContextStateValue = {
@@ -297,7 +505,9 @@ export const NavigationProvider: React.FC<{
selectedTool: state.selectedTool,
hasUnsavedChanges: state.hasUnsavedChanges,
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning
showNavigationWarning: state.showNavigationWarning,
viewerTransition: state.viewerTransition,
pageEditorTransition: state.pageEditorTransition
};
// Also memoize the context value to prevent unnecessary re-renders
@@ -0,0 +1,212 @@
import { useCallback } from 'react';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useFileState } from '@app/contexts/FileContext';
import { getContainedImageRect, getCenteredFallbackRect } from '@app/utils/dom';
import type { WorkbenchType } from '@app/types/workbench';
import type { FileId } from '@app/types/fileContext';
interface UsePageEditorTransitionParams {
currentView: WorkbenchType;
captureScreenshot: () => Promise<string | null>;
activeFileId?: FileId;
getScreenshotRect?: () => DOMRect | null;
}
interface PageEditorTransitionHandlers {
handleEntryTransition: (options?: { sourceRect?: DOMRect; sourceFileId?: FileId }) => Promise<void>;
handleExitTransition: () => void;
}
/**
* Custom hook to handle page editor entry and exit spreading transitions
* Captures file card positions and orchestrates the spreading animation
*/
export function usePageEditorTransition({
currentView,
captureScreenshot,
activeFileId,
getScreenshotRect,
}: UsePageEditorTransitionParams): PageEditorTransitionHandlers {
const { actions: navActions } = useNavigationActions();
const { selectors } = useFileState();
const decodeImage = useCallback(async (src: string) => {
if (!src) return;
try {
const img = new Image();
img.src = src;
if ('decode' in img) {
await img.decode();
} else {
await new Promise<void>((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve();
});
}
} catch {
// Ignore decode failures; fallback is rendering with normal load.
}
}, []);
/**
* Handle entry transition (fileEditor → pageEditor)
* Captures first page position and metadata for glide-then-burst animation
*/
const handleEntryTransition = useCallback(
async (options) => {
if (currentView !== 'fileEditor' && currentView !== 'viewer') {
return;
}
const fileCardRects = new Map<string, DOMRect>();
const filePageCounts = new Map<string, number>();
const pageThumbnails = new Map<string, string>(); // Map file ID to first page thumbnail URL
// Get active files to capture their first page positions
const activeFiles = selectors.getFiles();
let cumulativePageIndex = 0;
const sourceFileId = options?.sourceFileId ?? activeFileId ?? undefined;
let sourceRect = options?.sourceRect ?? null;
if (currentView === 'viewer' && sourceFileId && !sourceRect) {
const pageElement = document.querySelector('[data-page-index="0"]') as HTMLElement | null;
sourceRect = pageElement ? pageElement.getBoundingClientRect() : getCenteredFallbackRect();
}
activeFiles.forEach((file) => {
const fileId = file.fileId;
if (currentView === 'fileEditor') {
const card = document.querySelector(`[data-file-id="${fileId}"]`) as HTMLElement | null;
if (card) {
const img = card.querySelector('img') as HTMLImageElement | null;
const rect = img
? getContainedImageRect(img)
: card.getBoundingClientRect();
fileCardRects.set(fileId, rect);
}
} else if (currentView === 'viewer' && sourceFileId && sourceRect && fileId === sourceFileId) {
fileCardRects.set(fileId, sourceRect);
}
// Get page data from file metadata
const stub = selectors.getStirlingFileStub(fileId);
const pageCount = stub?.processedFile?.totalPages || 1;
const pages = stub?.processedFile?.pages || [];
filePageCounts.set(fileId, pageCount);
// Capture only the first page thumbnail to keep transition state light
const firstThumbnail = pages[0]?.thumbnail;
if (firstThumbnail) {
pageThumbnails.set(fileId, firstThumbnail);
}
cumulativePageIndex += pageCount;
});
// Only proceed if we have at least one file card
if (fileCardRects.size === 0) {
return;
}
// Optional: capture screenshot for static background
let screenshot: string | null = null;
const screenshotRect = getScreenshotRect ? getScreenshotRect() : null;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
try {
screenshot = await captureScreenshot();
} catch {
screenshot = null;
}
}
if (screenshot) {
await decodeImage(screenshot);
}
if (sourceFileId) {
const sourceStub = selectors.getStirlingFileStub(sourceFileId);
const firstThumb = sourceStub?.processedFile?.pages?.[0]?.thumbnail;
if (firstThumb) {
await decodeImage(firstThumb);
}
}
// Start page editor entry transition with first page glide
navActions.startPageEditorEntryTransition({
isAnimating: true,
direction: 'enter',
fileCardRects,
filePageCounts,
pageThumbnails,
targetPageRects: null, // Will be set after PageEditor renders
editorScreenshotUrl: screenshot,
editorScreenshotRect: screenshotRect,
});
},
[currentView, selectors, captureScreenshot, navActions, activeFileId, decodeImage, getScreenshotRect]
);
/**
* Handle exit transition (pageEditor → fileEditor)
* Captures current page positions in grid for reverse animation
*/
const handleExitTransition = useCallback(() => {
if (currentView !== 'pageEditor') {
return;
}
const targetPageRects = new Map<string, DOMRect>();
const fileCardRects = new Map<string, DOMRect>();
const filePageCounts = new Map<string, number>();
// Query all page thumbnails (by data-page-id attribute)
const pages = document.querySelectorAll('[data-page-id]');
pages.forEach((page) => {
const pageId = page.getAttribute('data-page-id');
const originalFileId = page.getAttribute('data-original-file-id');
if (!pageId) return;
const img = page.querySelector('img') as HTMLImageElement | null;
const rect = img
? getContainedImageRect(img)
: page.getBoundingClientRect();
targetPageRects.set(pageId, rect);
// Track which files have pages (for grouping in reverse animation)
if (originalFileId) {
if (!filePageCounts.has(originalFileId)) {
filePageCounts.set(originalFileId, 0);
}
filePageCounts.set(originalFileId, filePageCounts.get(originalFileId)! + 1);
}
});
// Only proceed if we have pages
if (targetPageRects.size === 0) {
return;
}
// Start page editor exit transition
// File card positions will be calculated after FileEditor renders
navActions.startPageEditorExitTransition({
isAnimating: true,
direction: 'exit',
targetPageRects, // Current page positions
fileCardRects, // Will be filled after FileEditor renders
filePageCounts,
editorScreenshotUrl: null,
editorScreenshotRect: null,
});
}, [currentView, navActions]);
return {
handleEntryTransition,
handleExitTransition,
};
}
@@ -0,0 +1,144 @@
import { useCallback } from 'react';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useFileState } from '@app/contexts/FileContext';
import { getThumbnailRect, getCenteredFallbackRect, getContainedImageRect } from '@app/utils/dom';
import type { WorkbenchType } from '@app/types/workbench';
import type { FileId } from '@app/types/fileContext';
interface UseViewerTransitionParams {
activeFileIndex: number;
currentView: WorkbenchType;
captureScreenshot: () => Promise<string | null>;
getScreenshotRect?: () => DOMRect | null;
}
interface ViewerTransitionHandlers {
handleEntryTransition: (fileId?: FileId, sourceRect?: DOMRect) => Promise<void>;
handleExitTransition: () => void;
}
/**
* Custom hook to handle viewer entry and exit transitions
* Extracts transition orchestration logic from Workbench component
*/
export function useViewerTransition({
activeFileIndex,
currentView,
captureScreenshot,
getScreenshotRect,
}: UseViewerTransitionParams): ViewerTransitionHandlers {
const { actions: navActions } = useNavigationActions();
const { selectors } = useFileState();
const getActiveFile = () => selectors.getFiles()[activeFileIndex];
/**
* Handle entry transition (fileEditor/pageEditor → viewer)
* Captures screenshot, finds source thumbnail, starts zoom animation
*/
const handleEntryTransition = useCallback(
async (fileId?: FileId, sourceRect?: DOMRect) => {
if (currentView !== 'fileEditor' && currentView !== 'pageEditor') {
return;
}
// Capture screenshot for smooth fade during transition
let screenshot: string | null = null;
const screenshotRect = currentView === 'pageEditor' ? getScreenshotRect?.() ?? null : null;
if (currentView === 'fileEditor' || currentView === 'pageEditor') {
try {
screenshot = await captureScreenshot();
} catch {
screenshot = null;
}
}
// Use passed fileId to find the file directly
let targetFileId = fileId;
if (!targetFileId) {
// Fallback to activeFile if no fileId passed
const activeFile = getActiveFile();
targetFileId = activeFile?.fileId;
}
if (!targetFileId) {
return; // Can't animate without knowing which file
}
// Find file stub directly by ID (no state dependency)
const activeStub = selectors.getStirlingFileStub(targetFileId);
const thumbnailUrl = activeStub?.thumbnailUrl || '';
if (!thumbnailUrl) {
return; // Can't animate without thumbnail
}
// Use passed sourceRect if available, otherwise search DOM
let rect = sourceRect;
if (!rect) {
if (currentView === 'pageEditor') {
// In page editor, find the first page thumbnail image
const firstPageThumbnail = document.querySelector('[data-page-number="1"]') as HTMLElement | null;
if (firstPageThumbnail) {
const img = firstPageThumbnail.querySelector('img') as HTMLImageElement | null;
// Calculate the actual rendered image size (accounting for objectFit: contain)
rect = img ? getContainedImageRect(img) : firstPageThumbnail.getBoundingClientRect();
} else {
rect = getCenteredFallbackRect();
}
} else {
// In file editor, find the file card
const fileCard = document.querySelector(`[data-file-id="${targetFileId}"]`) as HTMLElement | null;
rect = fileCard ? getThumbnailRect(fileCard) : getCenteredFallbackRect();
}
}
navActions.startViewerTransition(
rect,
thumbnailUrl,
currentView,
screenshot || undefined,
screenshotRect || undefined
);
},
[currentView, activeFileIndex, selectors, captureScreenshot, navActions, getScreenshotRect]
);
/**
* Handle exit transition (viewer → fileEditor/pageEditor)
* Finds PDF page position, finds target thumbnail, starts reverse zoom animation
*/
const handleExitTransition = useCallback(
(targetView: 'fileEditor' | 'pageEditor') => {
if (currentView !== 'viewer') {
return;
}
// Don't animate when returning to pageEditor
if (targetView === 'pageEditor') {
return;
}
const activeFile = getActiveFile();
const activeStub = activeFile ? selectors.getStirlingFileStub(activeFile.fileId) : null;
const thumbnailUrl = activeStub?.thumbnailUrl || '';
if (activeFile && thumbnailUrl) {
// Find current PDF page position (still in DOM)
const pdfPageElement = document.querySelector('[data-page-index="0"]');
const exitTargetRect = pdfPageElement
? pdfPageElement.getBoundingClientRect()
: getCenteredFallbackRect();
// Start exit transition - file card position will be found after fileEditor renders
navActions.startExitTransition(exitTargetRect, thumbnailUrl, activeFile.fileId);
}
},
[currentView, activeFileIndex, selectors, navActions]
);
return {
handleEntryTransition,
handleExitTransition,
};
}
@@ -36,6 +36,7 @@ export class ThumbnailGenerationService {
// Session-based thumbnail cache
private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
private maxCacheEntries = 20; // Hard cap to avoid runaway memory
private currentCacheSize = 0;
// PDF document cache to reuse PDF instances and avoid creating multiple workers
@@ -228,6 +229,16 @@ export class ThumbnailGenerationService {
}
addThumbnailToCache(pageId: string, thumbnail: string): void {
const existing = this.thumbnailCache.get(pageId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
while (this.thumbnailCache.size >= this.maxCacheEntries) {
this.evictLeastRecentlyUsed();
}
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
// Enforce cache size limits
@@ -265,7 +276,8 @@ export class ThumbnailGenerationService {
return {
size: this.thumbnailCache.size,
sizeBytes: this.currentCacheSize,
maxSizeBytes: this.maxCacheSizeBytes
maxSizeBytes: this.maxCacheSizeBytes,
maxEntries: this.maxCacheEntries
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* DOM utility functions
*/
/**
* Find a thumbnail image inside a file card element and return its bounding rect
* Falls back to the card's own rect if no image is found
*
* @param cardElement - The file card element to search within
* @returns The bounding rect of the thumbnail image, or the card itself if no image found
*/
export function getThumbnailRect(cardElement: Element): DOMRect {
const thumbnailImg = cardElement.querySelector('img') as HTMLImageElement;
return thumbnailImg
? thumbnailImg.getBoundingClientRect()
: cardElement.getBoundingClientRect();
}
/**
* Returns a centered fallback rectangle for animations when a DOM element is missing.
*/
export function getCenteredFallbackRect(): DOMRect {
const width = 200;
const height = 260;
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
return new DOMRect(centerX - width / 2, centerY - height / 2, width, height);
}
/**
* Calculate the actual rendered image rect for an img element with objectFit: contain
* Accounts for letterboxing/pillarboxing when aspect ratios don't match
*
* @param img - The image element with objectFit: contain
* @returns The bounding rect of the actual rendered image (excluding empty space)
*/
export function getContainedImageRect(img: HTMLImageElement): DOMRect {
const imgRect = img.getBoundingClientRect();
// If image hasn't loaded yet, return the full rect
if (!img.naturalWidth || !img.naturalHeight) {
return imgRect;
}
const naturalRatio = img.naturalWidth / img.naturalHeight;
const displayRatio = imgRect.width / imgRect.height;
let actualWidth, actualHeight, offsetX, offsetY;
if (naturalRatio > displayRatio) {
// Image is wider - constrained by width (letterboxed top/bottom)
actualWidth = imgRect.width;
actualHeight = imgRect.width / naturalRatio;
offsetX = 0;
offsetY = (imgRect.height - actualHeight) / 2;
} else {
// Image is taller - constrained by height (pillarboxed left/right)
actualHeight = imgRect.height;
actualWidth = imgRect.height * naturalRatio;
offsetX = (imgRect.width - actualWidth) / 2;
offsetY = 0;
}
return new DOMRect(
imgRect.left + offsetX,
imgRect.top + offsetY,
actualWidth,
actualHeight
);
}
+42
View File
@@ -0,0 +1,42 @@
import { domToBlob } from 'modern-screenshot';
interface ScreenshotOptions {
filter?: (node: Node) => boolean;
width?: number;
height?: number;
restoreScrollPosition?: boolean;
}
/**
* Capture a screenshot of a DOM element as a data URL
*
* @param element - The DOM element to capture
* @returns Promise resolving to an object URL of the screenshot, or null if capture fails
*/
export async function captureElementScreenshot(
element: HTMLElement,
options: ScreenshotOptions = {}
): Promise<string | null> {
try {
const {
filter,
width = window.innerWidth,
height = window.innerHeight,
restoreScrollPosition = true,
} = options;
const blob = await domToBlob(element, {
width,
height,
style: {
transform: 'none',
},
filter,
features: { restoreScrollPosition },
});
return URL.createObjectURL(blob);
} catch (error) {
console.warn('Failed to capture screenshot:', error);
return null;
}
}