UI improvements, merge
This commit is contained in:
460
src/App.tsx
460
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<ToolId>('split');
|
||||
const [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null);
|
||||
@@ -29,29 +26,52 @@ const App: React.FC = () => {
|
||||
null
|
||||
);
|
||||
|
||||
const [rangeUrl, setRangeUrl] = useState<string | null>(null);
|
||||
const [rangeFilename, setRangeFilename] = useState<string | null>(null);
|
||||
|
||||
const [reorderUrl, setReorderUrl] = useState<string | null>(null);
|
||||
const [reorderFilename, setReorderFilename] = useState<string | null>(null);
|
||||
|
||||
const [order, setOrder] = useState<number[]>([]);
|
||||
const [rotations, setRotations] = useState<Record<number, number>>({});
|
||||
|
||||
const handleFileLoaded = async (file: File) => {
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||
const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
|
||||
const [subsetUrl, setSubsetUrl] = useState<string | null>(null);
|
||||
const [subsetFilename, setSubsetFilename] = useState<string | null>(null);
|
||||
const [exportUrl, setExportUrl] = useState<string | null>(null);
|
||||
const [exportFilename, setExportFilename] = useState<string | null>(null);
|
||||
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [showMergeOptions, setShowMergeOptions] = useState(false);
|
||||
const [mergeMode, setMergeMode] = useState<'overwrite' | 'append' | 'insertAt'>('append');
|
||||
const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
|
||||
|
||||
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<HTMLButtonElement>
|
||||
) => {
|
||||
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 (
|
||||
<Layout activeTool={activeTool} onToolChange={setActiveTool}>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
<Layout>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
|
||||
{activeTool === 'split' && pdf && (
|
||||
<>
|
||||
<PageList
|
||||
pageCount={pageCount}
|
||||
selectedPages={selectedPages}
|
||||
onTogglePage={togglePageSelection}
|
||||
thumbnails={baseThumbnails}
|
||||
/>
|
||||
<ActionsPanel
|
||||
hasPdf={hasPdf}
|
||||
onSplit={handleSplit}
|
||||
onExtractRange={handleExtractRange}
|
||||
isBusy={isBusy}
|
||||
splitResults={splitResults}
|
||||
rangeDownloadUrl={rangeUrl}
|
||||
rangeFilename={rangeFilename}
|
||||
/>
|
||||
</>
|
||||
{showMergeOptions && pendingFile && pdf && order.length > 0 && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
|
||||
>
|
||||
<h2>Open file: merge or replace?</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
||||
You already have <strong>{pdf.name}</strong> with {order.length}{' '}
|
||||
pages open. What should happen with{' '}
|
||||
<strong>{pendingFile.name}</strong>?
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="overwrite"
|
||||
checked={mergeMode === 'overwrite'}
|
||||
onChange={() => setMergeMode('overwrite')}
|
||||
/>
|
||||
<span>Replace current document</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="append"
|
||||
checked={mergeMode === 'append'}
|
||||
onChange={() => setMergeMode('append')}
|
||||
/>
|
||||
<span>Merge and append pages at the end</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="insertAt"
|
||||
checked={mergeMode === 'insertAt'}
|
||||
onChange={() => setMergeMode('insertAt')}
|
||||
/>
|
||||
<span>
|
||||
Merge and insert starting at position{' '}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={order.length + 1}
|
||||
value={mergeInsertAt}
|
||||
onChange={(e) => setMergeInsertAt(e.target.value)}
|
||||
style={{
|
||||
width: '4rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
/>{' '}
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
(1 = before first page, {order.length + 1} = after last page)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="button-row" style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={handleMergeCancel}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={handleMergeConfirm}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTool === 'reorder' && (
|
||||
<ReorderPanel
|
||||
pageCount={pageCount}
|
||||
thumbnails={reorderThumbnails}
|
||||
isBusy={isBusy}
|
||||
hasPdf={hasPdf}
|
||||
rotations={rotations}
|
||||
onRotate={handleRotatePage}
|
||||
onExportReordered={handleExportReordered}
|
||||
reorderDownloadUrl={reorderUrl}
|
||||
reorderFilename={reorderFilename}
|
||||
/>
|
||||
)}
|
||||
<ReorderPanel
|
||||
order={order}
|
||||
thumbnails={reorderThumbnails}
|
||||
isBusy={isBusy}
|
||||
hasPdf={hasPdf}
|
||||
rotations={rotations}
|
||||
selectedPages={selectedPages}
|
||||
onRotateClockwise={handleRotatePageClockwise}
|
||||
onRotateCounterclockwise={handleRotatePageCounterclockwise}
|
||||
onDelete={handleDeletePage}
|
||||
onReorder={handleReorder}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
onOpenPreview={handleOpenPreview}
|
||||
onClearSelection={handleClearSelection}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
|
||||
|
||||
<ActionsPanel
|
||||
hasPdf={hasPdf}
|
||||
isBusy={isBusy}
|
||||
selectedCount={selectedPages.length}
|
||||
onSplit={handleSplit}
|
||||
onExtractSelected={handleExtractSelected}
|
||||
onExportReordered={handleExportReordered}
|
||||
splitResults={splitResults}
|
||||
subsetDownloadUrl={subsetUrl}
|
||||
subsetFilename={subsetFilename}
|
||||
exportDownloadUrl={exportUrl}
|
||||
exportFilename={exportFilename}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
@@ -247,6 +529,14 @@ const App: React.FC = () => {
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PagePreviewModal
|
||||
isOpen={previewIndex !== null}
|
||||
pdf={pdf}
|
||||
pageIndex={previewIndex}
|
||||
rotation={previewIndex != null ? rotations[previewIndex] ?? 0 : 0}
|
||||
onClose={handleClosePreview}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user