Improve workbench transition overlays
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user