viewer to file editor transition
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user