Drag and drop improvements basic box select

This commit is contained in:
Reece 2025-10-24 11:33:31 +01:00
parent ddefe81082
commit eef5dce849
5 changed files with 150 additions and 37 deletions

View File

@ -24,7 +24,7 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
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, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
}
@ -38,13 +38,15 @@ interface DraggableItemProps<T extends DragDropItem> {
clearBoxSelection: () => void;
getBoxSelection: () => string[];
activeId: string | null;
activeDragIds: string[];
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
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, zoomLevel?: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
data: {
@ -90,7 +92,7 @@ const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelec
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
@ -117,6 +119,8 @@ const DragDropGrid = <T extends DragDropItem>({
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
const justMovedIdsRef = useRef(new Set<string>());
const [, forceUpdate] = useState(0);
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
@ -301,24 +305,37 @@ const DragDropGrid = <T extends DragDropItem>({
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only start box select if Ctrl/Cmd is held
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
if (e.button !== 0) return; // Only respond to primary button
// Clear previous box selection when starting new one
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
} else {
// Clear box selection when clicking without Ctrl
if (boxSelectedPageIds.length > 0) {
const container = containerRef.current;
if (!container) return;
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
}
});
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
}, [boxSelectedPageIds.length]);
e.preventDefault();
const rect = container.getBoundingClientRect();
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
}, [boxSelectedPageIds]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isBoxSelecting || !boxSelectStart) return;
@ -421,18 +438,32 @@ const DragDropGrid = <T extends DragDropItem>({
// Get data from hooks
const activeData = active.data.current;
const overData = over.data.current;
if (!activeData || !overData) return;
if (!activeData) return;
const sourcePageNumber = activeData.pageNumber;
let targetIndex = overData.index;
// Use the final drop side to adjust target index
if (finalDropSide === 'right') {
targetIndex = targetIndex + 1;
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);
}
}
if (targetIndex === null) return;
// Clamp to bounds
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > visibleItems.length) targetIndex = visibleItems.length;
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
@ -440,6 +471,17 @@ const DragDropGrid = <T extends DragDropItem>({
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = new Set(pagesToDrag ?? [active.id as string]);
justMovedIdsRef.current = movedIds;
forceUpdate(prev => prev + 1);
window.setTimeout(() => {
if (justMovedIdsRef.current === movedIds) {
justMovedIdsRef.current = new Set();
forceUpdate(prev => prev + 1);
}
}, 1200);
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
@ -495,6 +537,14 @@ const DragDropGrid = <T extends DragDropItem>({
};
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
const activeDragIds = useMemo(() => {
if (!activeId) return [];
if (boxSelectedPageIds.includes(activeId)) {
return boxSelectedPageIds;
}
return [activeId];
}, [activeId, boxSelectedPageIds]);
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
@ -589,6 +639,8 @@ const DragDropGrid = <T extends DragDropItem>({
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMovedIdsRef.current.has(item.id)}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}

View File

@ -11,6 +11,26 @@
transform: scale(1.02) translateZ(0);
}
.pageSurface {
transition: background-color 0.4s ease;
}
.pageJustMoved {
animation: pageMovedHighlight 1.2s ease-out;
}
@keyframes pageMovedHighlight {
0% {
background-color: rgba(59, 130, 246, 0.32);
}
60% {
background-color: rgba(59, 130, 246, 0.12);
}
100% {
background-color: rgba(59, 130, 246, 0);
}
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}

View File

@ -350,6 +350,20 @@ const PageEditor = ({
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
// Automatically include newly added pages in the current selection
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) return;
const currentSelection = new Set(selectedPageIds);
const newlyAddedPageIds = displayDocument.pages
.map(page => page.id)
.filter(pageId => !currentSelection.has(pageId));
if (newlyAddedPageIds.length > 0) {
setSelectedPageIds([...selectedPageIds, ...newlyAddedPageIds]);
}
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
@ -845,6 +859,7 @@ const PageEditor = ({
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges: applyChanges,
exportLoading,
activeFileCount: activeFileIds.length,
closePdf,
@ -970,22 +985,25 @@ const PageEditor = ({
// 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 (!fileColorAssignments.current.has(fileId)) {
fileColorAssignments.current.set(fileId, fileColorAssignments.current.size);
if (!assignments.has(fileId)) {
assignments.set(fileId, assignments.size);
}
});
// Clean up removed files (only remove files that are completely gone, not just deselected)
const allFilesSet = new Set(orderedFileIds);
for (const fileId of fileColorAssignments.current.keys()) {
if (!allFilesSet.has(fileId)) {
fileColorAssignments.current.delete(fileId);
}
}
return fileColorAssignments.current;
return assignments;
}, [orderedFileIds.join(',')]); // Only recalculate when the set of files changes, not the order
return (
@ -1089,7 +1107,7 @@ const PageEditor = ({
rotation: page.rotation || 0
};
}}
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dragHandleProps, zoomLevel) => {
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, dragHandleProps, zoomLevel) => {
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
@ -1109,6 +1127,8 @@ const PageEditor = ({
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMoved}
isOver={isOver}
pageRefs={refs}
dragHandleProps={dragHandleProps}

View File

@ -32,6 +32,8 @@ interface PageThumbnailProps {
clearBoxSelection?: () => void;
getBoxSelection?: () => string[];
activeId: string | null;
activeDragIds: string[];
justMoved?: boolean;
isOver: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
@ -67,6 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
clearBoxSelection,
// getBoxSelection,
activeId,
activeDragIds,
// isOver,
pageRefs,
dragHandleProps,
@ -82,6 +85,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
splitPositions,
onInsertFiles,
zoomLevel = 1.0,
justMoved = false,
}: PageThumbnailProps) => {
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
@ -94,7 +98,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const { openFilesModal } = useFilesModalContext();
// Check if this page is currently being dragged
const isDragging = activeId === page.id;
const isDragging = activeDragIds.includes(page.id);
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
@ -415,6 +419,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
style={{
width: '100%',
height: '100%',

View File

@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams {
handleDeselectAll: () => void;
handleDelete: () => void;
onExportSelected: () => void;
onSaveChanges: () => void;
exportLoading: boolean;
activeFileCount: number;
closePdf: () => void;
@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
visible: totalPages > 0,
onClick: onExportSelected,
},
{
id: 'page-save-changes',
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
tooltip: saveChangesLabel,
ariaLabel: saveChangesLabel,
section: 'top' as const,
order: 55,
disabled: totalPages === 0 || exportLoading,
visible: totalPages > 0,
onClick: onSaveChanges,
},
{
id: 'page-close-pdf',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
selectByNumberLabel,
deleteSelectedLabel,
exportSelectedLabel,
saveChangesLabel,
closePdfLabel,
totalPages,
selectedPageCount,
@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,