Update top bar controls visually

This commit is contained in:
Reece 2025-10-20 20:44:48 +01:00
parent 658ce2dab9
commit 6d3154a7ae
6 changed files with 167 additions and 74 deletions

View File

@ -186,7 +186,6 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
}}
>
{renderMainContent()}

View File

@ -27,9 +27,10 @@ interface DragDropGridProps<T extends DragDropItem> {
selectionMode: boolean;
isAnimating: boolean;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
}
// Lightweight wrapper that handles dnd-kit hooks for each visible item
@ -43,10 +44,11 @@ interface DraggableItemProps<T extends DragDropItem> {
activeId: string | null;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget }: DraggableItemProps<T>) => {
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
data: {
@ -92,7 +94,7 @@ const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelec
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners })}
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
@ -102,6 +104,7 @@ const DragDropGrid = <T extends DragDropItem>({
renderItem,
onReorderPages,
getThumbnailData,
zoomLevel = 1.0,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
@ -244,8 +247,8 @@ const DragDropGrid = <T extends DragDropItem>({
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
@ -253,9 +256,9 @@ const DragDropGrid = <T extends DragDropItem>({
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
}, [zoomLevel]);
// Update items per row when container resizes
// Update items per row when container resizes or zoom changes
useEffect(() => {
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
@ -278,7 +281,7 @@ const DragDropGrid = <T extends DragDropItem>({
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
}, [calculateItemsPerRow, zoomLevel]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
@ -286,11 +289,16 @@ const DragDropGrid = <T extends DragDropItem>({
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
},
overscan: OVERSCAN,
});
// Re-measure virtualizer when zoom or items per row changes
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow]);
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only start box select if Ctrl/Cmd is held
@ -543,7 +551,7 @@ const DragDropGrid = <T extends DragDropItem>({
<div
style={{
display: 'flex',
gap: GRID_CONSTANTS.ITEM_GAP,
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
@ -565,6 +573,7 @@ const DragDropGrid = <T extends DragDropItem>({
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
zoomLevel={zoomLevel}
/>
);
})}

View File

@ -50,6 +50,20 @@ const PageEditor = ({
// Get PageEditor coordination functions
const { updateFileOrderFromPages, fileOrder } = usePageEditor();
// Zoom state management
const [zoomLevel, setZoomLevel] = useState(1.0);
const containerRef = useRef<HTMLDivElement>(null);
const [isContainerHovered, setIsContainerHovered] = useState(false);
// Zoom actions
const zoomIn = useCallback(() => {
setZoomLevel(prev => Math.min(prev + 0.1, 3.0));
}, []);
const zoomOut = useCallback(() => {
setZoomLevel(prev => Math.max(prev - 0.1, 0.5));
}, []);
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
@ -822,6 +836,69 @@ const PageEditor = ({
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
accumulator += event.deltaY;
const threshold = 10;
if (accumulator <= -threshold) {
// Accumulated scroll up - zoom in
zoomIn();
accumulator = 0;
} else if (accumulator >= threshold) {
// Accumulated scroll down - zoom out
zoomOut();
accumulator = 0;
}
}
};
const container = containerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheel);
};
}
}, [zoomIn, zoomOut]);
// Handle keyboard zoom shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isContainerHovered) return;
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomIn();
} else if (event.key === '-' || event.key === '_') {
// Ctrl+- for zoom out
event.preventDefault();
zoomOut();
} else if (event.key === '0') {
// Ctrl+0 for reset zoom
event.preventDefault();
setZoomLevel(1.0);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isContainerHovered, zoomIn, zoomOut]);
// Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || [];
@ -849,7 +926,15 @@ const PageEditor = ({
}, [orderedFileIds.join(',')]); // Only recalculate when the set of files changes, not the order
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<Box
ref={containerRef}
pos="relative"
h='100%'
style={{ overflow: 'auto' }}
data-scrolling-container="true"
onMouseEnter={() => setIsContainerHovered(true)}
onMouseLeave={() => setIsContainerHovered(false)}
>
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && selectedFileIds.length === 0 && (
@ -870,21 +955,20 @@ const PageEditor = ({
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pb="15rem" style={{ position: 'relative' }}>
<Box ref={gridContainerRef} p={0} pt="2rem" pb="15rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
{(() => {
// Calculate remToPx once outside the map to avoid layout thrashing
const containerWidth = containerDimensions.width;
@ -936,6 +1020,7 @@ const PageEditor = ({
selectionMode={selectionMode}
isAnimating={isAnimating}
onReorderPages={handleReorderPages}
zoomLevel={zoomLevel}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page?.thumbnail) return null;
@ -944,7 +1029,7 @@ const PageEditor = ({
rotation: page.rotation || 0
};
}}
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dragHandleProps) => {
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dragHandleProps, zoomLevel) => {
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
@ -981,11 +1066,11 @@ const PageEditor = ({
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevel}
/>
);
}}
/>
</Box>
)}

View File

@ -48,6 +48,7 @@ interface PageThumbnailProps {
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
zoomLevel?: number;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
@ -79,6 +80,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pdfDocument,
splitPositions,
onInsertFiles,
zoomLevel = 1.0,
}: PageThumbnailProps) => {
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
@ -351,8 +353,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
!rounded-lg
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
@ -364,6 +364,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
`}
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
...(isBoxSelected && {
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',

View File

@ -1,6 +1,6 @@
import React, { useRef, useState, useEffect } from 'react';
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
import EditNoteIcon from '@mui/icons-material/EditNote';
import { LocalIcon } from '../shared/LocalIcon';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import AddIcon from '@mui/icons-material/Add';
@ -107,16 +107,8 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
});
return () => {
try {
dragCleanup();
} catch {
// Cleanup may fail if element was already removed
}
try {
dropCleanup();
} catch {
// Cleanup may fail if element was already removed
}
try { dragCleanup(); } catch {}
try { dropCleanup(); } catch {}
};
}, []); // NOTE: no `onReorder` here
@ -191,23 +183,25 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
};
interface PageEditorFileDropdownProps {
displayName: string;
files: PageEditorFile[];
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
fileColorMap: Map<string, number>;
selectedCount: number;
totalCount: number;
}
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
displayName,
files,
onToggleSelection,
onReorder,
switchingTo,
viewOptionStyle,
fileColorMap,
selectedCount,
totalCount,
}) => {
const { openFilesModal } = useFilesModalContext();
@ -218,9 +212,9 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
)}
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} />
<span>{selectedCount}/{totalCount} selected</span>
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>

View File

@ -3,9 +3,9 @@ import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from "./LocalIcon";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { PageEditorFileDropdown } from './PageEditorFileDropdown';
import { usePageEditor } from '../../contexts/PageEditorContext';
@ -38,6 +38,7 @@ const viewOptionStyle: React.CSSProperties = {
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'center',
padding: '2px 1rem',
};
// Helper function to create view options for SegmentedControl
@ -71,11 +72,10 @@ const createViewOptions = (
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
<VisibilityIcon fontSize="small" />
<VisibilityIcon fontSize="medium" />
)}
<span className="ph-no-capture">{viewerDisplayName}</span>
</div>
),
value: "viewer",
@ -94,22 +94,22 @@ const createViewOptions = (
const pageEditorOption = {
label: showPageEditorDropdown ? (
<PageEditorFileDropdown
displayName={pageEditorDisplayName}
files={pageEditorState!.files}
onToggleSelection={pageEditorState!.onToggleSelection}
onReorder={pageEditorState!.onReorder}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
fileColorMap={pageEditorState!.fileColorMap}
selectedCount={pageEditorState!.selectedCount}
totalCount={pageEditorState!.totalCount}
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
<EditNoteIcon fontSize="small" />
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
)}
<span>{pageEditorDisplayName}</span>
</div>
),
value: "pageEditor",
@ -118,17 +118,7 @@ const createViewOptions = (
const fileEditorOption = {
label: (
<div style={viewOptionStyle}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
</div>
),
value: "fileEditor",
@ -146,9 +136,9 @@ const createViewOptions = (
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === view.workbenchId ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
view.icon || <PictureAsPdfIcon fontSize="medium" />
)}
<span>{view.label}</span>
</div>
@ -344,7 +334,7 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<div className="flex justify-center">
<SegmentedControl
data-tour="view-switcher"
data={viewOptions}
@ -361,18 +351,32 @@ const TopControls = ({
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
borderRadius: '0 0 16px 16px',
height: '1.8rem',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderTop: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
outline: '1px solid rgba(0, 0, 0, 0.1)',
outlineOffset: '-1px',
padding: '0 0',
gap: '0',
},
control: {
borderRadius: 9999,
borderRadius: '0 0 16px 16px',
padding: '0',
border: 'none',
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
borderRadius: '0 0 16px 16px',
height: '100%',
top: '0rem',
margin: '0',
border: 'none',
},
label: {
paddingTop: '0rem',
paddingTop: '0',
paddingBottom: '0',
}
}}
/>