UI improvements, merge
This commit is contained in:
@@ -1,103 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { SplitResult } from '../pdf/pdfTypes';
|
||||
|
||||
interface ActionsPanelProps {
|
||||
hasPdf: boolean;
|
||||
onSplit: () => void;
|
||||
onExtractRange: (from: number, to: number) => void;
|
||||
isBusy: boolean;
|
||||
|
||||
selectedCount: number;
|
||||
|
||||
onSplit: () => void;
|
||||
onExtractSelected: () => void;
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitResults: SplitResult[];
|
||||
rangeDownloadUrl: string | null;
|
||||
rangeFilename: string | null;
|
||||
subsetDownloadUrl: string | null;
|
||||
subsetFilename: string | null;
|
||||
exportDownloadUrl: string | null;
|
||||
exportFilename: string | null;
|
||||
}
|
||||
|
||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
hasPdf,
|
||||
onSplit,
|
||||
onExtractRange,
|
||||
isBusy,
|
||||
selectedCount,
|
||||
onSplit,
|
||||
onExtractSelected,
|
||||
onExportReordered,
|
||||
splitResults,
|
||||
rangeDownloadUrl,
|
||||
rangeFilename,
|
||||
subsetDownloadUrl,
|
||||
subsetFilename,
|
||||
exportDownloadUrl,
|
||||
exportFilename,
|
||||
}) => {
|
||||
const [fromPage, setFromPage] = useState<string>('');
|
||||
const [toPage, setToPage] = useState<string>('');
|
||||
const disabled = !hasPdf || isBusy;
|
||||
|
||||
const handleExtractClick = () => {
|
||||
const from = parseInt(fromPage, 10);
|
||||
const to = parseInt(toPage, 10);
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to)) return;
|
||||
onExtractRange(from, to);
|
||||
const handleExtractSelectedClick = () => {
|
||||
if (selectedCount === 0) return;
|
||||
onExtractSelected();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>3. Actions</h2>
|
||||
<p>Split into single pages or extract a continuous range.</p>
|
||||
<h2>Tools</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
Use these tools on the current in-memory document (reordered, rotated,
|
||||
with deletions). Nothing is uploaded to a server.
|
||||
</p>
|
||||
|
||||
<div className="button-row">
|
||||
<div
|
||||
className="button-row"
|
||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!hasPdf || isBusy}
|
||||
onClick={onSplit}
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onExportReordered}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
>
|
||||
{isBusy ? 'Splitting…' : 'Split into single pages'}
|
||||
🧾 Export new PDF
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled || selectedCount === 0}
|
||||
onClick={handleExtractSelectedClick}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? 'Select at least one page'
|
||||
: 'Create a PDF from selected pages'
|
||||
}
|
||||
>
|
||||
📤 Extract selected ({selectedCount})
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onSplit}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
>
|
||||
📂 Split into single PDFs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr style={{ margin: '0.75rem 0', borderColor: '#e5e7eb' }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div>
|
||||
<strong>Extract range:</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<label>
|
||||
From
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={fromPage}
|
||||
onChange={(e) => setFromPage(e.target.value)}
|
||||
style={{ marginLeft: '0.3rem', width: '4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={toPage}
|
||||
onChange={(e) => setToPage(e.target.value)}
|
||||
style={{ marginLeft: '0.3rem', width: '4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={!hasPdf || isBusy}
|
||||
onClick={handleExtractClick}
|
||||
{subsetDownloadUrl && subsetFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Subset result:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={subsetDownloadUrl}
|
||||
download={subsetFilename}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Extract'}
|
||||
</button>
|
||||
Download {subsetFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rangeDownloadUrl && rangeFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Range result:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={rangeDownloadUrl}
|
||||
download={rangeFilename}
|
||||
>
|
||||
Download {rangeFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{exportDownloadUrl && exportFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Exported document:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={exportDownloadUrl}
|
||||
download={exportFilename}
|
||||
>
|
||||
Download {exportFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitResults.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<strong>Split result:</strong>
|
||||
<strong>Single-page PDFs:</strong>
|
||||
<div>
|
||||
{splitResults.map((r) => {
|
||||
const url = URL.createObjectURL(r.blob);
|
||||
@@ -111,7 +125,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}}
|
||||
>
|
||||
Download {r.filename}
|
||||
{r.filename}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ToolId = 'split' | 'reorder' | 'merge' | 'annotate';
|
||||
|
||||
interface LayoutProps {
|
||||
activeTool: ToolId;
|
||||
onToolChange: (tool: ToolId) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ activeTool, onToolChange, children }) => {
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="app-sidebar">
|
||||
<div>
|
||||
<h1>PDF Workbench</h1>
|
||||
<small>Self-hosted, browser-based</small>
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
<div className="app-header-title">
|
||||
<span className="app-logo">📄</span>
|
||||
<div>
|
||||
<h1>PDF Workbench</h1>
|
||||
<small>All in your browser</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={activeTool === 'split' ? 'active' : ''}
|
||||
onClick={() => onToolChange('split')}
|
||||
>
|
||||
Split / Extract
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'reorder' ? 'active' : ''}
|
||||
onClick={() => onToolChange('reorder')}
|
||||
>
|
||||
Reorder / Delete / Rotate
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'merge' ? 'active' : ''}
|
||||
onClick={() => onToolChange('merge')}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
>
|
||||
Merge PDFs
|
||||
</button>
|
||||
<button
|
||||
className={activeTool === 'annotate' ? 'active' : ''}
|
||||
onClick={() => onToolChange('annotate')}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
>
|
||||
Annotations
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
</header>
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
181
src/components/PagePreviewModal.tsx
Normal file
181
src/components/PagePreviewModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
|
||||
// pdf.js worker setup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
interface PagePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
pdf: PdfFile | null;
|
||||
pageIndex: number | null; // original page index (0-based)
|
||||
rotation: number; // degrees
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
isOpen,
|
||||
pdf,
|
||||
pageIndex,
|
||||
rotation,
|
||||
onClose,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pdf || pageIndex == null) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// copy data for pdf.js (avoid detaching original ArrayBuffer)
|
||||
const src = new Uint8Array(pdf.arrayBuffer);
|
||||
const copy = new Uint8Array(src.byteLength);
|
||||
copy.set(src);
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({ data: copy });
|
||||
const doc = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
|
||||
const page = await doc.getPage(pageIndex + 1);
|
||||
if (cancelled) return;
|
||||
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const maxWidth = Math.min(window.innerWidth * 0.9, 800);
|
||||
const scale = maxWidth / viewport.width;
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// base size
|
||||
let canvasWidth = scaledViewport.width;
|
||||
let canvasHeight = scaledViewport.height;
|
||||
|
||||
const angle = ((rotation % 360) + 360) % 360;
|
||||
|
||||
if (angle === 90 || angle === 270) {
|
||||
canvasWidth = scaledViewport.height;
|
||||
canvasHeight = scaledViewport.width;
|
||||
}
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
// render into an offscreen canvas first
|
||||
const baseCanvas = document.createElement('canvas');
|
||||
const baseCtx = baseCanvas.getContext('2d');
|
||||
if (!baseCtx) return;
|
||||
|
||||
baseCanvas.width = scaledViewport.width;
|
||||
baseCanvas.height = scaledViewport.height;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvasContext: baseCtx,
|
||||
viewport: scaledViewport,
|
||||
});
|
||||
await renderTask.promise;
|
||||
if (cancelled) return;
|
||||
|
||||
// draw rotated onto visible canvas
|
||||
ctx.save();
|
||||
|
||||
switch (angle) {
|
||||
case 90:
|
||||
ctx.translate(canvasWidth, 0);
|
||||
ctx.rotate((angle * Math.PI) / 180);
|
||||
break;
|
||||
case 180:
|
||||
ctx.translate(canvasWidth, canvasHeight);
|
||||
ctx.rotate((angle * Math.PI) / 180);
|
||||
break;
|
||||
case 270:
|
||||
ctx.translate(0, canvasHeight);
|
||||
ctx.rotate((angle * Math.PI) / 180);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.drawImage(baseCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
} catch (e) {
|
||||
console.error('Error rendering preview', e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, pdf, pageIndex, rotation]);
|
||||
|
||||
if (!isOpen || !pdf || pageIndex == null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#111827',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ alignSelf: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.25rem 0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#374151',
|
||||
color: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '75vh',
|
||||
background: 'white',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
|
||||
Page {pageIndex + 1} · Rot {rotation}°
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PagePreviewModal;
|
||||
@@ -1,56 +1,113 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
interface ReorderPanelProps {
|
||||
pageCount: number;
|
||||
thumbnails: string[] | null;
|
||||
order: number[]; // current page order (page indices)
|
||||
thumbnails: string[] | null; // thumbnails by original page index
|
||||
isBusy: boolean;
|
||||
hasPdf: boolean;
|
||||
rotations: Record<number, number>;
|
||||
onRotate: (pageIndex: number) => void;
|
||||
onExportReordered: (order: number[]) => void;
|
||||
reorderDownloadUrl: string | null;
|
||||
reorderFilename: string | null;
|
||||
selectedPages: number[]; // selected original page indices
|
||||
|
||||
onRotateClockwise: (pageIndex: number) => void;
|
||||
onRotateCounterclockwise: (pageIndex: number) => void;
|
||||
onDelete: (pageIndex: number) => void;
|
||||
onReorder: (newOrder: number[]) => void;
|
||||
|
||||
onToggleSelect: (
|
||||
pageIndex: number,
|
||||
visualIndex: number,
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onSelectAll: () => void;
|
||||
|
||||
onOpenPreview: (pageIndex: number) => void;
|
||||
onClearSelection: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
pageCount,
|
||||
order,
|
||||
thumbnails,
|
||||
isBusy,
|
||||
hasPdf,
|
||||
rotations,
|
||||
onRotate,
|
||||
onExportReordered,
|
||||
reorderDownloadUrl,
|
||||
reorderFilename,
|
||||
selectedPages,
|
||||
onRotateClockwise,
|
||||
onRotateCounterclockwise,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onOpenPreview,
|
||||
onClearSelection,
|
||||
onDeleteSelected,
|
||||
}) => {
|
||||
const [order, setOrder] = useState<number[]>([]);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length
|
||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageCount > 0) {
|
||||
setOrder(Array.from({ length: pageCount }, (_, i) => i));
|
||||
} else {
|
||||
setOrder([]);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
const isSelected = (pageIndex: number) => selectedPages.includes(pageIndex);
|
||||
|
||||
const cleanupDragGhost = () => {
|
||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||
}
|
||||
}, [pageCount]);
|
||||
dragGhostRef.current = null;
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(index);
|
||||
setDropIndex(index); // initial assumption: before itself
|
||||
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.boxShadow = '0 4px 8px rgba(15, 23, 42, 0.4)';
|
||||
ghost.style.zIndex = '9999';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
dragGhostRef.current = ghost;
|
||||
|
||||
// center drag image under cursor
|
||||
const rect = ghost.getBoundingClientRect();
|
||||
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
||||
};
|
||||
|
||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(visualIndex);
|
||||
setDropIndex(visualIndex); // initial: before itself
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox needs some data
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
e.dataTransfer.setData('text/plain', String(visualIndex)); // Firefox
|
||||
|
||||
const draggedPageIndex = order[visualIndex];
|
||||
const selectedInVisualOrder = order.filter((p) =>
|
||||
selectedPages.includes(p)
|
||||
);
|
||||
const draggingIsSelected =
|
||||
selectedInVisualOrder.length > 0 &&
|
||||
selectedInVisualOrder.includes(draggedPageIndex);
|
||||
|
||||
const movingPages = draggingIsSelected
|
||||
? selectedInVisualOrder
|
||||
: [draggedPageIndex];
|
||||
|
||||
createDragGhost(e, movingPages.length);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleCardDragOver = (cardIndex: number) => (e: React.DragEvent) => {
|
||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
if (draggingIndex == null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
@@ -58,9 +115,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
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;
|
||||
const slot = x < rect.width / 2 ? visualIndex : visualIndex + 1;
|
||||
setDropIndex(slot);
|
||||
};
|
||||
|
||||
@@ -68,67 +123,195 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
if (draggingIndex == null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDropIndex(order.length); // slot at the very end
|
||||
setDropIndex(order.length);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
cleanupDragGhost();
|
||||
|
||||
if (draggingIndex == null || dropIndex == null) return;
|
||||
|
||||
setOrder((prev) => {
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(draggingIndex, 1);
|
||||
const draggedPageIndex = order[draggingIndex];
|
||||
|
||||
const adjustedSlot = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
|
||||
updated.splice(adjustedSlot, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
// Selected pages in current visual order
|
||||
const selectedInVisualOrder = order.filter((p) =>
|
||||
selectedPages.includes(p)
|
||||
);
|
||||
|
||||
const draggingIsSelected =
|
||||
selectedInVisualOrder.length > 0 &&
|
||||
selectedInVisualOrder.includes(draggedPageIndex);
|
||||
|
||||
// Pages that will move:
|
||||
// - if dragging selected -> move full selection (in visual order)
|
||||
// - else -> only the dragged page
|
||||
const movingPages = draggingIsSelected
|
||||
? selectedInVisualOrder
|
||||
: [draggedPageIndex];
|
||||
|
||||
// Map from page index to visual position
|
||||
const indexMap = new Map<number, number>();
|
||||
order.forEach((p, idx) => indexMap.set(p, idx));
|
||||
|
||||
// how many of the moving pages were before the drop slot?
|
||||
const countBefore = movingPages.reduce((count, p) => {
|
||||
const idx = indexMap.get(p);
|
||||
if (idx != null && idx < dropIndex) return count + 1;
|
||||
return count;
|
||||
}, 0);
|
||||
|
||||
const adjustedSlot = dropIndex - countBefore;
|
||||
|
||||
// Remove moving pages from current order
|
||||
const movingSet = new Set(movingPages);
|
||||
const remaining = order.filter((p) => !movingSet.has(p));
|
||||
|
||||
// Insert in same relative order at new slot
|
||||
const newOrder = [
|
||||
...remaining.slice(0, adjustedSlot),
|
||||
...movingPages,
|
||||
...remaining.slice(adjustedSlot),
|
||||
];
|
||||
|
||||
onReorder(newOrder);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleDelete = (visualIndex: number) => () => {
|
||||
setOrder((prev) => prev.filter((_, idx) => idx !== visualIndex));
|
||||
const handleDeleteClick = (pageIndex: number) => () => {
|
||||
onDelete(pageIndex);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleRotateClick = (pageIndex: number) => () => {
|
||||
onRotate(pageIndex);
|
||||
const handleRotateClickClockwise = (pageIndex: number) => () => {
|
||||
onRotateClockwise(pageIndex);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!hasPdf || order.length === 0) return;
|
||||
onExportReordered(order);
|
||||
const handleRotateClickCounterclockwise = (pageIndex: number) => () => {
|
||||
onRotateCounterclockwise(pageIndex);
|
||||
};
|
||||
|
||||
const handleCardClick = (pageIndex: number) => () => {
|
||||
onOpenPreview(pageIndex);
|
||||
};
|
||||
|
||||
const handleCheckboxClick =
|
||||
(pageIndex: number, visualIndex: number) =>
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // don't trigger preview
|
||||
onToggleSelect(pageIndex, visualIndex, e);
|
||||
};
|
||||
|
||||
if (!hasPdf) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Reorder pages</h2>
|
||||
<p>Load a PDF first to reorder, delete, or rotate its pages.</p>
|
||||
<h2>Pages</h2>
|
||||
<p>Open a PDF file to reorder, rotate, or delete its pages.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showLeftLine = (cardIndex: number) =>
|
||||
dropIndex !== null && dropIndex === cardIndex && draggingIndex !== null;
|
||||
const showLeftLine = (visualIndex: number) =>
|
||||
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||
|
||||
const showRightLine = (cardIndex: number) =>
|
||||
dropIndex !== null && dropIndex === cardIndex + 1 && draggingIndex !== null;
|
||||
const showRightLine = (visualIndex: number) =>
|
||||
dropIndex !== null &&
|
||||
dropIndex === visualIndex + 1 &&
|
||||
draggingIndex !== null;
|
||||
|
||||
const showEndLine = () =>
|
||||
dropIndex !== null && dropIndex === order.length && draggingIndex !== null;
|
||||
|
||||
// For highlighting the whole selection while dragging it
|
||||
const draggingPageIndex =
|
||||
draggingIndex != null ? order[draggingIndex] : null;
|
||||
const draggingSelectionActive =
|
||||
draggingPageIndex != null &&
|
||||
selectedPages.length > 0 &&
|
||||
selectedPages.includes(draggingPageIndex);
|
||||
|
||||
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.
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Selected: <strong>{selectedPages.length}</strong>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{selectedPages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#fee2e2',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#8dcd8d',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={selectedPages.length === 0}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background:
|
||||
selectedPages.length === 0 ? '#e5e7eb' : '#e5e7eb',
|
||||
color:
|
||||
selectedPages.length === 0 ? '#6b7280' : '#111827',
|
||||
cursor: selectedPages.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -141,8 +324,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
>
|
||||
{order.map((pageIndex, visualIndex) => {
|
||||
const thumb = thumbnails?.[pageIndex];
|
||||
const isDragging = visualIndex === draggingIndex;
|
||||
const rotation = rotations[pageIndex] ?? 0;
|
||||
const selected = isSelected(pageIndex);
|
||||
|
||||
const isDraggingCard =
|
||||
draggingIndex != null &&
|
||||
((draggingSelectionActive && selected) ||
|
||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,20 +339,52 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
onDragStart={handleDragStart(visualIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleCardDragOver(visualIndex)}
|
||||
onClick={handleCardClick(pageIndex)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '130px',
|
||||
width: '162px',
|
||||
padding: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: isDragging ? '2px solid #2563eb' : '1px solid #e5e7eb',
|
||||
background: isDragging ? '#dbeafe' : '#f9fafb',
|
||||
border: '1px solid #e5e7eb', // constant → no jump
|
||||
background: isDraggingCard
|
||||
? '#dbeafe'
|
||||
: selected
|
||||
? '#eff6ff'
|
||||
: '#f9fafb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
cursor: 'grab',
|
||||
cursor: isBusy ? 'default' : 'grab',
|
||||
opacity: isBusy ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{/* selection checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckboxClick(pageIndex, visualIndex)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: '4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '0.4rem',
|
||||
border: '1px solid #9ca3af',
|
||||
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
|
||||
color: selected ? 'white' : 'transparent',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Select page"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
{/* left drop indicator */}
|
||||
{showLeftLine(visualIndex) && (
|
||||
<div
|
||||
@@ -232,7 +452,10 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRotateClick(pageIndex)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickClockwise(pageIndex)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
@@ -246,7 +469,27 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete(visualIndex)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickCounterclockwise(pageIndex)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(pageIndex)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
@@ -265,55 +508,34 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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"
|
||||
{/* end slot for dropping after the last card */}
|
||||
{order.length > 0 && (
|
||||
<div
|
||||
onDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '120px',
|
||||
position: 'relative',
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
>
|
||||
Download {reorderFilename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{showEndLine() && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: '#2563eb',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user