Improve workbench transition overlays

This commit is contained in:
Anthony Stirling
2025-11-24 17:33:03 +00:00
parent 77d4179cad
commit 9b345f1bbc
2 changed files with 217 additions and 62 deletions
@@ -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; }
+194 -13
View File
@@ -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<TransitionAnimation, { from: Placement[]; to: Placement[]; delayStep: number }> = {
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<TransitionAnimation | null>(null);
const [overlayRunId, setOverlayRunId] = useState(0);
const [overlayPages, setOverlayPages] = useState<OverlayPage[]>([]);
const overlayThumbsRef = useRef<string[]>([]);
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 && (
<div key={overlayRunId} className={`${styles.transitionOverlay} ${styles[overlayAnimation]}`}>
{overlayPages.map((page) => (
<span key={page.id} className={styles.transitionPage} />
<span
key={page.id}
className={`${styles.transitionPage} ${!page.hasThumbnail ? styles.transitionPagePlaceholder : ''}`}
style={page.style}
/>
))}
</div>
)}