From a5ec62fa08235e34692c60477790ee8526d18d9b Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 17 Oct 2025 15:24:05 +0100 Subject: [PATCH] Performance improvements --- .../components/pageEditor/DragDropGrid.tsx | 196 ++++++++++++++---- .../src/components/pageEditor/PageEditor.tsx | 3 +- .../components/pageEditor/PageThumbnail.tsx | 18 -- .../src/components/shared/TopControls.tsx | 36 ++-- frontend/src/contexts/PreferencesContext.tsx | 18 +- frontend/src/contexts/ViewerContext.tsx | 83 ++++---- 6 files changed, 238 insertions(+), 116 deletions(-) diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 61c2f6a9e..faa51acb2 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react'; +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { Box } from '@mantine/core'; import { useVirtualizer } from '@tanstack/react-virtual'; import { GRID_CONSTANTS } from './constants'; @@ -29,7 +29,7 @@ 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, dropSide: 'left' | 'right' | null, 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) => React.ReactNode; renderSplitMarker?: (item: T, index: number) => React.ReactNode; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; } @@ -44,13 +44,11 @@ interface DraggableItemProps { getBoxSelection: () => string[]; activeId: string | null; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; - renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dropSide: 'left' | 'right' | null, dragHandleProps?: any) => React.ReactNode; + 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; } -const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem }: DraggableItemProps) => { - const elementRef = React.useRef(null); - const [dropSide, setDropSide] = React.useState<'left' | 'right' | null>(null); - +const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget }: DraggableItemProps) => { const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: item.id, data: { @@ -80,33 +78,23 @@ const DraggableItem = ({ item, index, itemRefs, boxSelec data: { index, pageNumber: index + 1 } }); - // Track cursor position when hovered to determine drop side + // Notify parent when hover state changes React.useEffect(() => { - if (!isOver || !elementRef.current) { - setDropSide(null); - return; + if (isOver) { + onUpdateDropTarget(item.id); + } else { + onUpdateDropTarget(null); } - - const handlePointerMove = (e: PointerEvent) => { - if (!elementRef.current) return; - const rect = elementRef.current.getBoundingClientRect(); - const midpoint = rect.left + rect.width / 2; - setDropSide(e.clientX < midpoint ? 'left' : 'right'); - }; - - window.addEventListener('pointermove', handlePointerMove); - return () => window.removeEventListener('pointermove', handlePointerMove); - }, [isOver]); + }, [isOver, item.id, onUpdateDropTarget]); const setNodeRef = useCallback((element: HTMLDivElement | null) => { - elementRef.current = element; setDraggableRef(element); setDroppableRef(element); }, [setDraggableRef, setDroppableRef]); return ( <> - {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, dropSide, { ref: setNodeRef, ...attributes, ...listeners })} + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners })} ); }; @@ -132,12 +120,115 @@ const DragDropGrid = ({ // Drag state const [activeId, setActiveId] = useState(null); const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null); + const [hoveredItemId, setHoveredItemId] = useState(null); + const [dropSide, setDropSide] = useState<'left' | 'right' | null>(null); + const lastCursorXRef = useRef(null); // Configure sensors for dnd-kit const sensors = useSensors( useSensor(PointerSensor) ); + // Throttled pointer move handler for drop indicator + // Calculate drop position based on cursor location relative to ALL items, not just hovered item + useEffect(() => { + if (!activeId) { + setDropSide(null); + setHoveredItemId(null); + return; + } + + let rafId: number | null = null; + + const handlePointerMove = (e: PointerEvent) => { + // Use the actual cursor position (pointer coordinates) + const cursorX = e.clientX; + const cursorY = e.clientY; + + if (rafId === null) { + rafId = requestAnimationFrame(() => { + // Step 1: Group items by rows and find closest row to cursor + const rows = new Map>(); + + itemRefs.current.forEach((element, itemId) => { + // Skip the item being dragged + if (itemId === activeId) return; + + const rect = element.getBoundingClientRect(); + const rowCenter = rect.top + rect.height / 2; + + // Group items by their vertical center position (items in same row will have similar centers) + let foundRow = false; + rows.forEach((items, rowY) => { + if (Math.abs(rowY - rowCenter) < rect.height / 4) { + items.push({ id: itemId, element, rect }); + foundRow = true; + } + }); + + if (!foundRow) { + rows.set(rowCenter, [{ id: itemId, element, rect }]); + } + }); + + // Step 2: Find the closest row to cursor Y position + let closestRowY = 0; + let closestRowDistance = Infinity; + rows.forEach((items, rowY) => { + const distance = Math.abs(cursorY - rowY); + if (distance < closestRowDistance) { + closestRowDistance = distance; + closestRowY = rowY; + } + }); + + const closestRow = rows.get(closestRowY); + if (!closestRow || closestRow.length === 0) { + setHoveredItemId(null); + setDropSide(null); + rafId = null; + return; + } + + // Step 3: Within the closest row, find the closest edge to cursor X position + let closestItemId: string | null = null; + let closestDistance = Infinity; + let closestSide: 'left' | 'right' = 'left'; + + closestRow.forEach(({ id, rect }) => { + // Calculate distance to left and right edges + const distanceToLeft = Math.abs(cursorX - rect.left); + const distanceToRight = Math.abs(cursorX - rect.right); + + // Find the closest edge + if (distanceToLeft < closestDistance) { + closestDistance = distanceToLeft; + closestItemId = id; + closestSide = 'left'; + } + if (distanceToRight < closestDistance) { + closestDistance = distanceToRight; + closestItemId = id; + closestSide = 'right'; + } + }); + + setHoveredItemId(closestItemId); + setDropSide(closestSide); + rafId = null; + }); + } + }; + + window.addEventListener('pointermove', handlePointerMove, { passive: true }); + return () => { + window.removeEventListener('pointermove', handlePointerMove); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [activeId]); + // Responsive grid configuration const [itemsPerRow, setItemsPerRow] = useState(4); const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; @@ -300,14 +391,19 @@ const DragDropGrid = ({ const handleDragCancel = useCallback(() => { setActiveId(null); setDragPreview(null); + setHoveredItemId(null); + setDropSide(null); }, []); // Handle drag end const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; + const finalDropSide = dropSide; setActiveId(null); setDragPreview(null); + setHoveredItemId(null); + setDropSide(null); if (!over || active.id === over.id) { return; @@ -322,18 +418,9 @@ const DragDropGrid = ({ const sourcePageNumber = activeData.pageNumber; let targetIndex = overData.index; - // Calculate drop position from final cursor position - const overElement = itemRefs.current.get(over.id as string); - if (overElement) { - const activatorEvent = (event as any).activatorEvent; - const cursorX = activatorEvent ? activatorEvent.clientX + (event.delta?.x || 0) : null; - if (cursorX) { - const rect = overElement.getBoundingClientRect(); - const midpoint = rect.left + rect.width / 2; - if (cursorX >= midpoint) { - targetIndex = targetIndex + 1; - } - } + // Use the final drop side to adjust target index + if (finalDropSide === 'right') { + targetIndex = targetIndex + 1; } // Check if this page is box-selected @@ -347,7 +434,7 @@ const DragDropGrid = ({ if (pagesToDrag) { clearBoxSelection(); } - }, [boxSelectedPageIds, onReorderPages, clearBoxSelection]); + }, [boxSelectedPageIds, dropSide, onReorderPages, clearBoxSelection]); // Calculate optimal width for centering const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); @@ -368,6 +455,37 @@ const DragDropGrid = ({ zIndex: 1000, } : null; + // Calculate drop indicator position + const dropIndicatorStyle = useMemo(() => { + if (!hoveredItemId || !dropSide || !activeId) return null; + + const element = itemRefs.current.get(hoveredItemId); + const container = containerRef.current; + if (!element || !container) return null; + + const itemRect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + const top = itemRect.top - containerRect.top; + const height = itemRect.height; + const left = dropSide === 'left' + ? itemRect.left - containerRect.left - itemGap / 2 + : itemRect.right - containerRect.left + itemGap / 2; + + return { + position: 'absolute' as const, + left: `${left}px`, + top: `${top}px`, + width: '4px', + height: `${height}px`, + backgroundColor: 'rgba(96, 165, 250, 0.8)', + borderRadius: '2px', + boxShadow: '0 0 8px 2px rgba(96, 165, 250, 0.6)', + zIndex: 1001, + pointerEvents: 'none' as const, + }; + }, [hoveredItemId, dropSide, activeId, itemGap]); + return ( ({ {/* Selection box overlay */} {selectionBoxStyle &&
} + {/* Global drop indicator */} + {dropIndicatorStyle &&
} +
({ getBoxSelection={getBoxSelection} activeId={activeId} getThumbnailData={getThumbnailData} + onUpdateDropTarget={setHoveredItemId} renderItem={renderItem} /> ); diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 87f53d055..8154ad2ea 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -974,7 +974,7 @@ const PageEditor = ({ rotation: page.rotation || 0 }; }} - renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dropSide, dragHandleProps) => { + renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dragHandleProps) => { const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; const isBoxSelected = boxSelectedIds.includes(page.id); return ( @@ -995,7 +995,6 @@ const PageEditor = ({ getBoxSelection={getBoxSelection} activeId={activeId} isOver={isOver} - dropSide={dropSide} pageRefs={refs} dragHandleProps={dragHandleProps} onReorderPages={handleReorderPages} diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index cc6a2bd0b..95ebfc777 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -30,7 +30,6 @@ interface PageThumbnailProps { getBoxSelection?: () => string[]; activeId: string | null; isOver: boolean; - dropSide: 'left' | 'right' | null; pageRefs: React.MutableRefObject>; dragHandleProps?: any; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; @@ -65,7 +64,6 @@ const PageThumbnail: React.FC = ({ getBoxSelection, activeId, isOver, - dropSide, pageRefs, dragHandleProps, onReorderPages, @@ -304,22 +302,6 @@ const PageThumbnail: React.FC = ({ onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} > - {/* Drop indicator - bar on left or right edge */} - {isOver && !isDragging && dropSide && ( -
- )} {
(() => ({ + files: pageEditorFiles, + selectedCount, + totalCount, + onToggleSelection: toggleFileSelection, + onReorder: handleReorder, + fileColorMap, + }), [pageEditorFiles, selectedCount, totalCount, toggleFileSelection, handleReorder, fileColorMap]); + + // Memoize view options to prevent SegmentedControl re-renders + const viewOptions = useMemo(() => createViewOptions( + currentView, + switchingTo, + activeFiles, + currentFileIndex, + onFileSelect, + pageEditorState + ), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState]); + return (
= ({ c setPreferences(DEFAULT_PREFERENCES); }, []); + const value = React.useMemo(() => ({ + preferences, + updatePreference, + resetPreferences, + isLoading, + }), [preferences, updatePreference, resetPreferences, isLoading]); + return ( - + {children} ); diff --git a/frontend/src/contexts/ViewerContext.tsx b/frontend/src/contexts/ViewerContext.tsx index e3aa94487..c43b91873 100644 --- a/frontend/src/contexts/ViewerContext.tsx +++ b/frontend/src/contexts/ViewerContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useRef, useMemo, useCallback } from 'react'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; import { useNavigation } from './NavigationContext'; @@ -280,21 +280,21 @@ export const ViewerProvider: React.FC = ({ children }) => { } }; - const toggleThumbnailSidebar = () => { + const toggleThumbnailSidebar = useCallback(() => { setIsThumbnailSidebarVisible(prev => !prev); - }; + }, []); - const toggleAnnotationsVisibility = () => { + const toggleAnnotationsVisibility = useCallback(() => { setIsAnnotationsVisible(prev => !prev); - }; + }, []); - const setAnnotationMode = (enabled: boolean) => { + const setAnnotationMode = useCallback((enabled: boolean) => { setIsAnnotationModeState(enabled); - }; + }, []); - const toggleAnnotationMode = () => { + const toggleAnnotationMode = useCallback(() => { setIsAnnotationModeState(prev => !prev); - }; + }, []); // State getters - read from bridge refs const getScrollState = (): ScrollState => { @@ -334,7 +334,7 @@ export const ViewerProvider: React.FC = ({ children }) => { }; // Action handlers - call APIs directly - const scrollActions = { + const scrollActions = useMemo(() => ({ scrollToPage: (page: number) => { const api = bridgeRefs.current.scroll?.api; if (api?.scrollToPage) { @@ -366,9 +366,9 @@ export const ViewerProvider: React.FC = ({ children }) => { api.scrollToPage({ pageNumber: scrollState.totalPages }); } } - }; + }), []); - const zoomActions = { + const zoomActions = useMemo(() => ({ zoomIn: () => { const api = bridgeRefs.current.zoom?.api; if (api?.zoomIn) { @@ -405,9 +405,9 @@ export const ViewerProvider: React.FC = ({ children }) => { api.requestZoom(level); } } - }; + }), []); - const panActions = { + const panActions = useMemo(() => ({ enablePan: () => { const api = bridgeRefs.current.pan?.api; if (api?.enable) { @@ -426,9 +426,9 @@ export const ViewerProvider: React.FC = ({ children }) => { api.toggle(); } } - }; + }), []); - const selectionActions = { + const selectionActions = useMemo(() => ({ copyToClipboard: () => { const api = bridgeRefs.current.selection?.api; if (api?.copyToClipboard) { @@ -449,9 +449,9 @@ export const ViewerProvider: React.FC = ({ children }) => { } return null; } - }; + }), []); - const spreadActions = { + const spreadActions = useMemo(() => ({ setSpreadMode: (mode: SpreadMode) => { const api = bridgeRefs.current.spread?.api; if (api?.setSpreadMode) { @@ -471,9 +471,9 @@ export const ViewerProvider: React.FC = ({ children }) => { api.toggleSpreadMode(); } } - }; + }), []); - const rotationActions = { + const rotationActions = useMemo(() => ({ rotateForward: () => { const api = bridgeRefs.current.rotation?.api; if (api?.rotateForward) { @@ -499,9 +499,9 @@ export const ViewerProvider: React.FC = ({ children }) => { } return 0; } - }; + }), []); - const searchActions = { + const searchActions = useMemo(() => ({ search: async (query: string) => { const api = bridgeRefs.current.search?.api; if (api?.search) { @@ -526,9 +526,9 @@ export const ViewerProvider: React.FC = ({ children }) => { api.clear(); } } - }; + }), []); - const exportActions = { + const exportActions = useMemo(() => ({ download: () => { const api = bridgeRefs.current.export?.api; if (api?.download) { @@ -548,29 +548,29 @@ export const ViewerProvider: React.FC = ({ children }) => { } return null; } - }; + }), []); - const registerImmediateZoomUpdate = (callback: (percent: number) => void) => { + const registerImmediateZoomUpdate = useCallback((callback: (percent: number) => void) => { immediateZoomUpdateCallback.current = callback; - }; + }, []); - const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => { + const registerImmediateScrollUpdate = useCallback((callback: (currentPage: number, totalPages: number) => void) => { immediateScrollUpdateCallback.current = callback; - }; + }, []); - const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => { + const triggerImmediateScrollUpdate = useCallback((currentPage: number, totalPages: number) => { if (immediateScrollUpdateCallback.current) { immediateScrollUpdateCallback.current(currentPage, totalPages); } - }; + }, []); - const triggerImmediateZoomUpdate = (zoomPercent: number) => { + const triggerImmediateZoomUpdate = useCallback((zoomPercent: number) => { if (immediateZoomUpdateCallback.current) { immediateZoomUpdateCallback.current(zoomPercent); } - }; + }, []); - const value: ViewerContextType = { + const value = useMemo(() => ({ // UI state isThumbnailSidebarVisible, toggleThumbnailSidebar, @@ -615,7 +615,20 @@ export const ViewerProvider: React.FC = ({ children }) => { // Bridge registration registerBridge, - }; + }), [ + isThumbnailSidebarVisible, + isAnnotationsVisible, + isAnnotationMode, + activeFileIndex, + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + ]); return (