diff --git a/frontend/src/core/components/layout/Workbench.module.css b/frontend/src/core/components/layout/Workbench.module.css index d85dbf3376..4db4d23914 100644 --- a/frontend/src/core/components/layout/Workbench.module.css +++ b/frontend/src/core/components/layout/Workbench.module.css @@ -33,7 +33,7 @@ z-index: 20; display: grid; place-items: center; - background: radial-gradient(circle at center, rgba(0, 0, 0, 0.04), transparent 55%); + background: radial-gradient(circle at center, rgba(0, 0, 0, 0.045), transparent 60%); } .transitionPage { @@ -45,15 +45,19 @@ --to-y: 0px; --to-scale: 1; --to-rot: 0deg; + --to-opacity: 1; width: clamp(5rem, 9vw, 8.75rem); aspect-ratio: 0.72; border-radius: 0.5rem; - background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(245, 246, 248, 0.88)); - box-shadow: 0 18px 36px rgba(0, 0, 0, 0.08), 0 4px 10px rgba(0, 0, 0, 0.04); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(245, 246, 248, 0.9)); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.06); border: 1px solid rgba(0, 0, 0, 0.06); opacity: 0; transform: translate(var(--from-x), var(--from-y)) scale(var(--from-scale)) rotate(var(--from-rot)); - animation: scopeTransition 640ms cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation: scopeTransition 760ms cubic-bezier(0.16, 1, 0.3, 1) forwards; filter: saturate(1.08); } @@ -75,64 +79,34 @@ opacity: 1; } 100% { - opacity: 1; + opacity: var(--to-opacity); transform: translate(var(--to-x), var(--to-y)) scale(var(--to-scale)) rotate(var(--to-rot)); } } -.viewerToPageEditor .transitionPage { - animation-delay: calc(var(--page-index) * 24ms); +.transitionPagePlaceholder { + background-image: linear-gradient(120deg, rgba(255, 255, 255, 0.96), rgba(235, 238, 242, 0.92)); + filter: saturate(0.95); } -.viewerToPageEditor .transitionPage:nth-child(1) { --page-index: 0; --from-x: -16vw; --from-y: -6vh; --to-x: -12vw; --to-y: -4vh; --to-rot: -1deg; } -.viewerToPageEditor .transitionPage:nth-child(2) { --page-index: 1; --from-x: -8vw; --from-y: -8vh; --to-x: -4vw; --to-y: -6vh; --to-rot: 2deg; } -.viewerToPageEditor .transitionPage:nth-child(3) { --page-index: 2; --from-x: 0vw; --from-y: -10vh; --to-x: 4vw; --to-y: -6vh; --to-rot: -1deg; } -.viewerToPageEditor .transitionPage:nth-child(4) { --page-index: 3; --from-x: 8vw; --from-y: -8vh; --to-x: 12vw; --to-y: -4vh; --to-rot: 1deg; } -.viewerToPageEditor .transitionPage:nth-child(5) { --page-index: 4; --from-x: -14vw; --from-y: 8vh; --to-x: -12vw; --to-y: 6vh; --to-rot: 1deg; } -.viewerToPageEditor .transitionPage:nth-child(6) { --page-index: 5; --from-x: -4vw; --from-y: 10vh; --to-x: -4vw; --to-y: 6vh; --to-rot: -2deg; } -.viewerToPageEditor .transitionPage:nth-child(7) { --page-index: 6; --from-x: 6vw; --from-y: 10vh; --to-x: 4vw; --to-y: 6vh; --to-rot: 2deg; } -.viewerToPageEditor .transitionPage:nth-child(8) { --page-index: 7; --from-x: 16vw; --from-y: 8vh; --to-x: 12vw; --to-y: 6vh; --to-rot: -1deg; } +.viewerToPageEditor .transitionPage { + animation-duration: 780ms; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.08), 0 6px 10px rgba(0, 0, 0, 0.06); +} .pageEditorToViewer .transitionPage { - animation-delay: calc(var(--page-index) * 22ms); - --from-scale: 1.02; - --to-scale: 0.82; - opacity: 1; + animation-duration: 780ms; + filter: saturate(1.05) drop-shadow(0 14px 22px rgba(0, 0, 0, 0.18)); } -.pageEditorToViewer .transitionPage:nth-child(1) { --page-index: 0; --from-x: -18vw; --from-y: -6vh; --to-x: -6vw; --to-y: -2vh; --to-rot: -4deg; } -.pageEditorToViewer .transitionPage:nth-child(2) { --page-index: 1; --from-x: -10vw; --from-y: -8vh; --to-x: -2vw; --to-y: -4vh; --to-rot: 2deg; } -.pageEditorToViewer .transitionPage:nth-child(3) { --page-index: 2; --from-x: -2vw; --from-y: -10vh; --to-x: 2vw; --to-y: -6vh; --to-rot: -3deg; } -.pageEditorToViewer .transitionPage:nth-child(4) { --page-index: 3; --from-x: 6vw; --from-y: -8vh; --to-x: 6vw; --to-y: -4vh; --to-rot: 2deg; } -.pageEditorToViewer .transitionPage:nth-child(5) { --page-index: 4; --from-x: -14vw; --from-y: 8vh; --to-x: -6vw; --to-y: 4vh; --to-rot: 3deg; } -.pageEditorToViewer .transitionPage:nth-child(6) { --page-index: 5; --from-x: -4vw; --from-y: 10vh; --to-x: -2vw; --to-y: 6vh; --to-rot: -2deg; } -.pageEditorToViewer .transitionPage:nth-child(7) { --page-index: 6; --from-x: 6vw; --from-y: 10vh; --to-x: 2vw; --to-y: 6vh; --to-rot: 4deg; } -.pageEditorToViewer .transitionPage:nth-child(8) { --page-index: 7; --from-x: 16vw; --from-y: 8vh; --to-x: 6vw; --to-y: 4vh; --to-rot: -3deg; } - .pageEditorToFileEditor .transitionPage { - animation-delay: calc(var(--page-index) * 26ms); - --to-scale: 0.9; + animation-duration: 840ms; + --to-scale: 0.88; + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08); } -.pageEditorToFileEditor .transitionPage:nth-child(1) { --page-index: 0; --from-x: -14vw; --from-y: -6vh; --to-x: -18vw; --to-y: -2vh; --to-rot: -6deg; } -.pageEditorToFileEditor .transitionPage:nth-child(2) { --page-index: 1; --from-x: -6vw; --from-y: -8vh; --to-x: -12vw; --to-y: 0vh; --to-rot: 4deg; } -.pageEditorToFileEditor .transitionPage:nth-child(3) { --page-index: 2; --from-x: 2vw; --from-y: -10vh; --to-x: -6vw; --to-y: 2vh; --to-rot: -8deg; } -.pageEditorToFileEditor .transitionPage:nth-child(4) { --page-index: 3; --from-x: 10vw; --from-y: -8vh; --to-x: -2vw; --to-y: 4vh; --to-rot: 5deg; } -.pageEditorToFileEditor .transitionPage:nth-child(5) { --page-index: 4; --from-x: -12vw; --from-y: 8vh; --to-x: 6vw; --to-y: -2vh; --to-rot: -5deg; } -.pageEditorToFileEditor .transitionPage:nth-child(6) { --page-index: 5; --from-x: -2vw; --from-y: 10vh; --to-x: 10vw; --to-y: 0vh; --to-rot: 6deg; } -.pageEditorToFileEditor .transitionPage:nth-child(7) { --page-index: 6; --from-x: 8vw; --from-y: 10vh; --to-x: 14vw; --to-y: 2vh; --to-rot: -4deg; } -.pageEditorToFileEditor .transitionPage:nth-child(8) { --page-index: 7; --from-x: 16vw; --from-y: 8vh; --to-x: 18vw; --to-y: 4vh; --to-rot: 5deg; } - .fileEditorToPageEditor .transitionPage { - animation-delay: calc(var(--page-index) * 24ms); + animation-duration: 820ms; --from-scale: 0.9; + filter: saturate(1.08); } - -.fileEditorToPageEditor .transitionPage:nth-child(1) { --page-index: 0; --from-x: -18vw; --from-y: -2vh; --to-x: -12vw; --to-y: -4vh; --to-rot: 2deg; --to-scale: 1.02; } -.fileEditorToPageEditor .transitionPage:nth-child(2) { --page-index: 1; --from-x: -12vw; --from-y: 0vh; --to-x: -4vw; --to-y: -6vh; --to-rot: -1deg; --to-scale: 1.02; } -.fileEditorToPageEditor .transitionPage:nth-child(3) { --page-index: 2; --from-x: -6vw; --from-y: 2vh; --to-x: 4vw; --to-y: -6vh; --to-rot: 2deg; --to-scale: 1.02; } -.fileEditorToPageEditor .transitionPage:nth-child(4) { --page-index: 3; --from-x: -2vw; --from-y: 4vh; --to-x: 12vw; --to-y: -4vh; --to-rot: -2deg; --to-scale: 1.02; } -.fileEditorToPageEditor .transitionPage:nth-child(5) { --page-index: 4; --from-x: 6vw; --from-y: -2vh; --to-x: -12vw; --to-y: 6vh; --to-rot: -2deg; --to-scale: 1; } -.fileEditorToPageEditor .transitionPage:nth-child(6) { --page-index: 5; --from-x: 10vw; --from-y: 0vh; --to-x: -4vw; --to-y: 6vh; --to-rot: 1deg; --to-scale: 1; } -.fileEditorToPageEditor .transitionPage:nth-child(7) { --page-index: 6; --from-x: 14vw; --from-y: 2vh; --to-x: 4vw; --to-y: 6vh; --to-rot: -1deg; --to-scale: 1; } -.fileEditorToPageEditor .transitionPage:nth-child(8) { --page-index: 7; --from-x: 18vw; --from-y: 4vh; --to-x: 12vw; --to-y: 6vh; --to-rot: 2deg; --to-scale: 1; } diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 097e6fe813..d8c100f62a 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,5 +1,5 @@ import { Box } from '@mantine/core'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { CSSProperties, useEffect, useRef, useState } from 'react'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useFileHandler } from '@app/hooks/useFileHandler'; @@ -25,6 +25,128 @@ type TransitionAnimation = | 'pageEditorToFileEditor' | 'fileEditorToPageEditor'; +type OverlayPage = { + id: string; + style: CSSProperties; + hasThumbnail: boolean; +}; + +type Placement = { + x: number; + y: number; + rotation: number; + scale: number; + opacity?: number; +}; + +const clampCount = (count: number) => Math.max(6, Math.min(count, 12)); + +const createGridTargets = (count: number): Placement[] => { + const placements: Placement[] = []; + const columns = 3; + const columnSpacing = 14; + const rowSpacing = 18; + const centerOffset = (columns - 1) / 2; + + for (let index = 0; index < count; index += 1) { + const column = index % columns; + const row = Math.floor(index / columns); + const x = (column - centerOffset) * columnSpacing + (Math.random() - 0.5) * 4; + const y = row * rowSpacing - 12 + (Math.random() - 0.5) * 4; + + placements.push({ + x, + y, + rotation: (Math.random() - 0.5) * 4, + scale: 1, + }); + } + + return placements; +}; + +const createScatterPlacements = (count: number, radiusX: number, radiusY: number, baseY = 0): Placement[] => + Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2 + Math.random() * 0.6; + const distanceX = radiusX * (0.45 + Math.random() * 0.55); + const distanceY = radiusY * (0.4 + Math.random() * 0.6); + + return { + x: Math.cos(angle) * distanceX, + y: baseY + Math.sin(angle) * distanceY, + rotation: (Math.random() - 0.5) * 10, + scale: 0.88 + Math.random() * 0.18, + }; + }); + +const createStackPlacements = (count: number, fileCount: number): Placement[] => { + const stacks = Math.min(Math.max(fileCount, 1), 4); + const spread = stacks > 1 ? 26 / (stacks - 1) : 0; + + return Array.from({ length: count }, (_, index) => { + const stackIndex = index % stacks; + const layer = Math.floor(index / stacks); + const baseX = (stackIndex - (stacks - 1) / 2) * spread + (Math.random() - 0.5) * 4; + const baseY = 6 + layer * 2.6 + (Math.random() - 0.5) * 3; + + return { + x: baseX, + y: baseY, + rotation: (Math.random() - 0.5) * 8, + scale: 0.86 - Math.min(layer, 3) * 0.03, + opacity: 0.9 - Math.min(layer, 3) * 0.08, + }; + }); +}; + +const createOverlayPages = ( + animation: TransitionAnimation, + thumbnails: string[], + fileCount: number, +): OverlayPage[] => { + const usableThumbs = thumbnails.filter(Boolean); + const count = clampCount(Math.max(usableThumbs.length, 8)); + const gridPlacements = createGridTargets(count); + const viewerScatter = createScatterPlacements(count, 9, 6, -2); + const expandedScatter = createScatterPlacements(count, 16, 12, 2); + const stackPlacements = createStackPlacements(count, fileCount); + + const placementsByAnimation: Record = { + viewerToPageEditor: { from: viewerScatter, to: gridPlacements, delayStep: 32 }, + pageEditorToViewer: { from: gridPlacements, to: expandedScatter, delayStep: 28 }, + pageEditorToFileEditor: { from: gridPlacements, to: stackPlacements, delayStep: 30 }, + fileEditorToPageEditor: { from: stackPlacements, to: gridPlacements, delayStep: 28 }, + }; + + const { from, to, delayStep } = placementsByAnimation[animation]; + + return Array.from({ length: count }, (_, index) => { + const thumbnail = usableThumbs[index] ?? usableThumbs[index % Math.max(usableThumbs.length, 1)]; + const fromPlacement = from[index]; + const toPlacement = to[index]; + + const style: CSSProperties = { + ['--from-x' as string]: `${fromPlacement.x}vw`, + ['--from-y' as string]: `${fromPlacement.y}vh`, + ['--from-scale' as string]: fromPlacement.scale.toString(), + ['--from-rot' as string]: `${fromPlacement.rotation}deg`, + ['--to-x' as string]: `${toPlacement.x}vw`, + ['--to-y' as string]: `${toPlacement.y}vh`, + ['--to-scale' as string]: toPlacement.scale.toString(), + ['--to-rot' as string]: `${toPlacement.rotation}deg`, + ['--to-opacity' as string]: (toPlacement.opacity ?? 1).toString(), + animationDelay: `${delayStep * index}ms`, + backgroundImage: thumbnail ? `url(${thumbnail})` : undefined, + }; + + return { + id: `overlay-page-${index}`, + style, + hasThumbnail: Boolean(thumbnail), + }; + }); +}; + // No props needed - component uses contexts directly export default function Workbench() { const { isRainbowMode } = useRainbowThemeContext(); @@ -39,6 +161,8 @@ export default function Workbench() { const previousViewRef = useRef(currentView); const [overlayAnimation, setOverlayAnimation] = useState(null); const [overlayRunId, setOverlayRunId] = useState(0); + const [overlayPages, setOverlayPages] = useState([]); + const overlayThumbsRef = useRef([]); const activeFiles = selectors.getFiles(); const { previewFile, @@ -61,7 +185,7 @@ export default function Workbench() { const { addFiles } = useFileHandler(); // Get active file index from ViewerContext - const { activeFileIndex, setActiveFileIndex } = useViewer(); + const { activeFileIndex, setActiveFileIndex, getThumbnailAPI, getScrollState } = useViewer(); useEffect(() => { const previousView = previousViewRef.current; @@ -87,12 +211,73 @@ export default function Workbench() { if (animation) { setOverlayRunId((runId) => runId + 1); - const timeout = setTimeout(() => setOverlayAnimation(null), 720); + const timeout = setTimeout(() => setOverlayAnimation(null), 820); return () => clearTimeout(timeout); } }, [currentView]); + useEffect(() => { + if (!overlayAnimation) { + return undefined; + } + + let cancelled = false; + const thumbnailApi = getThumbnailAPI(); + const { totalPages } = getScrollState(); + const pagesToRender = totalPages > 0 ? Math.min(totalPages, 12) : 8; + + const resetThumbnails = () => { + overlayThumbsRef.current.forEach((url) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + overlayThumbsRef.current = []; + }; + + const loadThumbnails = async () => { + resetThumbnails(); + + if (!thumbnailApi || pagesToRender === 0) { + setOverlayPages(createOverlayPages(overlayAnimation, [], activeFiles.length)); + return; + } + + const promises = Array.from({ length: pagesToRender }, (_, index) => + thumbnailApi + .renderThumb(index, 0.6) + .toPromise() + .then((blob: Blob) => URL.createObjectURL(blob)) + .catch(() => ''), + ); + + const thumbnailUrls = await Promise.all(promises); + + if (cancelled) { + resetThumbnails(); + return; + } + + overlayThumbsRef.current = thumbnailUrls.filter((url) => url.startsWith('blob:')); + setOverlayPages(createOverlayPages(overlayAnimation, thumbnailUrls, activeFiles.length)); + }; + + void loadThumbnails(); + + return () => { + cancelled = true; + }; + }, [overlayAnimation, getScrollState, getThumbnailAPI, activeFiles.length]); + + useEffect(() => () => { + overlayThumbsRef.current.forEach((url) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + }, []); + const handlePreviewClose = () => { setPreviewFile(null); const previousMode = sessionStorage.getItem('previousMode'); @@ -111,14 +296,6 @@ export default function Workbench() { } }; - const overlayPages = useMemo( - () => - Array.from({ length: 8 }, (_, index) => ({ - id: `overlay-page-${index}`, - })), - [], - ); - const renderMainContent = () => { if (activeFiles.length === 0) { return ( @@ -238,10 +415,14 @@ export default function Workbench() { className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable} ${styles.workbenchTransition}`} > {renderMainContent()} - {overlayAnimation && ( + {overlayAnimation && overlayPages.length > 0 && (
{overlayPages.map((page) => ( - + ))}
)}