diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 8ffbeded9..7b8e3495e 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -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()} diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index dc72b9304..fb20e31a5 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -27,9 +27,10 @@ interface DragDropGridProps { selectionMode: boolean; isAnimating: boolean; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, 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 { activeId: string | null; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; onUpdateDropTarget: (itemId: string | null) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; + zoomLevel: number; } -const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget }: DraggableItemProps) => { +const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: item.id, data: { @@ -92,7 +94,7 @@ const DraggableItem = ({ 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 = ({ renderItem, onReorderPages, getThumbnailData, + zoomLevel = 1.0, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); @@ -244,8 +247,8 @@ const DragDropGrid = ({ // 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 = ({ 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 = ({ window.removeEventListener('resize', updateLayout); resizeObserver.disconnect(); }; - }, [calculateItemsPerRow]); + }, [calculateItemsPerRow, zoomLevel]); // Virtualization with react-virtual library const rowVirtualizer = useVirtualizer({ @@ -286,11 +289,16 @@ const DragDropGrid = ({ 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 = ({
({ getThumbnailData={getThumbnailData} onUpdateDropTarget={setHoveredItemId} renderItem={renderItem} + zoomLevel={zoomLevel} /> ); })} diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index c417bd31f..79bac508a 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -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(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 ( - + setIsContainerHovered(true)} + onMouseLeave={() => setIsContainerHovered(false)} + > {!mergedPdfDocument && !globalProcessing && selectedFileIds.length === 0 && ( @@ -870,21 +955,20 @@ const PageEditor = ({ )} {displayDocument && ( - + - - {/* Split Lines Overlay */} -
+ {/* Split Lines Overlay */} +
{(() => { // 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} /> ); }} /> - )} diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index f202dcf69..e2af37a51 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -48,6 +48,7 @@ interface PageThumbnailProps { setPdfDocument: (doc: PDFDocument) => void; splitPositions: Set; onInsertFiles?: (files: File[], insertAfterPage: number) => void; + zoomLevel?: number; } const PageThumbnail: React.FC = ({ @@ -79,6 +80,7 @@ const PageThumbnail: React.FC = ({ 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 = ({ !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 = ({ ${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)', diff --git a/frontend/src/components/shared/PageEditorFileDropdown.tsx b/frontend/src/components/shared/PageEditorFileDropdown.tsx index 772a9e08b..84c0870cb 100644 --- a/frontend/src/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/components/shared/PageEditorFileDropdown.tsx @@ -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 = ({ }); 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 = ({ }; interface PageEditorFileDropdownProps { - displayName: string; files: PageEditorFile[]; onToggleSelection: (fileId: FileId) => void; onReorder: (fromIndex: number, toIndex: number) => void; switchingTo?: string | null; viewOptionStyle: React.CSSProperties; fileColorMap: Map; + selectedCount: number; + totalCount: number; } export const PageEditorFileDropdown: React.FC = ({ - displayName, files, onToggleSelection, onReorder, switchingTo, viewOptionStyle, fileColorMap, + selectedCount, + totalCount, }) => { const { openFilesModal } = useFilesModalContext(); @@ -218,9 +212,9 @@ export const PageEditorFileDropdown: React.FC = ({ {switchingTo === "pageEditor" ? ( ) : ( - + )} - + {selectedCount}/{totalCount} selected
diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 9a76921f6..e64ca21c8 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -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 = ( ) : (
{switchingTo === "viewer" ? ( - + ) : ( - + )} - {viewerDisplayName}
), value: "viewer", @@ -94,22 +94,22 @@ const createViewOptions = ( const pageEditorOption = { label: showPageEditorDropdown ? ( ) : (
{switchingTo === "pageEditor" ? ( - + ) : ( - + )} - {pageEditorDisplayName}
), value: "pageEditor", @@ -118,17 +118,7 @@ const createViewOptions = ( const fileEditorOption = { label: (
- {currentView === "fileEditor" ? ( - <> - {switchingTo === "fileEditor" ? : } - Active Files - - ) : ( - <> - {switchingTo === "fileEditor" ? : } - Active Files - - )} + {switchingTo === "fileEditor" ? : }
), value: "fileEditor", @@ -146,9 +136,9 @@ const createViewOptions = ( label: (
{switchingTo === view.workbenchId ? ( - + ) : ( - view.icon || + view.icon || )} {view.label}
@@ -344,7 +334,7 @@ const TopControls = ({ return (
-
+