viewer to file editor transition

This commit is contained in:
Reece
2026-01-07 16:26:51 +00:00
parent 4ccd950fe4
commit 5aee8fe6ee
3 changed files with 181 additions and 31 deletions
@@ -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
@@ -16,6 +16,8 @@ export const ViewerZoomTransition: React.FC = () => {
const [animationPhase, setAnimationPhase] = useState<'idle' | 'searching' | 'zooming'>('idle');
const [targetRect, setTargetRect] = useState<DOMRect | null>(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<DOMRect | null> => {
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<DOMRect | null> => {
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"
>
@@ -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 = {