UI improvements, merge
This commit is contained in:
442
src/App.tsx
442
src/App.tsx
@@ -1,14 +1,14 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Layout, { type ToolId } from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import FileLoader from './components/FileLoader';
|
import FileLoader from './components/FileLoader';
|
||||||
import PageList from './components/PageList';
|
|
||||||
import ActionsPanel from './components/ActionsPanel';
|
|
||||||
import ReorderPanel from './components/ReorderPanel';
|
import ReorderPanel from './components/ReorderPanel';
|
||||||
|
import ActionsPanel from './components/ActionsPanel';
|
||||||
|
import PagePreviewModal from './components/PagePreviewModal';
|
||||||
import type { PdfFile, SplitResult } from './pdf/pdfTypes';
|
import type { PdfFile, SplitResult } from './pdf/pdfTypes';
|
||||||
import {
|
import {
|
||||||
loadPdfFromFile,
|
loadPdfFromFile,
|
||||||
|
mergePdfFiles,
|
||||||
splitIntoSinglePages,
|
splitIntoSinglePages,
|
||||||
extractRange,
|
|
||||||
exportReordered,
|
exportReordered,
|
||||||
} from './pdf/pdfService';
|
} from './pdf/pdfService';
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +17,8 @@ import {
|
|||||||
} from './pdf/pdfThumbnailService';
|
} from './pdf/pdfThumbnailService';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [activeTool, setActiveTool] = useState<ToolId>('split');
|
|
||||||
const [pdf, setPdf] = useState<PdfFile | null>(null);
|
const [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null);
|
const [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null);
|
||||||
@@ -29,29 +26,52 @@ const App: React.FC = () => {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const [rangeUrl, setRangeUrl] = useState<string | null>(null);
|
const [order, setOrder] = useState<number[]>([]);
|
||||||
const [rangeFilename, setRangeFilename] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [reorderUrl, setReorderUrl] = useState<string | null>(null);
|
|
||||||
const [reorderFilename, setReorderFilename] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [rotations, setRotations] = useState<Record<number, 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);
|
setError(null);
|
||||||
setSplitResults([]);
|
setSplitResults([]);
|
||||||
setSelectedPages([]);
|
setSelectedPages([]);
|
||||||
setRangeUrl(null);
|
setLastSelectedVisualIndex(null);
|
||||||
setRangeFilename(null);
|
setSubsetUrl(null);
|
||||||
setReorderUrl(null);
|
setSubsetFilename(null);
|
||||||
setReorderFilename(null);
|
setExportUrl(null);
|
||||||
|
setExportFilename(null);
|
||||||
setBaseThumbnails(null);
|
setBaseThumbnails(null);
|
||||||
setReorderThumbnails(null);
|
setReorderThumbnails(null);
|
||||||
setRotations({});
|
setRotations({});
|
||||||
|
setOrder([]);
|
||||||
|
setPreviewIndex(null);
|
||||||
|
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const loaded = await loadPdfFromFile(file);
|
const loaded = await loadPdfFromFile(file);
|
||||||
setPdf(loaded);
|
setPdf(loaded);
|
||||||
|
|
||||||
|
const initialOrder = Array.from(
|
||||||
|
{ length: loaded.pageCount },
|
||||||
|
(_, i) => i
|
||||||
|
);
|
||||||
|
setOrder(initialOrder);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Failed to load PDF (see console).');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!pdf) {
|
if (!pdf) {
|
||||||
setBaseThumbnails(null);
|
setBaseThumbnails(null);
|
||||||
@@ -117,10 +232,87 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [pdf, rotations]);
|
}, [pdf, rotations]);
|
||||||
|
|
||||||
const togglePageSelection = (index: number) => {
|
const hasPdf = !!pdf;
|
||||||
setSelectedPages((prev) =>
|
const pageCount = pdf?.pageCount ?? 0;
|
||||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
|
||||||
);
|
// === 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 () => {
|
const handleSplit = async () => {
|
||||||
@@ -138,41 +330,44 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExtractRange = async (from: number, to: number) => {
|
const handleExtractSelected = async () => {
|
||||||
if (!pdf) return;
|
if (!pdf || selectedPages.length === 0) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
|
|
||||||
if (rangeUrl) {
|
if (subsetUrl) {
|
||||||
URL.revokeObjectURL(rangeUrl);
|
URL.revokeObjectURL(subsetUrl);
|
||||||
setRangeUrl(null);
|
setSubsetUrl(null);
|
||||||
setRangeFilename(null);
|
setSubsetFilename(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 url = URL.createObjectURL(blob);
|
||||||
const base = pdf.name.replace(/\.pdf$/i, '');
|
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||||
const filename = `${base}_pages_${from}-${to}.pdf`;
|
const filename = `${base}_selected.pdf`;
|
||||||
setRangeUrl(url);
|
setSubsetUrl(url);
|
||||||
setRangeFilename(filename);
|
setSubsetFilename(filename);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Error while extracting range (see console).');
|
setError('Error while extracting selected pages (see console).');
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportReordered = async (order: number[]) => {
|
const handleExportReordered = async () => {
|
||||||
if (!pdf) return;
|
if (!pdf || order.length === 0) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
|
|
||||||
if (reorderUrl) {
|
if (exportUrl) {
|
||||||
URL.revokeObjectURL(reorderUrl);
|
URL.revokeObjectURL(exportUrl);
|
||||||
setReorderUrl(null);
|
setExportUrl(null);
|
||||||
setReorderFilename(null);
|
setExportFilename(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -180,8 +375,8 @@ const App: React.FC = () => {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const base = pdf.name.replace(/\.pdf$/i, '');
|
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||||
const filename = `${base}_reordered.pdf`;
|
const filename = `${base}_reordered.pdf`;
|
||||||
setReorderUrl(url);
|
setExportUrl(url);
|
||||||
setReorderFilename(filename);
|
setExportFilename(filename);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Error while exporting reordered PDF (see console).');
|
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 (
|
return (
|
||||||
<Layout activeTool={activeTool} onToolChange={setActiveTool}>
|
<Layout>
|
||||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||||
|
|
||||||
{activeTool === 'split' && pdf && (
|
{showMergeOptions && pendingFile && pdf && order.length > 0 && (
|
||||||
<>
|
<div
|
||||||
<PageList
|
className="card"
|
||||||
pageCount={pageCount}
|
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
|
||||||
selectedPages={selectedPages}
|
>
|
||||||
onTogglePage={togglePageSelection}
|
<h2>Open file: merge or replace?</h2>
|
||||||
thumbnails={baseThumbnails}
|
<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')}
|
||||||
/>
|
/>
|
||||||
<ActionsPanel
|
<span>Replace current document</span>
|
||||||
hasPdf={hasPdf}
|
</label>
|
||||||
onSplit={handleSplit}
|
|
||||||
onExtractRange={handleExtractRange}
|
<label
|
||||||
isBusy={isBusy}
|
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||||
splitResults={splitResults}
|
>
|
||||||
rangeDownloadUrl={rangeUrl}
|
<input
|
||||||
rangeFilename={rangeFilename}
|
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
|
<ReorderPanel
|
||||||
pageCount={pageCount}
|
order={order}
|
||||||
thumbnails={reorderThumbnails}
|
thumbnails={reorderThumbnails}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
hasPdf={hasPdf}
|
hasPdf={hasPdf}
|
||||||
rotations={rotations}
|
rotations={rotations}
|
||||||
onRotate={handleRotatePage}
|
selectedPages={selectedPages}
|
||||||
onExportReordered={handleExportReordered}
|
onRotateClockwise={handleRotatePageClockwise}
|
||||||
reorderDownloadUrl={reorderUrl}
|
onRotateCounterclockwise={handleRotatePageCounterclockwise}
|
||||||
reorderFilename={reorderFilename}
|
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 && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
@@ -247,6 +529,14 @@ const App: React.FC = () => {
|
|||||||
<strong>Error:</strong> {error}
|
<strong>Error:</strong> {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PagePreviewModal
|
||||||
|
isOpen={previewIndex !== null}
|
||||||
|
pdf={pdf}
|
||||||
|
pageIndex={previewIndex}
|
||||||
|
rotation={previewIndex != null ? rotations[previewIndex] ?? 0 : 0}
|
||||||
|
onClose={handleClosePreview}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,103 +1,117 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import type { SplitResult } from '../pdf/pdfTypes';
|
import type { SplitResult } from '../pdf/pdfTypes';
|
||||||
|
|
||||||
interface ActionsPanelProps {
|
interface ActionsPanelProps {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
onSplit: () => void;
|
|
||||||
onExtractRange: (from: number, to: number) => void;
|
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
|
||||||
|
selectedCount: number;
|
||||||
|
|
||||||
|
onSplit: () => void;
|
||||||
|
onExtractSelected: () => void;
|
||||||
|
onExportReordered: () => void;
|
||||||
|
|
||||||
splitResults: SplitResult[];
|
splitResults: SplitResult[];
|
||||||
rangeDownloadUrl: string | null;
|
subsetDownloadUrl: string | null;
|
||||||
rangeFilename: string | null;
|
subsetFilename: string | null;
|
||||||
|
exportDownloadUrl: string | null;
|
||||||
|
exportFilename: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||||
hasPdf,
|
hasPdf,
|
||||||
onSplit,
|
|
||||||
onExtractRange,
|
|
||||||
isBusy,
|
isBusy,
|
||||||
|
selectedCount,
|
||||||
|
onSplit,
|
||||||
|
onExtractSelected,
|
||||||
|
onExportReordered,
|
||||||
splitResults,
|
splitResults,
|
||||||
rangeDownloadUrl,
|
subsetDownloadUrl,
|
||||||
rangeFilename,
|
subsetFilename,
|
||||||
|
exportDownloadUrl,
|
||||||
|
exportFilename,
|
||||||
}) => {
|
}) => {
|
||||||
const [fromPage, setFromPage] = useState<string>('');
|
const disabled = !hasPdf || isBusy;
|
||||||
const [toPage, setToPage] = useState<string>('');
|
|
||||||
|
|
||||||
const handleExtractClick = () => {
|
const handleExtractSelectedClick = () => {
|
||||||
const from = parseInt(fromPage, 10);
|
if (selectedCount === 0) return;
|
||||||
const to = parseInt(toPage, 10);
|
onExtractSelected();
|
||||||
if (!Number.isFinite(from) || !Number.isFinite(to)) return;
|
|
||||||
onExtractRange(from, to);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>3. Actions</h2>
|
<h2>Tools</h2>
|
||||||
<p>Split into single pages or extract a continuous range.</p>
|
<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
|
||||||
<button
|
className="button-row"
|
||||||
className="primary"
|
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
disabled={!hasPdf || isBusy}
|
|
||||||
onClick={onSplit}
|
|
||||||
>
|
>
|
||||||
{isBusy ? 'Splitting…' : 'Split into single pages'}
|
|
||||||
</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
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={!hasPdf || isBusy}
|
disabled={disabled}
|
||||||
onClick={handleExtractClick}
|
onClick={onExportReordered}
|
||||||
|
style={{ flex: '1 1 45%' }}
|
||||||
>
|
>
|
||||||
{isBusy ? 'Working…' : 'Extract'}
|
🧾 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rangeDownloadUrl && rangeFilename && (
|
{subsetDownloadUrl && subsetFilename && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Range result:</strong>{' '}
|
<strong>Subset result:</strong>{' '}
|
||||||
<a
|
<a
|
||||||
className="download-link"
|
className="download-link"
|
||||||
href={rangeDownloadUrl}
|
href={subsetDownloadUrl}
|
||||||
download={rangeFilename}
|
download={subsetFilename}
|
||||||
>
|
>
|
||||||
Download {rangeFilename}
|
Download {subsetFilename}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{splitResults.length > 0 && (
|
{splitResults.length > 0 && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Split result:</strong>
|
<strong>Single-page PDFs:</strong>
|
||||||
<div>
|
<div>
|
||||||
{splitResults.map((r) => {
|
{splitResults.map((r) => {
|
||||||
const url = URL.createObjectURL(r.blob);
|
const url = URL.createObjectURL(r.blob);
|
||||||
@@ -111,7 +125,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download {r.filename}
|
{r.filename}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,54 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type ToolId = 'split' | 'reorder' | 'merge' | 'annotate';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
activeTool: ToolId;
|
|
||||||
onToolChange: (tool: ToolId) => void;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ activeTool, onToolChange, children }) => {
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-root">
|
||||||
<aside className="app-sidebar">
|
<header className="app-header">
|
||||||
|
<div className="app-header-title">
|
||||||
|
<span className="app-logo">📄</span>
|
||||||
<div>
|
<div>
|
||||||
<h1>PDF Workbench</h1>
|
<h1>PDF Workbench</h1>
|
||||||
<small>Self-hosted, browser-based</small>
|
<small>All in your browser</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<nav className="app-nav">
|
</header>
|
||||||
<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>
|
|
||||||
|
|
||||||
<main className="app-main">{children}</main>
|
<main className="app-main">{children}</main>
|
||||||
</div>
|
</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 {
|
interface ReorderPanelProps {
|
||||||
pageCount: number;
|
order: number[]; // current page order (page indices)
|
||||||
thumbnails: string[] | null;
|
thumbnails: string[] | null; // thumbnails by original page index
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
rotations: Record<number, number>;
|
rotations: Record<number, number>;
|
||||||
onRotate: (pageIndex: number) => void;
|
selectedPages: number[]; // selected original page indices
|
||||||
onExportReordered: (order: number[]) => void;
|
|
||||||
reorderDownloadUrl: string | null;
|
onRotateClockwise: (pageIndex: number) => void;
|
||||||
reorderFilename: string | null;
|
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> = ({
|
const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||||
pageCount,
|
order,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
isBusy,
|
isBusy,
|
||||||
hasPdf,
|
hasPdf,
|
||||||
rotations,
|
rotations,
|
||||||
onRotate,
|
selectedPages,
|
||||||
onExportReordered,
|
onRotateClockwise,
|
||||||
reorderDownloadUrl,
|
onRotateCounterclockwise,
|
||||||
reorderFilename,
|
onDelete,
|
||||||
|
onReorder,
|
||||||
|
onToggleSelect,
|
||||||
|
onSelectAll,
|
||||||
|
onOpenPreview,
|
||||||
|
onClearSelection,
|
||||||
|
onDeleteSelected,
|
||||||
}) => {
|
}) => {
|
||||||
const [order, setOrder] = useState<number[]>([]);
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length
|
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length
|
||||||
|
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const isSelected = (pageIndex: number) => selectedPages.includes(pageIndex);
|
||||||
if (pageCount > 0) {
|
|
||||||
setOrder(Array.from({ length: pageCount }, (_, i) => i));
|
const cleanupDragGhost = () => {
|
||||||
} else {
|
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||||
setOrder([]);
|
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||||
setDraggingIndex(null);
|
|
||||||
setDropIndex(null);
|
|
||||||
}
|
}
|
||||||
}, [pageCount]);
|
dragGhostRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (index: number) => (e: React.DragEvent) => {
|
const createDragGhost = (e: React.DragEvent, count: number) => {
|
||||||
setDraggingIndex(index);
|
cleanupDragGhost();
|
||||||
setDropIndex(index); // initial assumption: before itself
|
|
||||||
|
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';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
// Firefox needs some data
|
e.dataTransfer.setData('text/plain', String(visualIndex)); // Firefox
|
||||||
e.dataTransfer.setData('text/plain', String(index));
|
|
||||||
|
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 = () => {
|
const handleDragEnd = () => {
|
||||||
|
cleanupDragGhost();
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardDragOver = (cardIndex: number) => (e: React.DragEvent) => {
|
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||||
if (draggingIndex == null) return;
|
if (draggingIndex == null) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
@@ -58,9 +115,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
|
|
||||||
// left half => slot BEFORE this card
|
const slot = x < rect.width / 2 ? visualIndex : visualIndex + 1;
|
||||||
// right half => slot AFTER this card
|
|
||||||
const slot = x < rect.width / 2 ? cardIndex : cardIndex + 1;
|
|
||||||
setDropIndex(slot);
|
setDropIndex(slot);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,67 +123,195 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
if (draggingIndex == null) return;
|
if (draggingIndex == null) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDropIndex(order.length); // slot at the very end
|
setDropIndex(order.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
cleanupDragGhost();
|
||||||
|
|
||||||
if (draggingIndex == null || dropIndex == null) return;
|
if (draggingIndex == null || dropIndex == null) return;
|
||||||
|
|
||||||
setOrder((prev) => {
|
const draggedPageIndex = order[draggingIndex];
|
||||||
const updated = [...prev];
|
|
||||||
const [moved] = updated.splice(draggingIndex, 1);
|
|
||||||
|
|
||||||
const adjustedSlot = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
|
// Selected pages in current visual order
|
||||||
updated.splice(adjustedSlot, 0, moved);
|
const selectedInVisualOrder = order.filter((p) =>
|
||||||
return updated;
|
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);
|
setDraggingIndex(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (visualIndex: number) => () => {
|
const handleDeleteClick = (pageIndex: number) => () => {
|
||||||
setOrder((prev) => prev.filter((_, idx) => idx !== visualIndex));
|
onDelete(pageIndex);
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRotateClick = (pageIndex: number) => () => {
|
const handleRotateClickClockwise = (pageIndex: number) => () => {
|
||||||
onRotate(pageIndex);
|
onRotateClockwise(pageIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleRotateClickCounterclockwise = (pageIndex: number) => () => {
|
||||||
if (!hasPdf || order.length === 0) return;
|
onRotateCounterclockwise(pageIndex);
|
||||||
onExportReordered(order);
|
};
|
||||||
|
|
||||||
|
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) {
|
if (!hasPdf) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Reorder pages</h2>
|
<h2>Pages</h2>
|
||||||
<p>Load a PDF first to reorder, delete, or rotate its pages.</p>
|
<p>Open a PDF file to reorder, rotate, or delete its pages.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showLeftLine = (cardIndex: number) =>
|
const showLeftLine = (visualIndex: number) =>
|
||||||
dropIndex !== null && dropIndex === cardIndex && draggingIndex !== null;
|
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||||
|
|
||||||
const showRightLine = (cardIndex: number) =>
|
const showRightLine = (visualIndex: number) =>
|
||||||
dropIndex !== null && dropIndex === cardIndex + 1 && draggingIndex !== null;
|
dropIndex !== null &&
|
||||||
|
dropIndex === visualIndex + 1 &&
|
||||||
|
draggingIndex !== null;
|
||||||
|
|
||||||
const showEndLine = () =>
|
const showEndLine = () =>
|
||||||
dropIndex !== null && dropIndex === order.length && draggingIndex !== null;
|
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 (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Reorder / delete / rotate</h2>
|
<h2>Pages</h2>
|
||||||
<p>
|
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
Drag pages to reorder them. A vertical blue line shows where the page
|
Tap/click a page to preview it. Use the checkbox to select pages
|
||||||
will be inserted. Use rotate and delete controls below each thumbnail.
|
(Shift for ranges). Drag to reorder; dragging a selected page moves the
|
||||||
|
whole selection.
|
||||||
</p>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -141,8 +324,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{order.map((pageIndex, visualIndex) => {
|
{order.map((pageIndex, visualIndex) => {
|
||||||
const thumb = thumbnails?.[pageIndex];
|
const thumb = thumbnails?.[pageIndex];
|
||||||
const isDragging = visualIndex === draggingIndex;
|
|
||||||
const rotation = rotations[pageIndex] ?? 0;
|
const rotation = rotations[pageIndex] ?? 0;
|
||||||
|
const selected = isSelected(pageIndex);
|
||||||
|
|
||||||
|
const isDraggingCard =
|
||||||
|
draggingIndex != null &&
|
||||||
|
((draggingSelectionActive && selected) ||
|
||||||
|
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -151,20 +339,52 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
onDragStart={handleDragStart(visualIndex)}
|
onDragStart={handleDragStart(visualIndex)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragOver={handleCardDragOver(visualIndex)}
|
onDragOver={handleCardDragOver(visualIndex)}
|
||||||
|
onClick={handleCardClick(pageIndex)}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '130px',
|
width: '162px',
|
||||||
padding: '0.4rem',
|
padding: '0.4rem',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
border: isDragging ? '2px solid #2563eb' : '1px solid #e5e7eb',
|
border: '1px solid #e5e7eb', // constant → no jump
|
||||||
background: isDragging ? '#dbeafe' : '#f9fafb',
|
background: isDraggingCard
|
||||||
|
? '#dbeafe'
|
||||||
|
: selected
|
||||||
|
? '#eff6ff'
|
||||||
|
: '#f9fafb',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.25rem',
|
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 */}
|
{/* left drop indicator */}
|
||||||
{showLeftLine(visualIndex) && (
|
{showLeftLine(visualIndex) && (
|
||||||
<div
|
<div
|
||||||
@@ -232,7 +452,10 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRotateClick(pageIndex)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRotateClickClockwise(pageIndex)();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
@@ -246,7 +469,27 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
@@ -265,7 +508,8 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* invisible end slot to allow dropping after the last card */}
|
{/* end slot for dropping after the last card */}
|
||||||
|
{order.length > 0 && (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleEndSlotDragOver}
|
onDragOver={handleEndSlotDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -290,31 +534,9 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-row">
|
|
||||||
<button
|
|
||||||
className="primary"
|
|
||||||
disabled={!hasPdf || isBusy || order.length === 0}
|
|
||||||
onClick={handleExport}
|
|
||||||
>
|
|
||||||
{isBusy ? 'Exporting…' : 'Export reordered PDF'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reorderDownloadUrl && reorderFilename && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
|
||||||
<strong>Reordered result:</strong>{' '}
|
|
||||||
<a
|
|
||||||
href={reorderDownloadUrl}
|
|
||||||
download={reorderFilename}
|
|
||||||
className="download-link"
|
|
||||||
>
|
|
||||||
Download {reorderFilename}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,61 @@ export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function mergePdfFiles(
|
||||||
|
basePdf: PdfFile,
|
||||||
|
newPdf: PdfFile,
|
||||||
|
insertAt: number
|
||||||
|
): Promise<PdfFile> {
|
||||||
|
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(
|
export async function splitIntoSinglePages(
|
||||||
pdf: PdfFile
|
pdf: PdfFile
|
||||||
): Promise<SplitResult[]> {
|
): Promise<SplitResult[]> {
|
||||||
|
|||||||
@@ -126,9 +126,59 @@ button.secondary {
|
|||||||
gap: 0.5rem;
|
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 {
|
.page-pill {
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem; /* was 999px */
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
|
|||||||
Reference in New Issue
Block a user