Clean up and #smoothzoom

This commit is contained in:
Reece
2026-01-09 13:45:18 +00:00
parent 5aee8fe6ee
commit beac51c1b7
10 changed files with 382 additions and 342 deletions
@@ -400,14 +400,3 @@
opacity: 0.8;
}
/* =========================
Viewer Transition Fade
========================= */
.fileEditorContainer {
transition: opacity 300ms ease-out;
}
.fileEditorFadingOut {
opacity: 0;
}
@@ -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<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
@@ -69,8 +70,6 @@ const FileEditor = ({
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Ref for capturing screenshot during transition
const containerRef = useRef<HTMLDivElement>(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<string | null> => {
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}
>
<Box
ref={containerRef}
pos="relative"
style={{
overflow: 'auto',
@@ -1,6 +1,5 @@
import { useCallback, useRef } from 'react';
import { Box } from '@mantine/core';
import html2canvas from 'html2canvas';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFileHandler } from '@app/hooks/useFileHandler';
@@ -9,6 +8,10 @@ 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 styles from '@app/components/layout/Workbench.module.css';
import TopControls from '@app/components/shared/TopControls';
@@ -93,98 +96,30 @@ export default function Workbench() {
// Capture screenshot helper for TopControls transitions
const captureMainContentScreenshot = useCallback(async (): Promise<string | null> => {
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 = (
<div
style={{
@@ -210,7 +145,9 @@ export default function Workbench() {
width: window.innerWidth,
height: window.innerHeight,
opacity: viewerTransition.isZooming ? 0 : 1,
transition: viewerTransition.isZooming ? 'opacity 0.2s ease-out' : 'none',
transition: viewerTransition.isZooming
? `opacity ${VIEWER_TRANSITION.SCREENSHOT_FADE_DURATION}ms ease-out`
: 'none',
pointerEvents: 'none',
}}
>
@@ -263,15 +200,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);
}}
/>
);
@@ -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);
}
@@ -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<DOMRect | null>(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<DOMRect | null> => {
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();
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<DOMRect> => {
const maxAttempts = 20;
const delayMs = 50;
const waitForPdfPage = async (): Promise<DOMRect | null> => {
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"
>
+18
View File
@@ -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;
@@ -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
}
};
@@ -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<string | 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,
}: 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,
};
}
+28
View File
@@ -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);
}
+29
View File
@@ -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<string | null> {
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;
}
}