Performance improvements

This commit is contained in:
Reece 2025-10-17 15:24:05 +01:00
parent e7f7b7e201
commit a5ec62fa08
6 changed files with 238 additions and 116 deletions

View File

@ -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<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, dropSide: 'left' | 'right' | null, 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) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
}
@ -44,13 +44,11 @@ interface DraggableItemProps<T extends DragDropItem> {
getBoxSelection: () => string[];
activeId: string | null;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, 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<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any) => React.ReactNode;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem }: DraggableItemProps<T>) => {
const elementRef = React.useRef<HTMLDivElement | null>(null);
const [dropSide, setDropSide] = React.useState<'left' | 'right' | null>(null);
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget }: DraggableItemProps<T>) => {
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
data: {
@ -80,33 +78,23 @@ const DraggableItem = <T extends DragDropItem>({ 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 = <T extends DragDropItem>({
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [dropSide, setDropSide] = useState<'left' | 'right' | null>(null);
const lastCursorXRef = useRef<number | null>(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<number, Array<{ id: string; element: HTMLDivElement; rect: DOMRect }>>();
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 = <T extends DragDropItem>({
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 = <T extends DragDropItem>({
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 = <T extends DragDropItem>({
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 = <T extends DragDropItem>({
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 (
<DndContext
sensors={sensors}
@ -391,6 +509,9 @@ const DragDropGrid = <T extends DragDropItem>({
{/* Selection box overlay */}
{selectionBoxStyle && <div style={selectionBoxStyle} />}
{/* Global drop indicator */}
{dropIndicatorStyle && <div style={dropIndicatorStyle} />}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
@ -440,6 +561,7 @@ const DragDropGrid = <T extends DragDropItem>({
getBoxSelection={getBoxSelection}
activeId={activeId}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
/>
);

View File

@ -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}

View File

@ -30,7 +30,6 @@ interface PageThumbnailProps {
getBoxSelection?: () => string[];
activeId: string | null;
isOver: boolean;
dropSide: 'left' | 'right' | null;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
@ -65,7 +64,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
getBoxSelection,
activeId,
isOver,
dropSide,
pageRefs,
dragHandleProps,
onReorderPages,
@ -304,22 +302,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
{/* Drop indicator - bar on left or right edge */}
{isOver && !isDragging && dropSide && (
<div
style={{
position: 'absolute',
[dropSide]: '-4px',
top: '0',
width: '4px',
height: '100%',
backgroundColor: '#22c55e',
borderRadius: '2px',
zIndex: 1000,
pointerEvents: 'none'
}}
/>
)}
{
<div
className={styles.checkboxContainer}

View File

@ -304,25 +304,31 @@ const TopControls = ({
});
}, [setCurrentView]);
// Memoize pageEditorState object to prevent recreating on every render
const pageEditorState = useMemo<PageEditorState>(() => ({
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 (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
{
files: pageEditorFiles,
selectedCount,
totalCount,
onToggleSelection: toggleFileSelection,
onReorder: handleReorder,
fileColorMap,
}
)}
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService';
interface PreferencesContextValue {
@ -50,15 +50,15 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c
setPreferences(DEFAULT_PREFERENCES);
}, []);
const value = React.useMemo(() => ({
preferences,
updatePreference,
resetPreferences,
isLoading,
}), [preferences, updatePreference, resetPreferences, isLoading]);
return (
<PreferencesContext.Provider
value={{
preferences,
updatePreference,
resetPreferences,
isLoading,
}}
>
<PreferencesContext.Provider value={value}>
{children}
</PreferencesContext.Provider>
);

View File

@ -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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerContextType>(() => ({
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
@ -615,7 +615,20 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// Bridge registration
registerBridge,
};
}), [
isThumbnailSidebarVisible,
isAnnotationsVisible,
isAnnotationMode,
activeFileIndex,
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
]);
return (
<ViewerContext.Provider value={value}>