mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a5eebe17 | ||
|
|
a4480248e1 | ||
|
|
beac51c1b7 | ||
|
|
5aee8fe6ee | ||
|
|
4ccd950fe4 | ||
|
|
b63527c575 |
Generated
+7
@@ -57,6 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"modern-screenshot": "^4.4.39",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
@@ -10652,6 +10653,12 @@
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/modern-screenshot": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.7.tgz",
|
||||
"integrity": "sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/module-definition": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"modern-screenshot": "^4.4.39",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -399,3 +399,4 @@
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
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';
|
||||
@@ -18,15 +19,15 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
|
||||
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
|
||||
@@ -54,6 +55,9 @@ const FileEditor = ({
|
||||
// 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);
|
||||
|
||||
@@ -66,6 +70,7 @@ const FileEditor = ({
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
|
||||
// Current tool (for enforcing maxFiles limits)
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
|
||||
@@ -328,14 +333,28 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
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 and switch to viewer for preview
|
||||
// Find the index of the clicked file
|
||||
const fileIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
|
||||
// Set the file as selected and active
|
||||
setSelectedFiles([fileId]);
|
||||
navActions.setWorkbench('viewer');
|
||||
if (fileIndex !== -1) {
|
||||
setActiveFileIndex(fileIndex);
|
||||
}
|
||||
|
||||
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.setWorkbench]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions, onOpenViewer]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
@@ -364,7 +383,12 @@ const FileEditor = ({
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md">
|
||||
|
||||
@@ -35,7 +35,7 @@ interface FileEditorThumbnailProps {
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId, sourceElement?: HTMLElement) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
@@ -92,6 +92,7 @@ const FileEditorThumbnail = ({
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
const thumbnailRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
@@ -203,7 +204,7 @@ const FileEditorThumbnail = ({
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onViewFile(file.id, thumbnailRef.current || undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -253,7 +254,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
onViewFile(file.id, thumbnailRef.current || undefined);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
@@ -392,6 +393,7 @@ const FileEditorThumbnail = ({
|
||||
{file.thumbnailUrl && (
|
||||
<PrivateContent>
|
||||
<img
|
||||
ref={thumbnailRef}
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -412,6 +414,7 @@ const FileEditorThumbnail = ({
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
justifySelf: 'center',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,3 +19,21 @@
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@keyframes :global(fadeIn) {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes :global(fadeOut) {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
@@ -8,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';
|
||||
@@ -18,6 +22,7 @@ import Viewer from '@app/components/viewer/Viewer';
|
||||
import LandingPage from '@app/components/shared/LandingPage';
|
||||
import Footer from '@app/components/shared/Footer';
|
||||
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
|
||||
import { ViewerZoomTransition } from '@app/components/viewer/ViewerZoomTransition';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -26,10 +31,12 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { workbench: currentView, viewerTransition } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Ref for capturing screenshot during TopControls transitions
|
||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
@@ -82,11 +89,89 @@ export default function Workbench() {
|
||||
handleToolSelect('convert');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor');
|
||||
navActions.setWorkbench('fileEditor');
|
||||
}
|
||||
};
|
||||
|
||||
// Capture screenshot helper for TopControls transitions
|
||||
const captureMainContentScreenshot = useCallback(async (): Promise<string | null> => {
|
||||
if (!mainContentRef.current) return null;
|
||||
return captureElementScreenshot(mainContentRef.current);
|
||||
}, []);
|
||||
|
||||
// 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')) {
|
||||
await handleEntryTransition(fileId, sourceRect);
|
||||
}
|
||||
|
||||
// Handle exit transition (viewer → fileEditor/pageEditor)
|
||||
if ((view === 'fileEditor' || view === 'pageEditor') && currentView === 'viewer') {
|
||||
handleExitTransition(view);
|
||||
}
|
||||
|
||||
navActions.setWorkbench(view);
|
||||
}, [currentView, navActions, handleEntryTransition, handleExitTransition]);
|
||||
|
||||
const renderMainContent = () => {
|
||||
// During viewer transition with screenshot, show screenshot overlay
|
||||
if (viewerTransition.isAnimating && viewerTransition.editorScreenshotUrl) {
|
||||
const viewerContent = (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
// Screenshot fades out when zoom starts
|
||||
const screenshotOverlay = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
opacity: viewerTransition.isZooming ? 0 : 1,
|
||||
transition: viewerTransition.isZooming
|
||||
? `opacity ${VIEWER_TRANSITION.SCREENSHOT_FADE_DURATION}ms ease-out`
|
||||
: 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={viewerTransition.editorScreenshotUrl}
|
||||
alt="Loading..."
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewerContent}
|
||||
{screenshotOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for custom workbench views first
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
@@ -115,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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -207,22 +286,28 @@ export default function Workbench() {
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
ref={mainContentRef}
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
{/* Viewer Zoom Transition Overlay */}
|
||||
<ViewerZoomTransition />
|
||||
|
||||
<Box style={{ position: 'relative', zIndex: 100 }}>
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ const EmbedPdfViewerContent = ({
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
// Get viewer transition state for fade-in animation
|
||||
const { viewerTransition } = useNavigationState();
|
||||
|
||||
const {
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
@@ -563,7 +566,8 @@ const EmbedPdfViewerContent = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
contain: 'layout style paint'
|
||||
contain: 'layout style paint',
|
||||
opacity: viewerTransition.isAnimating ? 0 : 1,
|
||||
}}>
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
@@ -635,6 +639,8 @@ const EmbedPdfViewerContent = ({
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
background: "transparent",
|
||||
opacity: viewerTransition.isAnimating ? 0 : 1,
|
||||
transition: 'opacity 300ms ease-in',
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: "auto" }}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* ViewerZoomTransition.module.css - Smooth zoom animation for viewer transitions */
|
||||
|
||||
/* Animation timing - synced with constants/animations.ts */
|
||||
:root {
|
||||
--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 */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity var(--viewer-zoom-duration) var(--viewer-zoom-easing);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdropVisible {
|
||||
opacity: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Thumbnail that zooms from source position to PDF page */
|
||||
.thumbnail {
|
||||
position: fixed;
|
||||
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);
|
||||
}
|
||||
|
||||
.thumbnailZoomed {
|
||||
/* Dimensions controlled by inline styles (calculated from PDF page element) */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--viewer-zoom-duration: 0ms;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.backdropVisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.thumbnailZoomed {
|
||||
/* Instant transition for reduced motion - dimensions from inline styles */
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
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
|
||||
*
|
||||
* Creates a "zoom in" effect from source element (file card or page thumbnail)
|
||||
* to the actual rendered PDF page by animating a thumbnail overlay.
|
||||
*
|
||||
* Coordinates with EmbedPDF initialization to ensure smooth handoff.
|
||||
*/
|
||||
export const ViewerZoomTransition: React.FC = () => {
|
||||
const { viewerTransition } = useNavigationState();
|
||||
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;
|
||||
let isMounted = true;
|
||||
|
||||
if (!viewerTransition.isAnimating) {
|
||||
setAnimationPhase('idle');
|
||||
setTargetRect(null);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (transitionTimer !== null) {
|
||||
clearTimeout(transitionTimer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setAnimationPhase('searching');
|
||||
|
||||
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();
|
||||
|
||||
let card: Element | null = null;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
card = document.querySelector(`[data-file-id="${fileId}"]`);
|
||||
if (card) break;
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
if (!card) {
|
||||
console.warn('ViewerZoomTransition: file card not found, using fallback rect');
|
||||
return getFallbackRect();
|
||||
}
|
||||
|
||||
const img = card.querySelector('img') as HTMLImageElement | null;
|
||||
return img ? img.getBoundingClientRect() : card.getBoundingClientRect();
|
||||
};
|
||||
|
||||
waitForFileCard().then(cardRect => {
|
||||
if (!isMounted) return;
|
||||
|
||||
actions.startZoom();
|
||||
setTargetRect(cardRect);
|
||||
setAnimationPhase('zooming');
|
||||
|
||||
transitionTimer = window.setTimeout(() => {
|
||||
if (!isMounted) return;
|
||||
actions.endViewerTransition();
|
||||
}, VIEWER_TRANSITION.ZOOM_DURATION);
|
||||
});
|
||||
} else {
|
||||
const waitForPdfPage = async (): Promise<DOMRect> => {
|
||||
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, using fallback rect');
|
||||
return viewerTransition.sourceRect || getFallbackRect();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (transitionTimer !== null) {
|
||||
clearTimeout(transitionTimer);
|
||||
}
|
||||
};
|
||||
}, [viewerTransition.isAnimating, viewerTransition.exitFileId, viewerTransition.sourceRect, isExitTransition, actions]);
|
||||
|
||||
// Don't render if not animating or missing thumbnail
|
||||
if (!viewerTransition.isAnimating || !viewerTransition.sourceThumbnailUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sourceThumbnailUrl } = viewerTransition;
|
||||
const initialRect = isExitTransition
|
||||
? (viewerTransition.exitTargetRect || viewerTransition.sourceRect || getFallbackRect())
|
||||
: (viewerTransition.sourceRect || getFallbackRect());
|
||||
|
||||
const initialRadius = '0';
|
||||
const targetRadius = '0';
|
||||
const initialWidth = Math.max(initialRect.width, 1);
|
||||
const initialHeight = Math.max(initialRect.height, 1);
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
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' && targetTransform !== null;
|
||||
const zoomStyle: React.CSSProperties = shouldZoom && targetTransform
|
||||
? { transform: targetTransform, borderRadius: targetRadius }
|
||||
: {};
|
||||
|
||||
// Build class names for thumbnail
|
||||
const thumbnailClasses = [
|
||||
styles.thumbnail,
|
||||
shouldZoom && styles.thumbnailZoomed,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Animated thumbnail - no backdrop needed */}
|
||||
<div
|
||||
className={thumbnailClasses}
|
||||
style={{
|
||||
...initialStyle,
|
||||
...zoomStyle,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
src={sourceThumbnailUrl}
|
||||
alt="Transitioning to viewer"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -11,6 +11,19 @@ import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Viewer transition animation state
|
||||
export interface ViewerTransitionState {
|
||||
isAnimating: boolean;
|
||||
sourceRect: DOMRect | null;
|
||||
sourceThumbnailUrl: string | null;
|
||||
transitionType: 'fileEditor' | 'pageEditor' | null;
|
||||
editorScreenshotUrl: string | null;
|
||||
isZooming: boolean;
|
||||
transitionDirection: 'enter' | 'exit' | null;
|
||||
exitTargetRect: DOMRect | null;
|
||||
exitFileId: string | null;
|
||||
}
|
||||
|
||||
// Navigation state
|
||||
interface NavigationContextState {
|
||||
workbench: WorkbenchType;
|
||||
@@ -18,6 +31,7 @@ interface NavigationContextState {
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
viewerTransition: ViewerTransitionState;
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
@@ -27,7 +41,11 @@ type NavigationAction =
|
||||
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
| { 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_EXIT_TRANSITION'; payload: { exitTargetRect: DOMRect; sourceThumbnailUrl: string; exitFileId: string } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
|
||||
@@ -54,6 +72,62 @@ const navigationReducer = (state: NavigationContextState, action: NavigationActi
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
case 'START_VIEWER_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
isAnimating: true,
|
||||
sourceRect: action.payload.sourceRect,
|
||||
sourceThumbnailUrl: action.payload.sourceThumbnailUrl,
|
||||
transitionType: action.payload.transitionType,
|
||||
editorScreenshotUrl: action.payload.editorScreenshotUrl || null,
|
||||
isZooming: false,
|
||||
transitionDirection: 'enter',
|
||||
exitTargetRect: null,
|
||||
exitFileId: 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
|
||||
}
|
||||
};
|
||||
|
||||
case 'END_VIEWER_TRANSITION':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
isAnimating: false,
|
||||
sourceRect: null,
|
||||
sourceThumbnailUrl: null,
|
||||
transitionType: null,
|
||||
editorScreenshotUrl: null,
|
||||
isZooming: false,
|
||||
transitionDirection: null,
|
||||
exitTargetRect: null,
|
||||
exitFileId: null
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_ZOOM':
|
||||
return {
|
||||
...state,
|
||||
viewerTransition: {
|
||||
...state.viewerTransition,
|
||||
isZooming: true
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -65,7 +139,18 @@ const initialState: NavigationContextState = {
|
||||
selectedTool: null,
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
showNavigationWarning: false,
|
||||
viewerTransition: {
|
||||
isAnimating: false,
|
||||
sourceRect: null,
|
||||
sourceThumbnailUrl: null,
|
||||
transitionType: null,
|
||||
editorScreenshotUrl: null,
|
||||
isZooming: false,
|
||||
transitionDirection: null,
|
||||
exitTargetRect: null,
|
||||
exitFileId: null
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
@@ -82,6 +167,10 @@ export interface NavigationContextActions {
|
||||
cancelNavigation: () => void;
|
||||
clearToolSelection: () => void;
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
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
|
||||
@@ -91,6 +180,7 @@ export interface NavigationContextStateValue {
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
viewerTransition: ViewerTransitionState;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
@@ -252,7 +342,7 @@ export const NavigationProvider: React.FC<{
|
||||
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
|
||||
|
||||
// If switching away from current tool and have unsaved changes, show warning
|
||||
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
|
||||
@@ -262,6 +352,25 @@ export const NavigationProvider: React.FC<{
|
||||
}
|
||||
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
|
||||
|
||||
const startViewerTransition = useCallback((sourceRect: DOMRect, sourceThumbnailUrl: string, transitionType: 'fileEditor' | 'pageEditor', editorScreenshotUrl?: string) => {
|
||||
dispatch({
|
||||
type: 'START_VIEWER_TRANSITION',
|
||||
payload: { sourceRect, sourceThumbnailUrl, transitionType, editorScreenshotUrl }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endViewerTransition = useCallback(() => {
|
||||
dispatch({ type: 'END_VIEWER_TRANSITION' });
|
||||
}, []);
|
||||
|
||||
const startZoom = useCallback(() => {
|
||||
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(() => ({
|
||||
@@ -277,6 +386,10 @@ export const NavigationProvider: React.FC<{
|
||||
cancelNavigation,
|
||||
clearToolSelection,
|
||||
handleToolSelect,
|
||||
startViewerTransition,
|
||||
endViewerTransition,
|
||||
startZoom,
|
||||
startExitTransition,
|
||||
}), [
|
||||
setWorkbench,
|
||||
setSelectedTool,
|
||||
@@ -290,6 +403,10 @@ export const NavigationProvider: React.FC<{
|
||||
cancelNavigation,
|
||||
clearToolSelection,
|
||||
handleToolSelect,
|
||||
startViewerTransition,
|
||||
endViewerTransition,
|
||||
startZoom,
|
||||
startExitTransition,
|
||||
]);
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
@@ -297,7 +414,8 @@ export const NavigationProvider: React.FC<{
|
||||
selectedTool: state.selectedTool,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
viewerTransition: state.viewerTransition
|
||||
};
|
||||
|
||||
// Also memoize the context value to prevent unnecessary re-renders
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { getThumbnailRect, getCenteredFallbackRect, getContainedImageRect } 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;
|
||||
}
|
||||
|
||||
// Only capture screenshot for fileEditor transitions
|
||||
// pageEditor doesn't need it since we're animating the thumbnail directly
|
||||
let screenshot: string | null = null;
|
||||
if (currentView === 'fileEditor') {
|
||||
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) {
|
||||
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(
|
||||
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(
|
||||
(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 || '';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { domToPng } from 'modern-screenshot';
|
||||
|
||||
/**
|
||||
* 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 dataUrl = await domToPng(element, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
style: {
|
||||
transform: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.warn('Failed to capture screenshot:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user