Enhance drag-and-drop functionality with new drop hint resolution and target index calculation; refactor file color mapping in PageEditor and implement dropdown state management for improved file handling.

This commit is contained in:
Reece 2025-10-24 13:28:50 +01:00
parent eef5dce849
commit 494f92421f
7 changed files with 222 additions and 257 deletions

View File

@ -29,6 +29,98 @@ interface DragDropGridProps<T extends DragDropItem> {
zoomLevel?: number;
}
type DropSide = 'left' | 'right' | null;
interface DropHint {
hoveredId: string | null;
dropSide: DropSide;
}
function resolveDropHint(
activeId: string | null,
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
cursorX: number,
cursorY: number,
): DropHint {
if (!activeId) {
return { hoveredId: null, dropSide: null };
}
const rows = new Map<number, Array<{ id: string; rect: DOMRect }>>();
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<T extends DragDropItem>(
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<T extends DragDropItem> {
item: T;
@ -126,7 +218,7 @@ const DragDropGrid = <T extends DragDropItem>({
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 [dropSide, setDropSide] = useState<DropSide>(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 = <T extends DragDropItem>({
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;
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 = <T extends DragDropItem>({
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;

View File

@ -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<FileId, number>());
// 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 (
<Box

View File

@ -0,0 +1,34 @@
import { useMemo, useRef } from 'react';
import { FileId } from '../../../types/file';
/**
* Maintains stable color assignments for a collection of file IDs.
* Colors are assigned by insertion order and preserved across reorders.
*/
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
const assignmentsRef = useRef(new Map<FileId, number>());
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]);
}

View File

@ -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<FileId, number>;
}
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<PageEditorDropdownFile | null>((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<PageEditorDropdownState>(() => ({
files: pageEditorFiles,
selectedCount,
totalCount: pageEditorFiles.length,
onToggleSelection: toggleFileSelection,
onReorder: reorderFiles,
fileColorMap,
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
}

View File

@ -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<string, number>;
}
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<WorkbenchType | null>(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<FileId, PageEditorFile>());
const pageEditorFiles = useMemo<PageEditorFile[]>(() => {
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<string, number>());
// Create stable file color mapping (preserves colors on reorder)
const fileColorMap = useMemo(() => {
const map = new Map<string, number>();
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<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,

View File

@ -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<SplitMethod, TooltipContent> => {
const { t } = useTranslation();
if (!method) return null;
const tooltipMap: Record<SplitMethod, TooltipContent> = {
[SPLIT_METHODS.BY_PAGES]: {
header: {
@ -130,5 +132,5 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent |
}
};
return tooltipMap[method];
return tooltipMap;
};

View File

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