Compare commits

...
2 changed files with 331 additions and 6 deletions
@@ -19,3 +19,94 @@
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-5);
}
.workbenchTransition {
position: relative;
isolation: isolate;
}
.transitionOverlay {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 20;
display: grid;
place-items: center;
background: radial-gradient(circle at center, rgba(0, 0, 0, 0.045), transparent 60%);
}
.transitionPage {
--from-x: 0px;
--from-y: 0px;
--from-scale: 0.9;
--from-rot: 0deg;
--to-x: 0px;
--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.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 760ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
filter: saturate(1.08);
}
.transitionPage::after {
content: '';
position: absolute;
inset: 0.55rem;
border-radius: 0.35rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.035), rgba(0, 0, 0, 0.06));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
@keyframes scopeTransition {
0% {
opacity: 0;
transform: translate(var(--from-x), var(--from-y)) scale(var(--from-scale)) rotate(var(--from-rot));
}
14% {
opacity: 1;
}
100% {
opacity: var(--to-opacity);
transform: translate(var(--to-x), var(--to-y)) scale(var(--to-scale)) rotate(var(--to-rot));
}
}
.transitionPagePlaceholder {
background-image: linear-gradient(120deg, rgba(255, 255, 255, 0.96), rgba(235, 238, 242, 0.92));
filter: saturate(0.95);
}
.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-duration: 780ms;
filter: saturate(1.05) drop-shadow(0 14px 22px rgba(0, 0, 0, 0.18));
}
.pageEditorToFileEditor .transitionPage {
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);
}
.fileEditorToPageEditor .transitionPage {
animation-duration: 820ms;
--from-scale: 0.9;
filter: saturate(1.08);
}
@@ -1,10 +1,11 @@
import { Box } from '@mantine/core';
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';
import { useFileState } from '@app/contexts/FileContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
import { BaseWorkbenchType, isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import styles from '@app/components/layout/Workbench.module.css';
@@ -18,6 +19,134 @@ import LandingPage from '@app/components/shared/LandingPage';
import Footer from '@app/components/shared/Footer';
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
type TransitionAnimation =
| 'viewerToPageEditor'
| 'pageEditorToViewer'
| '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();
@@ -28,6 +157,12 @@ export default function Workbench() {
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
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,
@@ -50,7 +185,98 @@ 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;
if (previousView === currentView) {
return;
}
let animation: TransitionAnimation | null = null;
if (previousView === 'viewer' && currentView === 'pageEditor') {
animation = 'viewerToPageEditor';
} else if (previousView === 'pageEditor' && currentView === 'viewer') {
animation = 'pageEditorToViewer';
} else if (previousView === 'pageEditor' && currentView === 'fileEditor') {
animation = 'pageEditorToFileEditor';
} else if (previousView === 'fileEditor' && currentView === 'pageEditor') {
animation = 'fileEditorToPageEditor';
}
setOverlayAnimation(animation);
previousViewRef.current = currentView;
if (animation) {
setOverlayRunId((runId) => runId + 1);
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);
@@ -186,12 +412,20 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
}}
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable} ${styles.workbenchTransition}`}
>
{renderMainContent()}
{overlayAnimation && overlayPages.length > 0 && (
<div key={overlayRunId} className={`${styles.transitionOverlay} ${styles[overlayAnimation]}`}>
{overlayPages.map((page) => (
<span
key={page.id}
className={`${styles.transitionPage} ${!page.hasThumbnail ? styles.transitionPagePlaceholder : ''}`}
style={page.style}
/>
))}
</div>
)}
</Box>
<Footer