diff --git a/frontend/src/core/components/fileEditor/FileEditor.module.css b/frontend/src/core/components/fileEditor/FileEditor.module.css index 95c70c60a2..fd60cde178 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/src/core/components/fileEditor/FileEditor.module.css @@ -400,14 +400,3 @@ opacity: 0.8; } -/* ========================= - Viewer Transition Fade - ========================= */ - -.fileEditorContainer { - transition: opacity 300ms ease-out; -} - -.fileEditorFadingOut { - opacity: 0; -} diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index 645c179aef..975dc65af2 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,11 +1,11 @@ import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import html2canvas from 'html2canvas'; import { Text, Center, Box, LoadingOverlay, Stack } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext'; -import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +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'; @@ -16,19 +16,18 @@ import { alert } from '@app/components/toast'; import { downloadBlob } from '@app/utils/downloadUtils'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import styles from './FileEditor.module.css'; 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 @@ -50,13 +49,15 @@ const FileEditor = ({ const totalItems = state.files.ids.length; const selectedCount = selectedFileIds.length; - // Get navigation actions and state + // Get navigation actions const { actions: navActions } = useNavigationActions(); - const { viewerTransition } = useNavigationState(); // Get file selection context const { setSelectedFiles } = useFileSelection(); + // Get viewer context for active file index + const { setActiveFileIndex } = useViewer(); + const [_status, _setStatus] = useState(null); const [_error, _setError] = useState(null); @@ -69,8 +70,6 @@ const FileEditor = ({ }, []); const [selectionMode, setSelectionMode] = useState(toolMode); - // Ref for capturing screenshot during transition - const containerRef = useRef(null); // Current tool (for enforcing maxFiles limits) const { selectedTool } = useToolWorkflow(); @@ -334,50 +333,28 @@ const FileEditor = ({ } }, [activeStirlingFileStubs, selectors, fileActions, removeFiles]); - const captureScreenshot = useCallback(async (): Promise => { - if (!containerRef.current) return null; - - try { - const canvas = await html2canvas(containerRef.current, { - backgroundColor: null, - scale: 1, - logging: false, - useCORS: true, - width: window.innerWidth, - height: window.innerHeight, - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - x: 0, - y: 0, - }); - - return canvas.toDataURL('image/png'); - } catch (error) { - console.warn('Failed to capture screenshot:', error); - return null; - } - }, []); - 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 + // Find the index of the clicked file + const fileIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId); + + // Set the file as selected and active setSelectedFiles([fileId]); - - // If we have a source element and thumbnail, start the zoom animation - if (sourceElement && record.thumbnailUrl) { - const sourceRect = sourceElement.getBoundingClientRect(); - - // Capture screenshot for smooth transition - const screenshot = await captureScreenshot(); - - navActions.startViewerTransition(sourceRect, record.thumbnailUrl, 'fileEditor', screenshot || undefined); + if (fileIndex !== -1) { + setActiveFileIndex(fileIndex); } - // Switch to viewer immediately - screenshot will show during search - navActions.setWorkbench('viewer'); + 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, captureScreenshot]); + }, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions, onOpenViewer]); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; @@ -407,7 +384,6 @@ const FileEditor = ({ activateOnDrag={true} > => { if (!mainContentRef.current) return null; - - try { - const canvas = await html2canvas(mainContentRef.current, { - backgroundColor: null, - scale: 1, - logging: false, - useCORS: true, - width: window.innerWidth, - height: window.innerHeight, - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - x: 0, - y: 0, - }); - - return canvas.toDataURL('image/png'); - } catch (error) { - console.warn('Failed to capture screenshot:', error); - return null; - } + return captureElementScreenshot(mainContentRef.current); }, []); - // Wrapper for setCurrentView that adds transition when switching to viewer - const setCurrentView = useCallback(async (view: typeof currentView) => { - // If switching to viewer from fileEditor or pageEditor, add transition + // Get transition handlers + const { handleEntryTransition, handleExitTransition } = useViewerTransition({ + activeFileIndex, + currentView, + captureScreenshot: captureMainContentScreenshot, + }); + + // 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')) { - const screenshot = await captureMainContentScreenshot(); - - // Get the active file's thumbnail for the zoom animation - const activeFile = activeFiles[activeFileIndex]; - const activeStub = activeFile ? selectors.getStirlingFileStub(activeFile.fileId) : null; - const thumbnailUrl = activeStub?.thumbnailUrl || ''; - - if (screenshot && thumbnailUrl && activeFile) { - // Find the actual file card element in the DOM - const fileCard = document.querySelector(`[data-file-id="${activeFile.fileId}"]`) as HTMLElement; - - let sourceRect: DOMRect; - - if (fileCard) { - // Find the thumbnail image inside the card - const thumbnailImg = fileCard.querySelector('img') as HTMLImageElement; - if (thumbnailImg) { - sourceRect = thumbnailImg.getBoundingClientRect(); - } else { - // Fallback to card position if no image found - sourceRect = fileCard.getBoundingClientRect(); - } - } else { - // Fallback to center if card not found (might be scrolled out of view) - const centerX = window.innerWidth / 2; - const centerY = window.innerHeight / 2; - const thumbnailSize = 200; - - sourceRect = new DOMRect( - centerX - thumbnailSize / 2, - centerY - thumbnailSize / 2, - thumbnailSize, - thumbnailSize - ); - } - - navActions.startViewerTransition( - sourceRect, - thumbnailUrl, - currentView, - screenshot - ); - } + await handleEntryTransition(fileId, sourceRect); } - // If switching from viewer to fileEditor or pageEditor, add exit transition + // Handle exit transition (viewer → fileEditor/pageEditor) if ((view === 'fileEditor' || view === 'pageEditor') && currentView === 'viewer') { - const activeFile = activeFiles[activeFileIndex]; - 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"]'); - - if (pdfPageElement) { - const exitTargetRect = pdfPageElement.getBoundingClientRect(); - - // Start exit transition - file card position will be found after fileEditor renders - navActions.startExitTransition(exitTargetRect, thumbnailUrl, activeFile.fileId); - } - } + handleExitTransition(); } navActions.setWorkbench(view); - }, [currentView, navActions, captureMainContentScreenshot, activeFiles, activeFileIndex, selectors, viewerTransition]); + }, [currentView, navActions, handleEntryTransition, handleExitTransition]); const renderMainContent = () => { // During viewer transition with screenshot, show screenshot overlay @@ -200,7 +135,7 @@ export default function Workbench() { /> ); - // Screenshot fades out in 200ms when zoom starts + // Screenshot fades out when zoom starts const screenshotOverlay = (
@@ -263,15 +200,9 @@ export default function Workbench() { { - setCurrentView("pageEditor"); - }, - onMergeFiles: (filesToMerge) => { - addFiles(filesToMerge); - setCurrentView("viewer"); - } - })} + onOpenViewer={(fileId, sourceRect) => { + setCurrentView("viewer", fileId, sourceRect); + }} /> ); diff --git a/frontend/src/core/components/viewer/ViewerZoomTransition.module.css b/frontend/src/core/components/viewer/ViewerZoomTransition.module.css index 593d075862..a2c9e36767 100644 --- a/frontend/src/core/components/viewer/ViewerZoomTransition.module.css +++ b/frontend/src/core/components/viewer/ViewerZoomTransition.module.css @@ -1,9 +1,9 @@ /* ViewerZoomTransition.module.css - Smooth zoom animation for viewer transitions */ -/* Animation timing - matches component duration */ +/* Animation timing - synced with constants/animations.ts */ :root { - --viewer-zoom-duration: 400ms; - --viewer-zoom-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* easeOutQuad */ + --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 */ @@ -28,8 +28,12 @@ /* Thumbnail that zooms from source position to PDF page */ .thumbnail { position: fixed; - transition: all var(--viewer-zoom-duration) var(--viewer-zoom-easing); - will-change: transform, width, height, top, left, border-radius; + 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); } diff --git a/frontend/src/core/components/viewer/ViewerZoomTransition.tsx b/frontend/src/core/components/viewer/ViewerZoomTransition.tsx index 39028831ab..4311908e73 100644 --- a/frontend/src/core/components/viewer/ViewerZoomTransition.tsx +++ b/frontend/src/core/components/viewer/ViewerZoomTransition.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; -import styles from './ViewerZoomTransition.module.css'; +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 @@ -15,8 +17,8 @@ export const ViewerZoomTransition: React.FC = () => { const { actions } = useNavigationActions(); const [animationPhase, setAnimationPhase] = useState<'idle' | 'searching' | 'zooming'>('idle'); const [targetRect, setTargetRect] = useState(null); - const isExitTransition = viewerTransition.transitionDirection === 'exit'; + const getFallbackRect = () => getCenteredFallbackRect(); useEffect(() => { let transitionTimer: number | null = null; @@ -33,165 +35,133 @@ export const ViewerZoomTransition: React.FC = () => { }; } - if (isExitTransition) { - // EXIT TRANSITION: Search for file card in fileEditor, then zoom - setAnimationPhase('searching'); + setAnimationPhase('searching'); - const waitForFileCard = async (): Promise => { + if (isExitTransition) { + // EXIT: start at PDF page rect (already captured), zoom to file card + const waitForFileCard = async (): Promise => { const maxAttempts = 20; const delayMs = 50; const fileId = viewerTransition.exitFileId; + if (!fileId) return getFallbackRect(); - if (!fileId) { - console.warn('ViewerZoomTransition: No exitFileId for exit transition'); - return null; - } - - // Step 1: Find the file card element - let fileCard: Element | null = null; + let card: Element | null = null; for (let i = 0; i < maxAttempts; i++) { - fileCard = document.querySelector(`[data-file-id="${fileId}"]`); - if (fileCard) break; + card = document.querySelector(`[data-file-id="${fileId}"]`); + if (card) break; await new Promise(resolve => setTimeout(resolve, delayMs)); } - if (!fileCard) { - console.warn('ViewerZoomTransition: File card not found for exit transition'); - // Fallback to center position - const centerX = window.innerWidth / 2; - const centerY = window.innerHeight / 2; - const thumbnailSize = 200; - return new DOMRect( - centerX - thumbnailSize / 2, - centerY - thumbnailSize / 2, - thumbnailSize, - thumbnailSize - ); + if (!card) { + console.warn('ViewerZoomTransition: file card not found, using fallback rect'); + return getFallbackRect(); } - // Step 2: Find thumbnail image inside card - const thumbnailImg = fileCard.querySelector('img') as HTMLImageElement; - if (thumbnailImg) { - return thumbnailImg.getBoundingClientRect(); - } else { - // Fallback to card position if no image found - return fileCard.getBoundingClientRect(); - } + const img = card.querySelector('img') as HTMLImageElement | null; + return img ? img.getBoundingClientRect() : card.getBoundingClientRect(); }; - waitForFileCard().then(fileCardRect => { - if (!fileCardRect || !isMounted) { - actions.endViewerTransition(); - return; - } + waitForFileCard().then(cardRect => { + if (!isMounted) return; - // Found file card - start animation actions.startZoom(); - setTargetRect(fileCardRect); + setTargetRect(cardRect); setAnimationPhase('zooming'); - const zoomDuration = 400; - transitionTimer = window.setTimeout(() => { if (!isMounted) return; actions.endViewerTransition(); - }, zoomDuration); + }, VIEWER_TRANSITION.ZOOM_DURATION); }); } else { - // ENTRY TRANSITION: Search for PDF page, then zoom - setAnimationPhase('searching'); + const waitForPdfPage = async (): Promise => { + const maxAttempts = 20; + const delayMs = 50; - const waitForPdfPage = async (): Promise => { - 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'); - return null; - } - - // 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; - } + // 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)); } - previousSize = currentSize; - await new Promise(resolve => setTimeout(resolve, 50)); - } + if (!pageElement) { + console.warn('ViewerZoomTransition: PDF page not found, using fallback rect'); + return viewerTransition.sourceRect || getFallbackRect(); + } - // Use stable rect if found, otherwise use latest measurement - return stableRect || pageElement.getBoundingClientRect(); - }; + // 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; - waitForPdfPage().then(pdfPageRect => { - if (!pdfPageRect || !isMounted) { - actions.endViewerTransition(); - return; - } + for (let i = 0; i < 10; i++) { // Max 500ms to stabilize + const currentRect = pageElement.getBoundingClientRect(); + const currentSize = { + width: currentRect.width, + height: currentRect.height + }; - // Found PDF page - trigger screenshot fade and start animation - actions.startZoom(); // Triggers 200ms fade of screenshot - setTargetRect(pdfPageRect); - setAnimationPhase('zooming'); + // Check if size has changed from previous measurement + const sizeChanged = Math.abs(currentSize.width - previousSize.width) > 1 || + Math.abs(currentSize.height - previousSize.height) > 1; - const zoomDuration = 400; - - // After zoom completes, wait for PDF to be fully rendered before removing thumbnail - transitionTimer = window.setTimeout(async () => { - if (!isMounted) return; - - // Wait for PDF to be ready (check for rendered canvas or content) - let pdfReady = false; - 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) { - pdfReady = true; + 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'); + + const zoomDuration = VIEWER_TRANSITION.ZOOM_DURATION; + + // After zoom completes, wait for PDF to be fully rendered before removing thumbnail + transitionTimer = window.setTimeout(async () => { + if (!isMounted) return; + + // 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) - actions.endViewerTransition(); - }, zoomDuration); - }); + actions.endViewerTransition(); + }, zoomDuration); + }); } return () => { @@ -200,71 +170,46 @@ export const ViewerZoomTransition: React.FC = () => { clearTimeout(transitionTimer); } }; - }, [viewerTransition.isAnimating, actions, isExitTransition]); + }, [viewerTransition.isAnimating, viewerTransition.exitFileId, viewerTransition.sourceRect, isExitTransition, actions]); - // Don't render if not animating - if (!viewerTransition.isAnimating) { + // Don't render if not animating or missing thumbnail + if (!viewerTransition.isAnimating || !viewerTransition.sourceThumbnailUrl) { return null; } - // Must have thumbnail URL - if (!viewerTransition.sourceThumbnailUrl) { - return null; - } + const { sourceThumbnailUrl } = viewerTransition; + const initialRect = isExitTransition + ? (viewerTransition.exitTargetRect || viewerTransition.sourceRect || getFallbackRect()) + : (viewerTransition.sourceRect || getFallbackRect()); - const { sourceThumbnailUrl, exitTargetRect, sourceRect } = viewerTransition; + const initialRadius = isExitTransition ? '0' : '8px'; + const targetRadius = isExitTransition ? '8px' : '0'; + const initialWidth = Math.max(initialRect.width, 1); + const initialHeight = Math.max(initialRect.height, 1); - // Calculate styles based on direction - let initialStyle: React.CSSProperties; - let finalStyle: React.CSSProperties | undefined; + // 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', + }; - if (isExitTransition && exitTargetRect) { - // EXIT: Start at PDF page (exitTargetRect), end at file card (targetRect) - initialStyle = { - position: 'fixed', - top: `${exitTargetRect.top}px`, - left: `${exitTargetRect.left}px`, - width: `${exitTargetRect.width}px`, - height: `${exitTargetRect.height}px`, - borderRadius: '0', - overflow: 'hidden', - zIndex: 10000, - }; - - finalStyle = targetRect ? { - top: `${targetRect.top}px`, - left: `${targetRect.left}px`, - width: `${targetRect.width}px`, - height: `${targetRect.height}px`, - borderRadius: '8px', - } : undefined; - } else if (sourceRect) { - // ENTRY: Start at source thumbnail, end at PDF page (existing logic) - initialStyle = { - position: 'fixed', - top: `${sourceRect.top}px`, - left: `${sourceRect.left}px`, - width: `${sourceRect.width}px`, - height: `${sourceRect.height}px`, - borderRadius: '8px', - overflow: 'hidden', - zIndex: 10000, - }; - - finalStyle = targetRect ? { - top: `${targetRect.top}px`, - left: `${targetRect.left}px`, - width: `${targetRect.width}px`, - height: `${targetRect.height}px`, - borderRadius: '0', - } : undefined; - } else { - // No position info available - return null; - } + 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' && finalStyle; + const shouldZoom = animationPhase === 'zooming' && targetTransform !== null; + const zoomStyle: React.CSSProperties = shouldZoom && targetTransform + ? { transform: targetTransform, borderRadius: targetRadius } + : {}; // Build class names for thumbnail const thumbnailClasses = [ @@ -279,7 +224,7 @@ export const ViewerZoomTransition: React.FC = () => { className={thumbnailClasses} style={{ ...initialStyle, - ...(shouldZoom ? finalStyle : {}), + ...zoomStyle, }} aria-hidden="true" > diff --git a/frontend/src/core/constants/animations.ts b/frontend/src/core/constants/animations.ts new file mode 100644 index 0000000000..3517783e7c --- /dev/null +++ b/frontend/src/core/constants/animations.ts @@ -0,0 +1,18 @@ +/** + * 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, + + /** 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; diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index ff537cb323..f5f3ce3e4d 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -83,7 +83,8 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi editorScreenshotUrl: action.payload.editorScreenshotUrl || null, isZooming: false, transitionDirection: 'enter', - exitTargetRect: null + exitTargetRect: null, + exitFileId: null } }; diff --git a/frontend/src/core/hooks/useViewerTransition.ts b/frontend/src/core/hooks/useViewerTransition.ts new file mode 100644 index 0000000000..367ad7b1b7 --- /dev/null +++ b/frontend/src/core/hooks/useViewerTransition.ts @@ -0,0 +1,119 @@ +import { useCallback } from 'react'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; +import { useFileState } from '@app/contexts/FileContext'; +import { getThumbnailRect, getCenteredFallbackRect } 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; +} + +interface ViewerTransitionHandlers { + handleEntryTransition: (fileId?: FileId, sourceRect?: DOMRect) => Promise; + handleExitTransition: () => void; +} + +/** + * Custom hook to handle viewer entry and exit transitions + * Extracts transition orchestration logic from Workbench component + */ +export function useViewerTransition({ + activeFileIndex, + currentView, + captureScreenshot, +}: 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; + } + + let screenshot: string | null = null; + 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) { + const fileCard = document.querySelector(`[data-file-id="${targetFileId}"]`) as HTMLElement | null; + rect = fileCard ? getThumbnailRect(fileCard) : getCenteredFallbackRect(); + } + + navActions.startViewerTransition( + rect, + thumbnailUrl, + currentView, + screenshot || undefined + ); + }, + [currentView, activeFileIndex, selectors, captureScreenshot, navActions] + ); + + /** + * Handle exit transition (viewer → fileEditor/pageEditor) + * Finds PDF page position, finds target thumbnail, starts reverse zoom animation + */ + const handleExitTransition = useCallback( + () => { + if (currentView !== 'viewer') { + 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, + }; +} diff --git a/frontend/src/core/utils/dom.ts b/frontend/src/core/utils/dom.ts new file mode 100644 index 0000000000..87522599ba --- /dev/null +++ b/frontend/src/core/utils/dom.ts @@ -0,0 +1,28 @@ +/** + * 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); +} diff --git a/frontend/src/core/utils/screenshot.ts b/frontend/src/core/utils/screenshot.ts new file mode 100644 index 0000000000..97a47065fb --- /dev/null +++ b/frontend/src/core/utils/screenshot.ts @@ -0,0 +1,29 @@ +import html2canvas from 'html2canvas'; + +/** + * Capture a screenshot of a DOM element as a data URL + * + * @param element - The DOM element to capture + * @returns Promise resolving to a data URL of the screenshot, or null if capture fails + */ +export async function captureElementScreenshot(element: HTMLElement): Promise { + try { + const canvas = await html2canvas(element, { + backgroundColor: null, + scale: 1, + logging: false, + useCORS: true, + width: window.innerWidth, + height: window.innerHeight, + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + x: 0, + y: 0, + }); + + return canvas.toDataURL('image/png'); + } catch (error) { + console.warn('Failed to capture screenshot:', error); + return null; + } +}