From 9f660af924e594b532e8707de219df69f00de9d5 Mon Sep 17 00:00:00 2001 From: zemion Date: Thu, 27 Nov 2025 10:52:44 +0100 Subject: [PATCH] UI improvements, merge --- src/App.tsx | 460 +++++++++++++++++++++++----- src/components/ActionsPanel.tsx | 156 +++++----- src/components/Layout.tsx | 53 +--- src/components/PagePreviewModal.tsx | 181 +++++++++++ src/components/ReorderPanel.tsx | 442 +++++++++++++++++++------- src/pdf/pdfService.ts | 55 ++++ src/styles.css | 52 +++- 7 files changed, 1089 insertions(+), 310 deletions(-) create mode 100644 src/components/PagePreviewModal.tsx diff --git a/src/App.tsx b/src/App.tsx index ce4142b..bc1bfd2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,14 @@ import React, { useEffect, useState } from 'react'; -import Layout, { type ToolId } from './components/Layout'; +import Layout from './components/Layout'; import FileLoader from './components/FileLoader'; -import PageList from './components/PageList'; -import ActionsPanel from './components/ActionsPanel'; import ReorderPanel from './components/ReorderPanel'; +import ActionsPanel from './components/ActionsPanel'; +import PagePreviewModal from './components/PagePreviewModal'; import type { PdfFile, SplitResult } from './pdf/pdfTypes'; import { loadPdfFromFile, + mergePdfFiles, splitIntoSinglePages, - extractRange, exportReordered, } from './pdf/pdfService'; import { @@ -17,11 +17,8 @@ import { } from './pdf/pdfThumbnailService'; const App: React.FC = () => { - const [activeTool, setActiveTool] = useState('split'); const [pdf, setPdf] = useState(null); - const [selectedPages, setSelectedPages] = useState([]); const [isBusy, setIsBusy] = useState(false); - const [splitResults, setSplitResults] = useState([]); const [error, setError] = useState(null); const [baseThumbnails, setBaseThumbnails] = useState(null); @@ -29,29 +26,52 @@ const App: React.FC = () => { null ); - const [rangeUrl, setRangeUrl] = useState(null); - const [rangeFilename, setRangeFilename] = useState(null); - - const [reorderUrl, setReorderUrl] = useState(null); - const [reorderFilename, setReorderFilename] = useState(null); - + const [order, setOrder] = useState([]); const [rotations, setRotations] = useState>({}); - const handleFileLoaded = async (file: File) => { + const [selectedPages, setSelectedPages] = useState([]); + const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState< + number | null + >(null); + + const [splitResults, setSplitResults] = useState([]); + const [subsetUrl, setSubsetUrl] = useState(null); + const [subsetFilename, setSubsetFilename] = useState(null); + const [exportUrl, setExportUrl] = useState(null); + const [exportFilename, setExportFilename] = useState(null); + + const [previewIndex, setPreviewIndex] = useState(null); + + const [pendingFile, setPendingFile] = useState(null); + const [showMergeOptions, setShowMergeOptions] = useState(false); + const [mergeMode, setMergeMode] = useState<'overwrite' | 'append' | 'insertAt'>('append'); + const [mergeInsertAt, setMergeInsertAt] = useState(''); + + const loadFileAsNew = async (file: File) => { setError(null); setSplitResults([]); setSelectedPages([]); - setRangeUrl(null); - setRangeFilename(null); - setReorderUrl(null); - setReorderFilename(null); + setLastSelectedVisualIndex(null); + setSubsetUrl(null); + setSubsetFilename(null); + setExportUrl(null); + setExportFilename(null); setBaseThumbnails(null); setReorderThumbnails(null); setRotations({}); + setOrder([]); + setPreviewIndex(null); + setIsBusy(true); try { const loaded = await loadPdfFromFile(file); setPdf(loaded); + + const initialOrder = Array.from( + { length: loaded.pageCount }, + (_, i) => i + ); + setOrder(initialOrder); } catch (e) { console.error(e); setError('Failed to load PDF (see console).'); @@ -60,6 +80,101 @@ const App: React.FC = () => { } }; + const handleFileLoaded = (file: File) => { + // If no PDF loaded yet, just open it as before + if (!pdf || order.length === 0) { + void loadFileAsNew(file); + } else { + // Otherwise, ask whether to merge or replace + setPendingFile(file); + setShowMergeOptions(true); + setMergeMode('append'); + setMergeInsertAt(String(order.length + 1)); + } + }; + + const handleMergeCancel = () => { + setPendingFile(null); + setShowMergeOptions(false); + }; + + const handleMergeConfirm = async () => { + if (!pendingFile) return; + + // If there's no current PDF or the user chose overwrite, just load normally + if (!pdf || mergeMode === 'overwrite') { + await loadFileAsNew(pendingFile); + setPendingFile(null); + setShowMergeOptions(false); + return; + } + + setError(null); + setIsBusy(true); + + try { + // 1) Materialize the current in-memory state (order + rotations) + const currentBlob = await exportReordered(pdf, order, rotations); + const currentArrayBuffer = await currentBlob.arrayBuffer(); + const currentPdf: PdfFile = { + name: pdf.name, + arrayBuffer: currentArrayBuffer, + pageCount: order.length, + }; + + // 2) Load the new PDF + const newPdf = await loadPdfFromFile(pendingFile); + + // 3) Determine insert position (0-based) + let insertAt = order.length; // default: append at end + if (mergeMode === 'insertAt') { + const parsed = parseInt(mergeInsertAt, 10); + if (Number.isFinite(parsed)) { + insertAt = Math.min(Math.max(parsed - 1, 0), order.length); + } + } else if (mergeMode === 'append') { + insertAt = order.length; + } + + // 4) Merge + const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt); + + // 5) Reset state to the merged document + setPdf(mergedPdf); + const mergedOrder = Array.from( + { length: mergedPdf.pageCount }, + (_, i) => i + ); + setOrder(mergedOrder); + setRotations({}); + setSelectedPages([]); + setLastSelectedVisualIndex(null); + setSplitResults([]); + + if (subsetUrl) { + URL.revokeObjectURL(subsetUrl); + setSubsetUrl(null); + setSubsetFilename(null); + } + if (exportUrl) { + URL.revokeObjectURL(exportUrl); + setExportUrl(null); + setExportFilename(null); + } + + setBaseThumbnails(null); + setReorderThumbnails(null); + setPreviewIndex(null); + } catch (e) { + console.error(e); + setError('Failed to merge PDF (see console).'); + } finally { + setIsBusy(false); + setPendingFile(null); + setShowMergeOptions(false); + } + }; + useEffect(() => { if (!pdf) { setBaseThumbnails(null); @@ -117,10 +232,87 @@ const App: React.FC = () => { }; }, [pdf, rotations]); - const togglePageSelection = (index: number) => { - setSelectedPages((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index] - ); + const hasPdf = !!pdf; + const pageCount = pdf?.pageCount ?? 0; + + // === UI interactions === + + const handleRotatePageClockwise = (pageIndex: number) => { + setRotations((prev) => { + const current = prev[pageIndex] ?? 0; + const next = (current + 90) % 360; + return { ...prev, [pageIndex]: next }; + }); + }; + + const handleRotatePageCounterclockwise = (pageIndex: number) => { + setRotations((prev) => { + const current = prev[pageIndex] ?? 0; + const next = (current + 270) % 360; + return { ...prev, [pageIndex]: next }; + }); + }; + + const handleDeletePage = (pageIndex: number) => { + setOrder((prev) => prev.filter((p) => p !== pageIndex)); + setSelectedPages((prev) => prev.filter((p) => p !== pageIndex)); + }; + + const handleReorder = (newOrder: number[]) => { + setOrder(newOrder); + }; + + const handleToggleSelect = ( + pageIndex: number, + visualIndex: number, + e: React.MouseEvent + ) => { + setSelectedPages((prev) => { + // Shift: add a range (visual positions) to the existing selection + if (e.shiftKey && lastSelectedVisualIndex !== null && order.length > 0) { + const from = Math.min(lastSelectedVisualIndex, visualIndex); + const to = Math.max(lastSelectedVisualIndex, visualIndex); + const rangeIndices = order.slice(from, to + 1); // original page indices + + const set = new Set(prev); + rangeIndices.forEach((idx) => set.add(idx)); + return Array.from(set); + } + + // Plain click: toggle this page + if (prev.includes(pageIndex)) { + return prev.filter((p) => p !== pageIndex); + } + + return [...prev, pageIndex]; + }); + + setLastSelectedVisualIndex(visualIndex); + }; + + const handleSelectAll = () => { + setSelectedPages([...order]); + setLastSelectedVisualIndex(null); + }; + + const handleClearSelection = () => { + setSelectedPages([]); + setLastSelectedVisualIndex(null); + }; + + const handleDeleteSelected = () => { + if (selectedPages.length === 0) return; + setOrder((prev) => prev.filter((p) => !selectedPages.includes(p))); + setSelectedPages([]); + setLastSelectedVisualIndex(null); + }; + + const handleOpenPreview = (pageIndex: number) => { + setPreviewIndex(pageIndex); + }; + + const handleClosePreview = () => { + setPreviewIndex(null); }; const handleSplit = async () => { @@ -138,41 +330,44 @@ const App: React.FC = () => { } }; - const handleExtractRange = async (from: number, to: number) => { - if (!pdf) return; + const handleExtractSelected = async () => { + if (!pdf || selectedPages.length === 0) return; setError(null); setIsBusy(true); - if (rangeUrl) { - URL.revokeObjectURL(rangeUrl); - setRangeUrl(null); - setRangeFilename(null); + if (subsetUrl) { + URL.revokeObjectURL(subsetUrl); + setSubsetUrl(null); + setSubsetFilename(null); } try { - const blob = await extractRange(pdf, { from, to }); + const selectedOrder = order.filter((idx) => + selectedPages.includes(idx) + ); + const blob = await exportReordered(pdf, selectedOrder, rotations); const url = URL.createObjectURL(blob); const base = pdf.name.replace(/\.pdf$/i, ''); - const filename = `${base}_pages_${from}-${to}.pdf`; - setRangeUrl(url); - setRangeFilename(filename); + const filename = `${base}_selected.pdf`; + setSubsetUrl(url); + setSubsetFilename(filename); } catch (e) { console.error(e); - setError('Error while extracting range (see console).'); + setError('Error while extracting selected pages (see console).'); } finally { setIsBusy(false); } }; - const handleExportReordered = async (order: number[]) => { - if (!pdf) return; + const handleExportReordered = async () => { + if (!pdf || order.length === 0) return; setError(null); setIsBusy(true); - if (reorderUrl) { - URL.revokeObjectURL(reorderUrl); - setReorderUrl(null); - setReorderFilename(null); + if (exportUrl) { + URL.revokeObjectURL(exportUrl); + setExportUrl(null); + setExportFilename(null); } try { @@ -180,8 +375,8 @@ const App: React.FC = () => { const url = URL.createObjectURL(blob); const base = pdf.name.replace(/\.pdf$/i, ''); const filename = `${base}_reordered.pdf`; - setReorderUrl(url); - setReorderFilename(filename); + setExportUrl(url); + setExportFilename(filename); } catch (e) { console.error(e); setError('Error while exporting reordered PDF (see console).'); @@ -190,54 +385,141 @@ const App: React.FC = () => { } }; - const handleRotatePage = (pageIndex: number) => { - setRotations((prev) => { - const current = prev[pageIndex] ?? 0; - const next = (current + 90) % 360; - return { ...prev, [pageIndex]: next }; - }); - }; - - const hasPdf = !!pdf; - const pageCount = pdf?.pageCount ?? 0; - return ( - - + + - {activeTool === 'split' && pdf && ( - <> - - - + {showMergeOptions && pendingFile && pdf && order.length > 0 && ( +
+

Open file: merge or replace?

+

+ You already have {pdf.name} with {order.length}{' '} + pages open. What should happen with{' '} + {pendingFile.name}? +

+ +
+ + + + + +
+ +
+ + +
+
)} - {activeTool === 'reorder' && ( - - )} + + + + {error && (
{ Error: {error}
)} + +
); }; diff --git a/src/components/ActionsPanel.tsx b/src/components/ActionsPanel.tsx index 507b1fa..3edb68a 100644 --- a/src/components/ActionsPanel.tsx +++ b/src/components/ActionsPanel.tsx @@ -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 = ({ hasPdf, - onSplit, - onExtractRange, isBusy, + selectedCount, + onSplit, + onExtractSelected, + onExportReordered, splitResults, - rangeDownloadUrl, - rangeFilename, + subsetDownloadUrl, + subsetFilename, + exportDownloadUrl, + exportFilename, }) => { - const [fromPage, setFromPage] = useState(''); - const [toPage, setToPage] = useState(''); + 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 (
-

3. Actions

-

Split into single pages or extract a continuous range.

+

Tools

+

+ Use these tools on the current in-memory document (reordered, rotated, + with deletions). Nothing is uploaded to a server. +

-
+
+ + + +
-
- -
-
- Extract range: -
-
- - - + Download {subsetFilename} +
+ )} - {rangeDownloadUrl && rangeFilename && ( -
- Range result:{' '} - - Download {rangeFilename} - -
- )} -
+ {exportDownloadUrl && exportFilename && ( +
+ Exported document:{' '} + + Download {exportFilename} + +
+ )} {splitResults.length > 0 && (
- Split result: + Single-page PDFs:
{splitResults.map((r) => { const url = URL.createObjectURL(r.blob); @@ -111,7 +125,7 @@ const ActionsPanel: React.FC = ({ setTimeout(() => URL.revokeObjectURL(url), 5000); }} > - Download {r.filename} + {r.filename} ); })} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9dca2d0..c180923 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 = ({ activeTool, onToolChange, children }) => { +const Layout: React.FC = ({ children }) => { return ( -
- - +
{children}
); diff --git a/src/components/PagePreviewModal.tsx b/src/components/PagePreviewModal.tsx new file mode 100644 index 0000000..296e80a --- /dev/null +++ b/src/components/PagePreviewModal.tsx @@ -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 = ({ + isOpen, + pdf, + pageIndex, + rotation, + onClose, +}) => { + const canvasRef = useRef(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 ( +
+
e.stopPropagation()} + style={{ + background: '#111827', + borderRadius: '0.75rem', + padding: '0.75rem', + maxWidth: '90vw', + maxHeight: '90vh', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.5rem', + }} + > +
+ +
+ +
+ Page {pageIndex + 1} · Rot {rotation}° +
+
+
+ ); +}; + +export default PagePreviewModal; diff --git a/src/components/ReorderPanel.tsx b/src/components/ReorderPanel.tsx index e1d7a22..0f64bb1 100644 --- a/src/components/ReorderPanel.tsx +++ b/src/components/ReorderPanel.tsx @@ -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; - 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 + ) => void; + onSelectAll: () => void; + + onOpenPreview: (pageIndex: number) => void; + onClearSelection: () => void; + onDeleteSelected: () => void; } const ReorderPanel: React.FC = ({ - pageCount, + order, thumbnails, isBusy, hasPdf, rotations, - onRotate, - onExportReordered, - reorderDownloadUrl, - reorderFilename, + selectedPages, + onRotateClockwise, + onRotateCounterclockwise, + onDelete, + onReorder, + onToggleSelect, + onSelectAll, + onOpenPreview, + onClearSelection, + onDeleteSelected, }) => { - const [order, setOrder] = useState([]); const [draggingIndex, setDraggingIndex] = useState(null); const [dropIndex, setDropIndex] = useState(null); // slot 0..order.length + const dragGhostRef = useRef(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 = ({ 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 = ({ 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(); + 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) => { + e.stopPropagation(); // don't trigger preview + onToggleSelect(pageIndex, visualIndex, e); + }; + if (!hasPdf) { return (
-

Reorder pages

-

Load a PDF first to reorder, delete, or rotate its pages.

+

Pages

+

Open a PDF file to reorder, rotate, or delete its pages.

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

Reorder / delete / rotate

-

- Drag pages to reorder them. A vertical blue line shows where the page - will be inserted. Use rotate and delete controls below each thumbnail. +

Pages

+

+ 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.

+
+ + Selected: {selectedPages.length} + +
+ {selectedPages.length > 0 && ( + + )} + + +
+
+
= ({ > {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 (
= ({ 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 */} + + {/* left drop indicator */} {showLeftLine(visualIndex) && (
= ({ > + -
- - {reorderDownloadUrl && reorderFilename && ( -
- Reordered result:{' '} - 0 && ( + - )} + {showEndLine() && ( +
+ )} +
+ )} +
); }; diff --git a/src/pdf/pdfService.ts b/src/pdf/pdfService.ts index 04e35ec..a0e4852 100644 --- a/src/pdf/pdfService.ts +++ b/src/pdf/pdfService.ts @@ -18,6 +18,61 @@ export async function loadPdfFromFile(file: File): Promise { }; } +export async function mergePdfFiles( + basePdf: PdfFile, + newPdf: PdfFile, + insertAt: number +): Promise { + const baseDoc = await PDFDocument.load(basePdf.arrayBuffer); + const newDoc = await PDFDocument.load(newPdf.arrayBuffer); + + const mergedDoc = await PDFDocument.create(); + + const basePageCount = baseDoc.getPageCount(); + const newPageCount = newDoc.getPageCount(); + + const clampedInsertAt = Math.min(Math.max(insertAt, 0), basePageCount); + + const basePages = await mergedDoc.copyPages( + baseDoc, + Array.from({ length: basePageCount }, (_, i) => i) + ); + const newPages = await mergedDoc.copyPages( + newDoc, + Array.from({ length: newPageCount }, (_, i) => i) + ); + + // base pages before insertion + for (let i = 0; i < clampedInsertAt; i += 1) { + mergedDoc.addPage(basePages[i]); + } + + // inserted new pages + for (let i = 0; i < newPages.length; i += 1) { + mergedDoc.addPage(newPages[i]); + } + + // remaining base pages + for (let i = clampedInsertAt; i < basePages.length; i += 1) { + mergedDoc.addPage(basePages[i]); + } + + const bytes = await mergedDoc.save(); + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ); + + const baseName = basePdf.name.replace(/\.pdf$/i, ''); + const newName = newPdf.name.replace(/\.pdf$/i, ''); + + return { + name: `${baseName}_plus_${newName}.pdf`, + arrayBuffer: buffer, + pageCount: basePageCount + newPageCount, + }; +} + export async function splitIntoSinglePages( pdf: PdfFile ): Promise { diff --git a/src/styles.css b/src/styles.css index 350247c..bfb2c3f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -126,9 +126,59 @@ button.secondary { gap: 0.5rem; } +.app-root { + min-height: 100vh; + background-color: #f3f4f6; +} + +.app-header { + position: sticky; + top: 0; + z-index: 10; + background: #111827; + color: #e5e7eb; + padding: 0.6rem 1rem; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4); +} + +.app-header-title { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.app-header h1 { + font-size: 1rem; + margin: 0; +} + +.app-header small { + color: #9ca3af; + font-size: 0.8rem; +} + +.app-logo { + font-size: 1.4rem; +} + +.app-main { + padding: 0.75rem; + max-width: 900px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Make cards full-width on mobile */ +.card { + width: 100%; +} + +/* Slightly less rounded page pills so they look like rectangles */ .page-pill { padding: 0.2rem 0.5rem; - border-radius: 0.5rem; + border-radius: 0.5rem; /* was 999px */ border: 1px solid #e5e7eb; font-size: 0.8rem; background: #f9fafb;