mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
improved
This commit is contained in:
parent
74e8388bce
commit
e7f7b7e201
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
@ -496,6 +497,63 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
|
||||
@ -2,6 +2,20 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { GRID_CONSTANTS } from './constants';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useDndMonitor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
@ -15,13 +29,93 @@ 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>>) => React.ReactNode;
|
||||
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;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
}
|
||||
|
||||
// Lightweight wrapper that handles dnd-kit hooks for each visible item
|
||||
interface DraggableItemProps<T extends DragDropItem> {
|
||||
item: T;
|
||||
index: number;
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
boxSelectedPageIds: string[];
|
||||
clearBoxSelection: () => void;
|
||||
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;
|
||||
}
|
||||
|
||||
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 { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
||||
id: item.id,
|
||||
data: {
|
||||
index,
|
||||
pageNumber: index + 1,
|
||||
getThumbnail: () => {
|
||||
if (getThumbnailData) {
|
||||
const data = getThumbnailData(item.id);
|
||||
if (data?.src) return data;
|
||||
}
|
||||
|
||||
const element = itemRefs.current.get(item.id);
|
||||
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
||||
if (imgElement?.src) {
|
||||
return {
|
||||
src: imgElement.src,
|
||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: item.id,
|
||||
data: { index, pageNumber: index + 1 }
|
||||
});
|
||||
|
||||
// Track cursor position when hovered to determine drop side
|
||||
React.useEffect(() => {
|
||||
if (!isOver || !elementRef.current) {
|
||||
setDropSide(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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 })}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
renderItem,
|
||||
onReorderPages,
|
||||
getThumbnailData,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -29,6 +123,21 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
// Filter out placeholder items (invisible pages for deselected files)
|
||||
const visibleItems = items.filter(item => !item.isPlaceholder);
|
||||
|
||||
// Box selection state
|
||||
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
||||
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
||||
|
||||
// Drag state
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
||||
|
||||
// Configure sensors for dnd-kit
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor)
|
||||
);
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
@ -89,21 +198,199 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// 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) {
|
||||
setBoxSelectedPageIds([]);
|
||||
}
|
||||
}
|
||||
}, [boxSelectedPageIds.length]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isBoxSelecting || !boxSelectStart) return;
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
|
||||
// Calculate which pages intersect with selection box
|
||||
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
||||
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
||||
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
||||
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
||||
|
||||
const selectedIds: string[] = [];
|
||||
itemRefs.current.forEach((pageEl, pageId) => {
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const pageLeft = pageRect.left - rect.left;
|
||||
const pageRight = pageRect.right - rect.left;
|
||||
const pageTop = pageRect.top - rect.top;
|
||||
const pageBottom = pageRect.bottom - rect.top;
|
||||
|
||||
// Check if page intersects with selection box
|
||||
const intersects = !(
|
||||
pageRight < boxLeft ||
|
||||
pageLeft > boxRight ||
|
||||
pageBottom < boxTop ||
|
||||
pageTop > boxBottom
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
selectedIds.push(pageId);
|
||||
}
|
||||
});
|
||||
|
||||
setBoxSelectedPageIds(selectedIds);
|
||||
}, [isBoxSelecting, boxSelectStart]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isBoxSelecting) {
|
||||
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
|
||||
// They will remain highlighted until next interaction
|
||||
setIsBoxSelecting(false);
|
||||
setBoxSelectStart(null);
|
||||
setBoxSelectEnd(null);
|
||||
}
|
||||
}, [isBoxSelecting]);
|
||||
|
||||
// Function to clear box selection (exposed to child components)
|
||||
const clearBoxSelection = useCallback(() => {
|
||||
setBoxSelectedPageIds([]);
|
||||
}, []);
|
||||
|
||||
// Function to get current box selection (exposed to child components)
|
||||
const getBoxSelection = useCallback(() => {
|
||||
return boxSelectedPageIds;
|
||||
}, [boxSelectedPageIds]);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const activeId = event.active.id as string;
|
||||
setActiveId(activeId);
|
||||
|
||||
// Call the getter function to get fresh thumbnail data
|
||||
const getThumbnail = event.active.data.current?.getThumbnail;
|
||||
if (getThumbnail) {
|
||||
const thumbnailData = getThumbnail();
|
||||
if (thumbnailData?.src) {
|
||||
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDragPreview(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// Handle drag cancel
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setDragPreview(null);
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveId(null);
|
||||
setDragPreview(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get data from hooks
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
if (!activeData || !overData) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this page is box-selected
|
||||
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
||||
|
||||
// Call reorder with page number and target index
|
||||
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||
|
||||
// Clear box selection after drag
|
||||
if (pagesToDrag) {
|
||||
clearBoxSelection();
|
||||
}
|
||||
}, [boxSelectedPageIds, onReorderPages, clearBoxSelection]);
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
// Calculate selection box dimensions
|
||||
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
||||
position: 'absolute' as const,
|
||||
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
||||
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
||||
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
||||
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
||||
border: '2px dashed #3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 1000,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Selection box overlay */}
|
||||
{selectionBoxStyle && <div style={selectionBoxStyle} />}
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
@ -143,10 +430,18 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
<DraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={actualIndex}
|
||||
itemRefs={itemRefs}
|
||||
boxSelectedPageIds={boxSelectedPageIds}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
getBoxSelection={getBoxSelection}
|
||||
activeId={activeId}
|
||||
getThumbnailData={getThumbnailData}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -156,6 +451,66 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<div style={{ position: 'relative', cursor: 'grabbing' }}>
|
||||
{/* Multi-page badge */}
|
||||
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
zIndex: 1001
|
||||
}}
|
||||
>
|
||||
{boxSelectedPageIds.length}
|
||||
</div>
|
||||
)}
|
||||
{/* Just the thumbnail image */}
|
||||
{dragPreview ? (
|
||||
<img
|
||||
src={dragPreview.src}
|
||||
alt="Dragging"
|
||||
style={{
|
||||
width: '20rem',
|
||||
height: '20rem',
|
||||
objectFit: 'contain',
|
||||
transform: `rotate(${dragPreview.rotation}deg)`,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '20rem',
|
||||
height: '20rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '48px',
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
📄
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { usePageEditor } from "../../contexts/PageEditorContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
|
||||
@ -604,9 +604,14 @@ const PageEditor = ({
|
||||
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
console.log('=== HANDLE REORDER PAGES ===');
|
||||
console.log('selectedPageIds:', selectedPageIds);
|
||||
|
||||
// Convert selectedPageIds to page numbers for the reorder command
|
||||
const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined;
|
||||
|
||||
console.log('selectedPages (converted to numbers):', selectedPages);
|
||||
|
||||
const reorderCommand = new ReorderPagesCommand(
|
||||
sourcePageNumber,
|
||||
targetIndex,
|
||||
@ -961,8 +966,17 @@ const PageEditor = ({
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onReorderPages={handleReorderPages}
|
||||
renderItem={(page, index, refs) => {
|
||||
getThumbnailData={(pageId) => {
|
||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
||||
if (!page?.thumbnail) return null;
|
||||
return {
|
||||
src: page.thumbnail,
|
||||
rotation: page.rotation || 0
|
||||
};
|
||||
}}
|
||||
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dropSide, dragHandleProps) => {
|
||||
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
|
||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||
return (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
@ -975,7 +989,15 @@ const PageEditor = ({
|
||||
selectionMode={selectionMode}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
isBoxSelected={isBoxSelected}
|
||||
boxSelectedPageIds={boxSelectedIds}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
getBoxSelection={getBoxSelection}
|
||||
activeId={activeId}
|
||||
isOver={isOver}
|
||||
dropSide={dropSide}
|
||||
pageRefs={refs}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onReorderPages={handleReorderPages}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
|
||||
@ -7,7 +7,6 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
@ -25,7 +24,15 @@ interface PageThumbnailProps {
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
isBoxSelected?: boolean;
|
||||
boxSelectedPageIds?: string[];
|
||||
clearBoxSelection?: () => void;
|
||||
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;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
@ -52,7 +59,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
isBoxSelected = false,
|
||||
boxSelectedPageIds = [],
|
||||
clearBoxSelection,
|
||||
getBoxSelection,
|
||||
activeId,
|
||||
isOver,
|
||||
dropSide,
|
||||
pageRefs,
|
||||
dragHandleProps,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onExecuteCommand,
|
||||
@ -65,15 +80,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
}: PageThumbnailProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
// Check if this page is currently being dragged
|
||||
const isDragging = activeId === page.id;
|
||||
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
@ -130,63 +147,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
};
|
||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Merge refs - combine our ref tracking with dnd-kit's ref
|
||||
const mergedRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Track in our refs map
|
||||
elementRef.current = element;
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// Call dnd-kit's ref if provided
|
||||
if (dragHandleProps?.ref) {
|
||||
dragHandleProps.ref(element);
|
||||
}
|
||||
}, [page.id, pageRefs, dragHandleProps]);
|
||||
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
@ -262,19 +238,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||
if (distance < 2 && !isDragging) {
|
||||
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
||||
const now = Date.now();
|
||||
if (now - lastClickTimeRef.current > 100) {
|
||||
lastClickTimeRef.current = now;
|
||||
onTogglePage(page.id);
|
||||
|
||||
// Clear box selection when clicking on a non-selected page
|
||||
if (!isBoxSelected && clearBoxSelection) {
|
||||
clearBoxSelection();
|
||||
}
|
||||
|
||||
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
||||
if (!isBoxSelected) {
|
||||
onTogglePage(page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
@ -283,9 +268,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
|
||||
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||
|
||||
// Spread dragHandleProps but use our merged ref
|
||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
ref={mergedRef}
|
||||
{...restDragProps}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
@ -303,15 +292,34 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
relative
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
||||
`}
|
||||
style={{
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
...(isBoxSelected && {
|
||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
|
||||
}),
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user