diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 845d0d450..44f96cfad 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -29,6 +29,98 @@ interface DragDropGridProps { zoomLevel?: number; } +type DropSide = 'left' | 'right' | null; + +interface DropHint { + hoveredId: string | null; + dropSide: DropSide; +} + +function resolveDropHint( + activeId: string | null, + itemRefs: React.MutableRefObject>, + cursorX: number, + cursorY: number, +): DropHint { + if (!activeId) { + return { hoveredId: null, dropSide: null }; + } + + const rows = new Map>(); + + itemRefs.current.forEach((element, itemId) => { + if (!element || itemId === activeId) return; + + const rect = element.getBoundingClientRect(); + const rowCenter = rect.top + rect.height / 2; + + let row = rows.get(rowCenter); + if (!row) { + row = []; + rows.set(rowCenter, row); + } + row.push({ id: itemId, rect }); + }); + + let hoveredId: string | null = null; + let dropSide: DropSide = null; + + 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) { + return { hoveredId: null, dropSide: null }; + } + + let closestDistance = Infinity; + closestRow.forEach(({ id, rect }) => { + const distanceToLeft = Math.abs(cursorX - rect.left); + const distanceToRight = Math.abs(cursorX - rect.right); + + if (distanceToLeft < closestDistance) { + closestDistance = distanceToLeft; + hoveredId = id; + dropSide = 'left'; + } + if (distanceToRight < closestDistance) { + closestDistance = distanceToRight; + hoveredId = id; + dropSide = 'right'; + } + }); + + return { hoveredId, dropSide }; +} + +function resolveTargetIndex( + hoveredId: string | null, + dropSide: DropSide, + items: T[], + fallbackIndex: number | null, +): number | null { + if (hoveredId) { + const hoveredIndex = items.findIndex(item => item.id === hoveredId); + if (hoveredIndex !== -1) { + return hoveredIndex + (dropSide === 'right' ? 1 : 0); + } + } + + if (fallbackIndex !== null && fallbackIndex !== undefined) { + return fallbackIndex + (dropSide === 'right' ? 1 : 0); + } + + return null; +} + // Lightweight wrapper that handles dnd-kit hooks for each visible item interface DraggableItemProps { item: T; @@ -126,7 +218,7 @@ const DragDropGrid = ({ 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 [dropSide, setDropSide] = useState(null); // Configure sensors for dnd-kit with activation constraint // Require 10px movement before drag starts to allow clicks for selection @@ -156,74 +248,9 @@ const DragDropGrid = ({ 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; - Array.from(rows.keys()).forEach((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); + const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY); + setHoveredItemId(hint.hoveredId); + setDropSide(hint.dropSide); rafId = null; }); } @@ -442,21 +469,13 @@ const DragDropGrid = ({ const sourcePageNumber = activeData.pageNumber; - let targetIndex: number | null = null; - - if (hoveredItemId) { - const hoveredIndex = visibleItems.findIndex(item => item.id === hoveredItemId); - if (hoveredIndex !== -1) { - targetIndex = hoveredIndex + (finalDropSide === 'right' ? 1 : 0); - } - } - - if (targetIndex === null && over) { - const overData = over.data.current; - if (overData) { - targetIndex = overData.index + (finalDropSide === 'right' ? 1 : 0); - } - } + const overData = over?.data.current; + const targetIndex = resolveTargetIndex( + hoveredItemId, + finalDropSide, + visibleItems, + overData ? overData.index : null + ); if (targetIndex === null) return; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 682fd077e..62b7be891 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -32,6 +32,7 @@ import { usePageDocument } from './hooks/usePageDocument'; import { usePageEditorState } from './hooks/usePageEditorState'; import { parseSelection } from "../../utils/bulkselection/parseSelection"; import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons"; +import { useFileColorMap } from "./hooks/useFileColorMap"; export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; @@ -981,30 +982,7 @@ const PageEditor = ({ const displayedPages = displayDocument?.pages || []; // Track color assignments by insertion order (files keep their color) - const fileColorAssignments = useRef(new Map()); - - // Create a stable mapping of fileId to color index (preserves colors on reorder) - const fileColorIndexMap = useMemo(() => { - const assignments = fileColorAssignments.current; - - // Remove colors for files that no longer exist - const activeIds = new Set(orderedFileIds); - for (const fileId of Array.from(assignments.keys())) { - if (!activeIds.has(fileId)) { - assignments.delete(fileId); - } - } - - // Assign colors to new files based on insertion order - orderedFileIds.forEach(fileId => { - if (!assignments.has(fileId)) { - assignments.set(fileId, assignments.size); - } - }); - - // Clean up removed files (only remove files that are completely gone, not just deselected) - return assignments; - }, [orderedFileIds.join(',')]); // Only recalculate when the set of files changes, not the order + const fileColorIndexMap = useFileColorMap(orderedFileIds); return ( { + const assignmentsRef = useRef(new Map()); + + const serializedIds = useMemo(() => fileIds.join(','), [fileIds]); + + return useMemo(() => { + const assignments = assignmentsRef.current; + const activeIds = new Set(fileIds); + + // Remove colors for files that no longer exist + for (const id of Array.from(assignments.keys())) { + if (!activeIds.has(id)) { + assignments.delete(id); + } + } + + // Assign colors to any new files + fileIds.forEach((id) => { + if (!assignments.has(id)) { + assignments.set(id, assignments.size); + } + }); + + return assignments; + }, [serializedIds, fileIds]); +} + diff --git a/frontend/src/components/pageEditor/hooks/usePageEditorDropdownState.ts b/frontend/src/components/pageEditor/hooks/usePageEditorDropdownState.ts new file mode 100644 index 000000000..1b7284a80 --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageEditorDropdownState.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { usePageEditor } from '../../../contexts/PageEditorContext'; +import { useFileState } from '../../../contexts/FileContext'; +import { FileId } from '../../../types/file'; +import { useFileColorMap } from './useFileColorMap'; + +export interface PageEditorDropdownFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} + +export interface PageEditorDropdownState { + files: PageEditorDropdownFile[]; + selectedCount: number; + totalCount: number; + onToggleSelection: (fileId: FileId) => void; + onReorder: (fromIndex: number, toIndex: number) => void; + fileColorMap: Map; +} + +const isPdf = (name?: string | null) => + typeof name === 'string' && name.toLowerCase().endsWith('.pdf'); + +export function usePageEditorDropdownState(): PageEditorDropdownState { + const { state, selectors } = useFileState(); + const { + toggleFileSelection, + reorderFiles, + fileOrder, + } = usePageEditor(); + + const pageEditorFiles = useMemo(() => { + return fileOrder + .map((fileId) => { + const stub = selectors.getStirlingFileStub(fileId); + if (!isPdf(stub?.name)) return null; + + return { + fileId, + name: stub?.name || '', + versionNumber: stub?.versionNumber, + isSelected: state.ui.selectedFileIds.includes(fileId), + }; + }) + .filter((file): file is PageEditorDropdownFile => file !== null); + }, [fileOrder, selectors, state.ui.selectedFileIds]); + + const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId)); + + const selectedCount = useMemo( + () => pageEditorFiles.filter((file) => file.isSelected).length, + [pageEditorFiles] + ); + + return useMemo(() => ({ + files: pageEditorFiles, + selectedCount, + totalCount: pageEditorFiles.length, + onToggleSelection: toggleFileSelection, + onReorder: reorderFiles, + fileColorMap, + }), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]); +} diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index d01a402c8..112a34852 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -8,29 +8,9 @@ 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'; -import { useFileState } from '../../contexts/FileContext'; -import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; -import { FileId } from '../../types/file'; import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext'; import { FileDropdownMenu } from './FileDropdownMenu'; - -// Local interface for PageEditor file display -interface PageEditorFile { - fileId: FileId; - name: string; - versionNumber?: number; - isSelected: boolean; -} - -interface PageEditorState { - files: PageEditorFile[]; - selectedCount: number; - totalCount: number; - onToggleSelection: (fileId: FileId) => void; - onReorder: (fromIndex: number, toIndex: number) => void; - fileColorMap: Map; -} +import { usePageEditorDropdownState, PageEditorDropdownState } from '../pageEditor/hooks/usePageEditorDropdownState'; const viewOptionStyle: React.CSSProperties = { display: 'inline-flex', @@ -48,7 +28,7 @@ const createViewOptions = ( activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>, currentFileIndex: number, onFileSelect?: (index: number) => void, - pageEditorState?: PageEditorState, + pageEditorState?: PageEditorDropdownState, customViews?: CustomWorkbenchViewInstance[] ) => { // Viewer dropdown logic @@ -169,115 +149,7 @@ const TopControls = ({ const { isRainbowMode } = useRainbowThemeContext(); const [switchingTo, setSwitchingTo] = useState(null); - // Get FileContext state and PageEditor coordination functions - const { state, selectors } = useFileState(); - const pageEditorContext = usePageEditor(); - const { - toggleFileSelection, - reorderFiles: pageEditorReorderFiles, - fileOrder: pageEditorFileOrder, - } = pageEditorContext; - - // Derive page editor files from PageEditorContext.fileOrder (page editor workspace order) - // Filter to only show PDF files (PageEditor only supports PDFs) - // Use stable string keys to prevent infinite loops - // Cache file objects to prevent infinite re-renders from new object references - const fileOrderKey = pageEditorFileOrder.join(','); - const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(','); - const filesSignature = selectors.getFilesSignature(); - - const fileObjectsRef = React.useRef(new Map()); - - const pageEditorFiles = useMemo(() => { - const cache = fileObjectsRef.current; - const newFiles: PageEditorFile[] = []; - - // Use PageEditorContext.fileOrder instead of state.files.ids - pageEditorFileOrder.forEach(fileId => { - const stub = selectors.getStirlingFileStub(fileId); - const isSelected = state.ui.selectedFileIds.includes(fileId); - const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false; - - if (!isPdf) return; // Skip non-PDFs - - const cached = cache.get(fileId); - - // Check if data actually changed (compare by fileId, not position) - if (cached && - cached.fileId === fileId && - cached.name === (stub?.name || '') && - cached.versionNumber === stub?.versionNumber && - cached.isSelected === isSelected) { - // Reuse existing object reference - newFiles.push(cached); - } else { - // Create new object only if data changed - const newFile: PageEditorFile = { - fileId, - name: stub?.name || '', - versionNumber: stub?.versionNumber, - isSelected, - }; - cache.set(fileId, newFile); - newFiles.push(newFile); - } - }); - - // Clean up removed files from cache - const activeIds = new Set(newFiles.map(f => f.fileId)); - for (const cachedId of cache.keys()) { - if (!activeIds.has(cachedId)) { - cache.delete(cachedId); - } - } - - return newFiles; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileOrderKey, selectedIdsKey, filesSignature, pageEditorFileOrder, state.ui.selectedFileIds, selectors]); - - // Convert to counts - const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0; - const totalCount = pageEditorFiles?.length || 0; - - // Create stable file IDs string for dependency (only changes when file set changes) - const fileIdsString = (pageEditorFiles || []).map(f => f.fileId).sort().join(','); - - // Track color assignments by insertion order (files keep their color) - const fileColorAssignments = React.useRef(new Map()); - - // Create stable file color mapping (preserves colors on reorder) - const fileColorMap = useMemo(() => { - const map = new Map(); - if (!pageEditorFiles || pageEditorFiles.length === 0) return map; - - const allFileIds = (pageEditorFiles || []).map(f => f.fileId as string); - - // Assign colors to new files based on insertion order - allFileIds.forEach(fileId => { - if (!fileColorAssignments.current.has(fileId)) { - fileColorAssignments.current.set(fileId, fileColorAssignments.current.size); - } - }); - - // Clean up removed files - const activeSet = new Set(allFileIds); - for (const fileId of fileColorAssignments.current.keys()) { - if (!activeSet.has(fileId)) { - fileColorAssignments.current.delete(fileId); - } - } - - return fileColorAssignments.current; - }, [fileIdsString]); - - // Get pageEditorFunctions from ToolWorkflowContext - const { pageEditorFunctions } = useToolWorkflow(); - - // Memoize the reorder handler - const handleReorder = useCallback((fromIndex: number, toIndex: number) => { - // Single source of truth: PageEditorContext handles file->page reorder propagation - pageEditorReorderFiles(fromIndex, toIndex); - }, [pageEditorReorderFiles]); + const pageEditorState = usePageEditorDropdownState(); const handleViewChange = useCallback((view: string) => { if (!isValidWorkbench(view)) { @@ -301,16 +173,6 @@ const TopControls = ({ }); }, [setCurrentView]); - // Memoize pageEditorState object to prevent recreating on every render - const pageEditorState = useMemo(() => ({ - 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, diff --git a/frontend/src/components/tooltips/useSplitSettingsTips.ts b/frontend/src/components/tooltips/useSplitSettingsTips.ts index bfc80a7d9..7f6aae8b0 100644 --- a/frontend/src/components/tooltips/useSplitSettingsTips.ts +++ b/frontend/src/components/tooltips/useSplitSettingsTips.ts @@ -2,11 +2,13 @@ import { useTranslation } from 'react-i18next'; import { TooltipContent } from '../../types/tips'; import { SPLIT_METHODS, type SplitMethod } from '../../constants/splitConstants'; -export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | null => { +/** + * Hook that returns tooltip content for ALL split methods + * Can be called once and then looked up by method + */ +export const useSplitSettingsTips = (): Record => { const { t } = useTranslation(); - if (!method) return null; - const tooltipMap: Record = { [SPLIT_METHODS.BY_PAGES]: { header: { @@ -130,5 +132,5 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | } }; - return tooltipMap[method]; + return tooltipMap; }; \ No newline at end of file diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 4eaf38e7b..a4f70e7cd 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -21,12 +21,17 @@ const Split = (props: BaseToolProps) => { ); const methodTips = useSplitMethodTips(); - const settingsTips = useSplitSettingsTips(base.params.parameters.method); + const allSettingsTips = useSplitSettingsTips(); + + // Get tooltip content for the currently selected method + const settingsTips = base.params.parameters.method + ? allSettingsTips[base.params.parameters.method] + : null; // Get tooltip content for a specific method - const getMethodTooltip = (_option: MethodOption) => { - // TODO: Fix hook call in non-React function - return []; + const getMethodTooltip = (option: MethodOption) => { + const tooltipContent = allSettingsTips[option.value]; + return tooltipContent?.tips || []; }; // Get the method name for the settings step title