mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Drag and drop improvements basic box select
This commit is contained in:
parent
ddefe81082
commit
eef5dce849
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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%',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user