343 lines
9.8 KiB
TypeScript
343 lines
9.8 KiB
TypeScript
import React, { useRef, useState } from 'react';
|
|
import type { PageRef } from '../pdf/pdfTypes';
|
|
import CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
|
|
import PageGrid from './PageWorkspace/PageGrid';
|
|
import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
|
|
|
|
interface ReorderPanelProps {
|
|
pages: PageRef[];
|
|
thumbnails: Record<string, string>;
|
|
isBusy: boolean;
|
|
hasPdf: boolean;
|
|
selectedPageIds: string[];
|
|
|
|
onRotateClockwise: (pageId: string) => void;
|
|
onRotateCounterclockwise: (pageId: string) => void;
|
|
onDelete: (pageId: string) => void;
|
|
onReorder: (newPages: PageRef[]) => void;
|
|
onCopyPagesToSlot: (pageIds: string[], insertSlot: number) => void;
|
|
|
|
onToggleSelect: (
|
|
pageId: string,
|
|
visualIndex: number,
|
|
e: React.MouseEvent<HTMLButtonElement>
|
|
) => void;
|
|
onSelectAll: () => void;
|
|
|
|
onOpenPreview: (pageId: string) => void;
|
|
onClearSelection: () => void;
|
|
onDeleteSelected: () => void;
|
|
}
|
|
|
|
const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|
pages,
|
|
thumbnails,
|
|
isBusy,
|
|
hasPdf,
|
|
selectedPageIds,
|
|
onRotateClockwise,
|
|
onRotateCounterclockwise,
|
|
onDelete,
|
|
onReorder,
|
|
onCopyPagesToSlot,
|
|
onToggleSelect,
|
|
onSelectAll,
|
|
onOpenPreview,
|
|
onClearSelection,
|
|
onDeleteSelected,
|
|
}) => {
|
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
|
|
|
const [isCopyDragging, setIsCopyDragging] = useState(false);
|
|
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
|
const [copyTargetPosition, setCopyTargetPosition] = useState('');
|
|
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
|
|
|
|
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const cleanupDragGhost = () => {
|
|
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
|
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
|
}
|
|
dragGhostRef.current = null;
|
|
};
|
|
|
|
const isCopyModifierPressed = (e: React.DragEvent) => {
|
|
return e.ctrlKey || e.metaKey;
|
|
};
|
|
|
|
const getDraggedPages = (visualIndex: number): PageRef[] => {
|
|
const draggedPage = pages[visualIndex];
|
|
if (!draggedPage) return [];
|
|
|
|
const selectedInVisualOrder = pages.filter((page) =>
|
|
selectedPageIds.includes(page.id)
|
|
);
|
|
|
|
const draggingIsSelected =
|
|
selectedInVisualOrder.length > 0 &&
|
|
selectedInVisualOrder.some((page) => page.id === draggedPage.id);
|
|
|
|
return draggingIsSelected ? selectedInVisualOrder : [draggedPage];
|
|
};
|
|
|
|
const createDragGhost = (e: React.DragEvent, count: number) => {
|
|
cleanupDragGhost();
|
|
|
|
const ghost = document.createElement('div');
|
|
ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
|
|
|
|
ghost.style.position = 'fixed';
|
|
ghost.style.top = '0';
|
|
ghost.style.left = '0';
|
|
ghost.style.padding = '4px 8px';
|
|
ghost.style.borderRadius = '999px';
|
|
ghost.style.background = '#111827';
|
|
ghost.style.color = '#e5e7eb';
|
|
ghost.style.fontSize = '12px';
|
|
ghost.style.fontFamily =
|
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
ghost.style.zIndex = '9999';
|
|
|
|
document.body.appendChild(ghost);
|
|
dragGhostRef.current = ghost;
|
|
|
|
const rect = ghost.getBoundingClientRect();
|
|
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
|
};
|
|
|
|
const resetDragState = () => {
|
|
cleanupDragGhost();
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
setIsCopyDragging(false);
|
|
};
|
|
|
|
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
|
setDraggingIndex(visualIndex);
|
|
setDropIndex(visualIndex);
|
|
|
|
const copying = isCopyModifierPressed(e);
|
|
setIsCopyDragging(copying);
|
|
|
|
e.dataTransfer.effectAllowed = 'copyMove';
|
|
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
|
e.dataTransfer.setData('text/plain', String(visualIndex));
|
|
|
|
const draggedPages = getDraggedPages(visualIndex);
|
|
createDragGhost(e, draggedPages.length);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
resetDragState();
|
|
};
|
|
|
|
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
|
if (draggingIndex == null) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const copying = isCopyModifierPressed(e);
|
|
setIsCopyDragging(copying);
|
|
|
|
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
|
|
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
|
|
const slot = x < rect.width / 2 ? visualIndex : visualIndex + 1;
|
|
setDropIndex(slot);
|
|
};
|
|
|
|
const handleEndSlotDragOver = (e: React.DragEvent) => {
|
|
if (draggingIndex == null) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const copying = isCopyModifierPressed(e);
|
|
setIsCopyDragging(copying);
|
|
|
|
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
|
|
|
setDropIndex(pages.length);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
cleanupDragGhost();
|
|
|
|
if (draggingIndex == null || dropIndex == null) return;
|
|
|
|
const draggedPages = getDraggedPages(draggingIndex);
|
|
if (draggedPages.length === 0) return;
|
|
|
|
const shouldCopy = isCopyModifierPressed(e) || isCopyDragging;
|
|
|
|
if (shouldCopy) {
|
|
onCopyPagesToSlot(
|
|
draggedPages.map((page) => page.id),
|
|
dropIndex
|
|
);
|
|
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
setIsCopyDragging(false);
|
|
return;
|
|
}
|
|
|
|
const indexMap = new Map<string, number>();
|
|
pages.forEach((page, idx) => indexMap.set(page.id, idx));
|
|
|
|
const countBefore = draggedPages.reduce((count, page) => {
|
|
const idx = indexMap.get(page.id);
|
|
if (idx != null && idx < dropIndex) return count + 1;
|
|
return count;
|
|
}, 0);
|
|
|
|
const adjustedSlot = dropIndex - countBefore;
|
|
const movingSet = new Set(draggedPages.map((page) => page.id));
|
|
const remaining = pages.filter((page) => !movingSet.has(page.id));
|
|
|
|
const newPages = [
|
|
...remaining.slice(0, adjustedSlot),
|
|
...draggedPages,
|
|
...remaining.slice(adjustedSlot),
|
|
];
|
|
|
|
onReorder(newPages);
|
|
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
setIsCopyDragging(false);
|
|
};
|
|
|
|
const handleDeleteClick = (pageId: string) => {
|
|
onDelete(pageId);
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
};
|
|
|
|
const handleCheckboxClick =
|
|
(pageId: string, visualIndex: number) =>
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
onToggleSelect(pageId, visualIndex, e);
|
|
};
|
|
|
|
const handleCopySelectedClick = () => {
|
|
if (selectedPageIds.length === 0) return;
|
|
|
|
setCopyTargetPosition(String(pages.length + 1));
|
|
setCopyDialogError(null);
|
|
setCopyDialogOpen(true);
|
|
};
|
|
|
|
const handleCopyDialogCancel = () => {
|
|
setCopyDialogOpen(false);
|
|
setCopyDialogError(null);
|
|
};
|
|
|
|
const handleCopyTargetPositionChange = (value: string) => {
|
|
setCopyTargetPosition(value);
|
|
setCopyDialogError(null);
|
|
};
|
|
|
|
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
|
|
if (selectedPageIds.length === 0) {
|
|
setCopyDialogError('No pages selected.');
|
|
return;
|
|
}
|
|
|
|
const maxPosition = pages.length + 1;
|
|
const parsed = Number.parseInt(copyTargetPosition.trim(), 10);
|
|
|
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > maxPosition) {
|
|
setCopyDialogError(`Please enter a number between 1 and ${maxPosition}.`);
|
|
return;
|
|
}
|
|
|
|
onCopyPagesToSlot(selectedPageIds, parsed - 1);
|
|
|
|
setCopyDialogOpen(false);
|
|
setCopyDialogError(null);
|
|
};
|
|
|
|
if (!hasPdf) {
|
|
return (
|
|
<div className="card">
|
|
<h2>Pages</h2>
|
|
<p>Open a PDF file to reorder, rotate, or delete its pages.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
|
const draggingSelectionActive =
|
|
draggingPage != null &&
|
|
selectedPageIds.length > 0 &&
|
|
selectedPageIds.includes(draggingPage.id);
|
|
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
|
|
|
return (
|
|
<>
|
|
<div className="card">
|
|
<h2>Pages</h2>
|
|
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
|
Tap/click a page to preview it. Use the checkbox to select pages
|
|
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
|
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
|
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
|
Esc clears selection.
|
|
</p>
|
|
|
|
<PageSelectionToolbar
|
|
selectedCount={selectedPageIds.length}
|
|
onCopySelected={handleCopySelectedClick}
|
|
onDeleteSelected={onDeleteSelected}
|
|
onSelectAll={onSelectAll}
|
|
onClearSelection={onClearSelection}
|
|
/>
|
|
|
|
<PageGrid
|
|
pages={pages}
|
|
thumbnails={thumbnails}
|
|
selectedPageIds={selectedPageIds}
|
|
isBusy={isBusy}
|
|
draggingIndex={draggingIndex}
|
|
dropIndex={dropIndex}
|
|
draggingSelectionActive={draggingSelectionActive}
|
|
isCopyDragging={isCopyDragging}
|
|
dropIndicatorColor={dropIndicatorColor}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onCardDragOver={handleCardDragOver}
|
|
onEndSlotDragOver={handleEndSlotDragOver}
|
|
onDrop={handleDrop}
|
|
onOpenPreview={onOpenPreview}
|
|
onToggleSelect={handleCheckboxClick}
|
|
onRotateClockwise={onRotateClockwise}
|
|
onRotateCounterclockwise={onRotateCounterclockwise}
|
|
onDelete={handleDeleteClick}
|
|
/>
|
|
</div>
|
|
|
|
{copyDialogOpen && (
|
|
<CopyPagesDialog
|
|
selectedCount={selectedPageIds.length}
|
|
pageCount={pages.length}
|
|
targetPosition={copyTargetPosition}
|
|
error={copyDialogError}
|
|
onTargetPositionChange={handleCopyTargetPositionChange}
|
|
onCancel={handleCopyDialogCancel}
|
|
onConfirm={handleCopyDialogConfirm}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ReorderPanel;
|