diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index c5bbc98fb5..5f56999e45 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -164,8 +164,27 @@ export default function Workbench() { } } + // If switching from viewer to fileEditor or pageEditor, add exit transition + 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); + } + } + } + navActions.setWorkbench(view); - }, [currentView, navActions, captureMainContentScreenshot, activeFiles, activeFileIndex, selectors]); + }, [currentView, navActions, captureMainContentScreenshot, activeFiles, activeFileIndex, selectors, viewerTransition]); const renderMainContent = () => { // During viewer transition with screenshot, show screenshot overlay diff --git a/frontend/src/core/components/viewer/ViewerZoomTransition.tsx b/frontend/src/core/components/viewer/ViewerZoomTransition.tsx index 6c9114f309..39028831ab 100644 --- a/frontend/src/core/components/viewer/ViewerZoomTransition.tsx +++ b/frontend/src/core/components/viewer/ViewerZoomTransition.tsx @@ -16,6 +16,8 @@ export const ViewerZoomTransition: React.FC = () => { const [animationPhase, setAnimationPhase] = useState<'idle' | 'searching' | 'zooming'>('idle'); const [targetRect, setTargetRect] = useState(null); + const isExitTransition = viewerTransition.transitionDirection === 'exit'; + useEffect(() => { let transitionTimer: number | null = null; let isMounted = true; @@ -31,7 +33,73 @@ export const ViewerZoomTransition: React.FC = () => { }; } - setAnimationPhase('searching'); + if (isExitTransition) { + // EXIT TRANSITION: Search for file card in fileEditor, then zoom + setAnimationPhase('searching'); + + const waitForFileCard = async (): Promise => { + const maxAttempts = 20; + const delayMs = 50; + const fileId = viewerTransition.exitFileId; + + if (!fileId) { + console.warn('ViewerZoomTransition: No exitFileId for exit transition'); + return null; + } + + // Step 1: Find the file card element + let fileCard: Element | null = null; + for (let i = 0; i < maxAttempts; i++) { + fileCard = document.querySelector(`[data-file-id="${fileId}"]`); + if (fileCard) 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 + ); + } + + // 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(); + } + }; + + waitForFileCard().then(fileCardRect => { + if (!fileCardRect || !isMounted) { + actions.endViewerTransition(); + return; + } + + // Found file card - start animation + actions.startZoom(); + setTargetRect(fileCardRect); + setAnimationPhase('zooming'); + + const zoomDuration = 400; + + transitionTimer = window.setTimeout(() => { + if (!isMounted) return; + actions.endViewerTransition(); + }, zoomDuration); + }); + } else { + // ENTRY TRANSITION: Search for PDF page, then zoom + setAnimationPhase('searching'); const waitForPdfPage = async (): Promise => { const maxAttempts = 20; @@ -120,10 +188,11 @@ export const ViewerZoomTransition: React.FC = () => { await new Promise(resolve => setTimeout(resolve, 50)); } - // Remove thumbnail now that PDF is ready (or timeout reached) + // Remove thumbnail now that PDF is ready (or timeout reached) actions.endViewerTransition(); }, zoomDuration); }); + } return () => { isMounted = false; @@ -131,43 +200,71 @@ export const ViewerZoomTransition: React.FC = () => { clearTimeout(transitionTimer); } }; - }, [viewerTransition.isAnimating, actions]); + }, [viewerTransition.isAnimating, actions, isExitTransition]); // Don't render if not animating if (!viewerTransition.isAnimating) { return null; } - // If no sourceRect or thumbnail, we're transitioning from TopControls (screenshot only, no thumbnail zoom) - if (!viewerTransition.sourceRect || !viewerTransition.sourceThumbnailUrl) { + // Must have thumbnail URL + if (!viewerTransition.sourceThumbnailUrl) { return null; } - const { sourceRect, sourceThumbnailUrl } = viewerTransition; + const { sourceThumbnailUrl, exitTargetRect, sourceRect } = viewerTransition; - // Calculate initial styles based on source element position - const initialStyle: React.CSSProperties = { - position: 'fixed', - top: `${sourceRect.top}px`, - left: `${sourceRect.left}px`, - width: `${sourceRect.width}px`, - height: `${sourceRect.height}px`, - borderRadius: '8px', - overflow: 'hidden', - zIndex: 10000, - }; + // Calculate styles based on direction + let initialStyle: React.CSSProperties; + let finalStyle: React.CSSProperties | undefined; - // Calculate target styles based on PDF page position (if found) - const targetStyle: React.CSSProperties | undefined = targetRect ? { - top: `${targetRect.top}px`, - left: `${targetRect.left}px`, - width: `${targetRect.width}px`, - height: `${targetRect.height}px`, - borderRadius: '0', - } : undefined; + 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; + } // Determine if we should apply zoom animation - const shouldZoom = animationPhase === 'zooming' && targetStyle; + const shouldZoom = animationPhase === 'zooming' && finalStyle; // Build class names for thumbnail const thumbnailClasses = [ @@ -182,7 +279,7 @@ export const ViewerZoomTransition: React.FC = () => { className={thumbnailClasses} style={{ ...initialStyle, - ...(shouldZoom ? targetStyle : {}), + ...(shouldZoom ? finalStyle : {}), }} aria-hidden="true" > diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index 83ba3501f5..ff537cb323 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -19,6 +19,9 @@ export interface ViewerTransitionState { transitionType: 'fileEditor' | 'pageEditor' | null; editorScreenshotUrl: string | null; isZooming: boolean; + transitionDirection: 'enter' | 'exit' | null; + exitTargetRect: DOMRect | null; + exitFileId: string | null; } // Navigation state @@ -41,7 +44,8 @@ type NavigationAction = | { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } } | { type: 'START_VIEWER_TRANSITION'; payload: { sourceRect: DOMRect; sourceThumbnailUrl: string; transitionType: 'fileEditor' | 'pageEditor'; editorScreenshotUrl?: string } } | { type: 'END_VIEWER_TRANSITION' } - | { type: 'START_ZOOM' }; + | { type: 'START_ZOOM' } + | { type: 'START_EXIT_TRANSITION'; payload: { exitTargetRect: DOMRect; sourceThumbnailUrl: string; exitFileId: string } }; // Navigation reducer const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => { @@ -77,6 +81,23 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi sourceThumbnailUrl: action.payload.sourceThumbnailUrl, transitionType: action.payload.transitionType, editorScreenshotUrl: action.payload.editorScreenshotUrl || null, + isZooming: false, + transitionDirection: 'enter', + exitTargetRect: 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 } }; @@ -90,7 +111,10 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi sourceThumbnailUrl: null, transitionType: null, editorScreenshotUrl: null, - isZooming: false + isZooming: false, + transitionDirection: null, + exitTargetRect: null, + exitFileId: null } }; @@ -121,7 +145,10 @@ const initialState: NavigationContextState = { sourceThumbnailUrl: null, transitionType: null, editorScreenshotUrl: null, - isZooming: false + isZooming: false, + transitionDirection: null, + exitTargetRect: null, + exitFileId: null } }; @@ -142,6 +169,7 @@ export interface NavigationContextActions { startViewerTransition: (sourceRect: DOMRect, sourceThumbnailUrl: string, transitionType: 'fileEditor' | 'pageEditor', editorScreenshotUrl?: string) => void; endViewerTransition: () => void; startZoom: () => void; + startExitTransition: (exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => void; } // Context state values @@ -338,6 +366,10 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'START_ZOOM' }); }, []); + const startExitTransition = useCallback((exitTargetRect: DOMRect, sourceThumbnailUrl: string, exitFileId: string) => { + dispatch({ type: 'START_EXIT_TRANSITION', payload: { exitTargetRect, sourceThumbnailUrl, exitFileId } }); + }, []); + // 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(() => ({ @@ -356,6 +388,7 @@ export const NavigationProvider: React.FC<{ startViewerTransition, endViewerTransition, startZoom, + startExitTransition, }), [ setWorkbench, setSelectedTool, @@ -372,6 +405,7 @@ export const NavigationProvider: React.FC<{ startViewerTransition, endViewerTransition, startZoom, + startExitTransition, ]); const stateValue: NavigationContextStateValue = {