322 lines
9.4 KiB
TypeScript
322 lines
9.4 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
|
|
interface ReorderPanelProps {
|
|
pageCount: number;
|
|
thumbnails: string[] | null;
|
|
isBusy: boolean;
|
|
hasPdf: boolean;
|
|
rotations: Record<number, number>;
|
|
onRotate: (pageIndex: number) => void;
|
|
onExportReordered: (order: number[]) => void;
|
|
reorderDownloadUrl: string | null;
|
|
reorderFilename: string | null;
|
|
}
|
|
|
|
const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|
pageCount,
|
|
thumbnails,
|
|
isBusy,
|
|
hasPdf,
|
|
rotations,
|
|
onRotate,
|
|
onExportReordered,
|
|
reorderDownloadUrl,
|
|
reorderFilename,
|
|
}) => {
|
|
const [order, setOrder] = useState<number[]>([]);
|
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
|
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length
|
|
|
|
useEffect(() => {
|
|
if (pageCount > 0) {
|
|
setOrder(Array.from({ length: pageCount }, (_, i) => i));
|
|
} else {
|
|
setOrder([]);
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
}
|
|
}, [pageCount]);
|
|
|
|
const handleDragStart = (index: number) => (e: React.DragEvent) => {
|
|
setDraggingIndex(index);
|
|
setDropIndex(index); // initial assumption: before itself
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
// Firefox needs some data
|
|
e.dataTransfer.setData('text/plain', String(index));
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
};
|
|
|
|
const handleCardDragOver = (cardIndex: number) => (e: React.DragEvent) => {
|
|
if (draggingIndex == null) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
|
|
// left half => slot BEFORE this card
|
|
// right half => slot AFTER this card
|
|
const slot = x < rect.width / 2 ? cardIndex : cardIndex + 1;
|
|
setDropIndex(slot);
|
|
};
|
|
|
|
const handleEndSlotDragOver = (e: React.DragEvent) => {
|
|
if (draggingIndex == null) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
setDropIndex(order.length); // slot at the very end
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
if (draggingIndex == null || dropIndex == null) return;
|
|
|
|
setOrder((prev) => {
|
|
const updated = [...prev];
|
|
const [moved] = updated.splice(draggingIndex, 1);
|
|
|
|
const adjustedSlot = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
|
|
updated.splice(adjustedSlot, 0, moved);
|
|
return updated;
|
|
});
|
|
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
};
|
|
|
|
const handleDelete = (visualIndex: number) => () => {
|
|
setOrder((prev) => prev.filter((_, idx) => idx !== visualIndex));
|
|
setDraggingIndex(null);
|
|
setDropIndex(null);
|
|
};
|
|
|
|
const handleRotateClick = (pageIndex: number) => () => {
|
|
onRotate(pageIndex);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (!hasPdf || order.length === 0) return;
|
|
onExportReordered(order);
|
|
};
|
|
|
|
if (!hasPdf) {
|
|
return (
|
|
<div className="card">
|
|
<h2>Reorder pages</h2>
|
|
<p>Load a PDF first to reorder, delete, or rotate its pages.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const showLeftLine = (cardIndex: number) =>
|
|
dropIndex !== null && dropIndex === cardIndex && draggingIndex !== null;
|
|
|
|
const showRightLine = (cardIndex: number) =>
|
|
dropIndex !== null && dropIndex === cardIndex + 1 && draggingIndex !== null;
|
|
|
|
const showEndLine = () =>
|
|
dropIndex !== null && dropIndex === order.length && draggingIndex !== null;
|
|
|
|
return (
|
|
<div className="card">
|
|
<h2>Reorder / delete / rotate</h2>
|
|
<p>
|
|
Drag pages to reorder them. A vertical blue line shows where the page
|
|
will be inserted. Use rotate and delete controls below each thumbnail.
|
|
</p>
|
|
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '0.5rem',
|
|
alignItems: 'flex-start',
|
|
marginBottom: '0.75rem',
|
|
}}
|
|
onDrop={handleDrop}
|
|
>
|
|
{order.map((pageIndex, visualIndex) => {
|
|
const thumb = thumbnails?.[pageIndex];
|
|
const isDragging = visualIndex === draggingIndex;
|
|
const rotation = rotations[pageIndex] ?? 0;
|
|
|
|
return (
|
|
<div
|
|
key={`${pageIndex}-${visualIndex}`}
|
|
draggable
|
|
onDragStart={handleDragStart(visualIndex)}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={handleCardDragOver(visualIndex)}
|
|
style={{
|
|
position: 'relative',
|
|
width: '130px',
|
|
padding: '0.4rem',
|
|
borderRadius: '0.5rem',
|
|
border: isDragging ? '2px solid #2563eb' : '1px solid #e5e7eb',
|
|
background: isDragging ? '#dbeafe' : '#f9fafb',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '0.25rem',
|
|
cursor: 'grab',
|
|
}}
|
|
>
|
|
{/* left drop indicator */}
|
|
{showLeftLine(visualIndex) && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: '-4px',
|
|
top: '4px',
|
|
bottom: '4px',
|
|
width: '3px',
|
|
borderRadius: '999px',
|
|
background: '#2563eb',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* right drop indicator */}
|
|
{showRightLine(visualIndex) && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
right: '-4px',
|
|
top: '4px',
|
|
bottom: '4px',
|
|
width: '3px',
|
|
borderRadius: '999px',
|
|
background: '#2563eb',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{thumb ? (
|
|
<img
|
|
src={thumb}
|
|
alt={`Page ${pageIndex + 1}`}
|
|
style={{
|
|
maxHeight: '90px',
|
|
width: 'auto',
|
|
borderRadius: '0.25rem',
|
|
border: '1px solid #e5e7eb',
|
|
background: 'white',
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: '60px',
|
|
height: '80px',
|
|
borderRadius: '0.25rem',
|
|
border: '1px dashed #d1d5db',
|
|
background: '#f3f4f6',
|
|
}}
|
|
/>
|
|
)}
|
|
<span style={{ fontSize: '0.8rem' }}>Page {pageIndex + 1}</span>
|
|
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
|
Pos {visualIndex + 1} · Rot {rotation}°
|
|
</span>
|
|
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
gap: '0.25rem',
|
|
marginTop: '0.25rem',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleRotateClick(pageIndex)}
|
|
style={{
|
|
border: 'none',
|
|
borderRadius: '999px',
|
|
padding: '0.15rem 0.4rem',
|
|
fontSize: '0.75rem',
|
|
background: '#e5e7eb',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
↻ 90°
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete(visualIndex)}
|
|
style={{
|
|
border: 'none',
|
|
borderRadius: '999px',
|
|
padding: '0.15rem 0.4rem',
|
|
fontSize: '0.75rem',
|
|
background: '#fecaca',
|
|
color: '#b91c1c',
|
|
cursor: 'pointer',
|
|
}}
|
|
title="Remove this page from the exported PDF"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* invisible end slot to allow dropping after the last card */}
|
|
<div
|
|
onDragOver={handleEndSlotDragOver}
|
|
onDrop={handleDrop}
|
|
style={{
|
|
width: '20px',
|
|
height: '120px',
|
|
position: 'relative',
|
|
alignSelf: 'stretch',
|
|
}}
|
|
>
|
|
{showEndLine() && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: '8px',
|
|
top: '4px',
|
|
bottom: '4px',
|
|
width: '3px',
|
|
borderRadius: '999px',
|
|
background: '#2563eb',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="button-row">
|
|
<button
|
|
className="primary"
|
|
disabled={!hasPdf || isBusy || order.length === 0}
|
|
onClick={handleExport}
|
|
>
|
|
{isBusy ? 'Exporting…' : 'Export reordered PDF'}
|
|
</button>
|
|
</div>
|
|
|
|
{reorderDownloadUrl && reorderFilename && (
|
|
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
|
<strong>Reordered result:</strong>{' '}
|
|
<a
|
|
href={reorderDownloadUrl}
|
|
download={reorderFilename}
|
|
className="download-link"
|
|
>
|
|
Download {reorderFilename}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ReorderPanel;
|