mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b345f1bbc | ||
|
|
77d4179cad | ||
|
|
198831ecf5 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user