UI improvements, merge

This commit is contained in:
2025-11-27 10:52:44 +01:00
parent abfe6c347a
commit 9f660af924
7 changed files with 1089 additions and 310 deletions

View File

@@ -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>
);
};