diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 025f990ad7..17a7792682 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -115,7 +115,7 @@ export default function Workbench() { // Handle exit transition (viewer → fileEditor/pageEditor) if ((view === 'fileEditor' || view === 'pageEditor') && currentView === 'viewer') { - handleExitTransition(); + handleExitTransition(view); } navActions.setWorkbench(view); diff --git a/frontend/src/core/hooks/useViewerTransition.ts b/frontend/src/core/hooks/useViewerTransition.ts index 367ad7b1b7..09c6cd9279 100644 --- a/frontend/src/core/hooks/useViewerTransition.ts +++ b/frontend/src/core/hooks/useViewerTransition.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useNavigationActions } from '@app/contexts/NavigationContext'; import { useFileState } from '@app/contexts/FileContext'; -import { getThumbnailRect, getCenteredFallbackRect } from '@app/utils/dom'; +import { getThumbnailRect, getCenteredFallbackRect, getContainedImageRect } from '@app/utils/dom'; import type { WorkbenchType } from '@app/types/workbench'; import type { FileId } from '@app/types/fileContext'; @@ -39,11 +39,15 @@ export function useViewerTransition({ return; } + // Only capture screenshot for fileEditor transitions + // pageEditor doesn't need it since we're animating the thumbnail directly let screenshot: string | null = null; - try { - screenshot = await captureScreenshot(); - } catch { - screenshot = null; + if (currentView === 'fileEditor') { + try { + screenshot = await captureScreenshot(); + } catch { + screenshot = null; + } } // Use passed fileId to find the file directly @@ -69,8 +73,21 @@ export function useViewerTransition({ // 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(); + if (currentView === 'pageEditor') { + // In page editor, find the first page thumbnail image + const firstPageThumbnail = document.querySelector('[data-page-number="1"]') as HTMLElement | null; + if (firstPageThumbnail) { + const img = firstPageThumbnail.querySelector('img') as HTMLImageElement | null; + // Calculate the actual rendered image size (accounting for objectFit: contain) + rect = img ? getContainedImageRect(img) : firstPageThumbnail.getBoundingClientRect(); + } else { + rect = getCenteredFallbackRect(); + } + } else { + // In file editor, find the file card + const fileCard = document.querySelector(`[data-file-id="${targetFileId}"]`) as HTMLElement | null; + rect = fileCard ? getThumbnailRect(fileCard) : getCenteredFallbackRect(); + } } navActions.startViewerTransition( @@ -88,11 +105,16 @@ export function useViewerTransition({ * Finds PDF page position, finds target thumbnail, starts reverse zoom animation */ const handleExitTransition = useCallback( - () => { + (targetView: 'fileEditor' | 'pageEditor') => { if (currentView !== 'viewer') { return; } + // Don't animate when returning to pageEditor + if (targetView === 'pageEditor') { + return; + } + const activeFile = getActiveFile(); const activeStub = activeFile ? selectors.getStirlingFileStub(activeFile.fileId) : null; const thumbnailUrl = activeStub?.thumbnailUrl || ''; diff --git a/frontend/src/core/utils/dom.ts b/frontend/src/core/utils/dom.ts index 87522599ba..a1a5cef595 100644 --- a/frontend/src/core/utils/dom.ts +++ b/frontend/src/core/utils/dom.ts @@ -26,3 +26,45 @@ export function getCenteredFallbackRect(): DOMRect { const centerY = window.innerHeight / 2; return new DOMRect(centerX - width / 2, centerY - height / 2, width, height); } + +/** + * Calculate the actual rendered image rect for an img element with objectFit: contain + * Accounts for letterboxing/pillarboxing when aspect ratios don't match + * + * @param img - The image element with objectFit: contain + * @returns The bounding rect of the actual rendered image (excluding empty space) + */ +export function getContainedImageRect(img: HTMLImageElement): DOMRect { + const imgRect = img.getBoundingClientRect(); + + // If image hasn't loaded yet, return the full rect + if (!img.naturalWidth || !img.naturalHeight) { + return imgRect; + } + + const naturalRatio = img.naturalWidth / img.naturalHeight; + const displayRatio = imgRect.width / imgRect.height; + + let actualWidth, actualHeight, offsetX, offsetY; + + if (naturalRatio > displayRatio) { + // Image is wider - constrained by width (letterboxed top/bottom) + actualWidth = imgRect.width; + actualHeight = imgRect.width / naturalRatio; + offsetX = 0; + offsetY = (imgRect.height - actualHeight) / 2; + } else { + // Image is taller - constrained by height (pillarboxed left/right) + actualHeight = imgRect.height; + actualWidth = imgRect.height * naturalRatio; + offsetX = (imgRect.width - actualWidth) / 2; + offsetY = 0; + } + + return new DOMRect( + imgRect.left + offsetX, + imgRect.top + offsetY, + actualWidth, + actualHeight + ); +}