merge files inital commit
This commit is contained in:
273
src/App.tsx
273
src/App.tsx
@@ -9,6 +9,7 @@ import ActionDialog, {
|
||||
type ActionDialogAction,
|
||||
} from './components/ActionDialog';
|
||||
import HelpDialog from './components/HelpDialog';
|
||||
import MergeQueuePanel from './components/MergeQueuePanel';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import type {
|
||||
StoredWorkspace,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
import type { PageRef, PdfFile } from './pdf/pdfTypes';
|
||||
import {
|
||||
loadPdfFromFile,
|
||||
mergePdfFiles,
|
||||
mergePdfFilesAtPosition,
|
||||
splitIntoSinglePages,
|
||||
exportPages,
|
||||
} from './pdf/pdfService';
|
||||
@@ -47,6 +48,13 @@ import {
|
||||
createSelectionWorkspaceName,
|
||||
getSelectedPagesInVisualOrder,
|
||||
} from './workspace/workspaceSelection';
|
||||
import { useMergeQueue } from './merge/useMergeQueue';
|
||||
import {
|
||||
clampMergeInsertAt,
|
||||
createMergedPdfName,
|
||||
defaultMergeInsertPosition,
|
||||
} from './merge/mergeQueueHelpers';
|
||||
import type { MergeMode } from './merge/mergeTypes';
|
||||
|
||||
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -80,13 +88,23 @@ const App: React.FC = () => {
|
||||
|
||||
const [previewPageId, setPreviewPageId] = useState<string | null>(null);
|
||||
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [showMergeOptions, setShowMergeOptions] = useState(false);
|
||||
const [mergeMode, setMergeMode] = useState<
|
||||
'overwrite' | 'append' | 'insertAt'
|
||||
>('append');
|
||||
const [mergeQueueOpen, setMergeQueueOpen] = useState(false);
|
||||
const [mergeMode, setMergeMode] = useState<MergeMode>('append');
|
||||
const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
|
||||
|
||||
const {
|
||||
items: mergeQueueItems,
|
||||
addFiles: addFilesToMergeQueue,
|
||||
removeItem: removeMergeQueueItem,
|
||||
moveItemUp: moveMergeQueueItemUp,
|
||||
moveItemDown: moveMergeQueueItemDown,
|
||||
clearQueue: clearMergeQueue,
|
||||
readyPdfs: readyMergePdfs,
|
||||
canMerge: canMergeQueue,
|
||||
hasErrors: mergeQueueHasErrors,
|
||||
isLoading: mergeQueueIsLoading,
|
||||
} = useMergeQueue();
|
||||
|
||||
const {
|
||||
splitDownloads,
|
||||
splitZipDownload,
|
||||
@@ -422,89 +440,111 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileLoaded = (file: File) => {
|
||||
if (!pdf || pages.length === 0) {
|
||||
void loadFileAsNew(file);
|
||||
} else {
|
||||
setPendingFile(file);
|
||||
setShowMergeOptions(true);
|
||||
setMergeMode('append');
|
||||
setMergeInsertAt(String(pages.length + 1));
|
||||
const queueFilesForMerge = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const queueWasEmpty = mergeQueueItems.length === 0;
|
||||
|
||||
addFilesToMergeQueue(files);
|
||||
setMergeQueueOpen(true);
|
||||
|
||||
if (queueWasEmpty) {
|
||||
if (!pdf || pages.length === 0) {
|
||||
setMergeMode('overwrite');
|
||||
setMergeInsertAt('1');
|
||||
} else {
|
||||
setMergeMode('append');
|
||||
setMergeInsertAt(defaultMergeInsertPosition(pages.length));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilesLoaded = (files: File[]) => {
|
||||
const pdfFiles = files.filter(
|
||||
(file) =>
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
|
||||
if (pdfFiles.length === 0) return;
|
||||
|
||||
if (!pdf && pages.length === 0 && pdfFiles.length === 1) {
|
||||
void loadFileAsNew(pdfFiles[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
queueFilesForMerge(pdfFiles);
|
||||
};
|
||||
|
||||
const handleMergeCancel = () => {
|
||||
setPendingFile(null);
|
||||
setShowMergeOptions(false);
|
||||
clearMergeQueue();
|
||||
setMergeQueueOpen(false);
|
||||
};
|
||||
|
||||
const handleMergeConfirm = async () => {
|
||||
if (!pendingFile) return;
|
||||
|
||||
if (!pdf || mergeMode === 'overwrite') {
|
||||
await loadFileAsNew(pendingFile);
|
||||
setPendingFile(null);
|
||||
setShowMergeOptions(false);
|
||||
return;
|
||||
}
|
||||
if (!canMergeQueue || readyMergePdfs.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
try {
|
||||
// 1) Materialize the current in-memory workspace (page refs + rotations)
|
||||
const currentBlob = await exportPages(pdf, pages);
|
||||
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
||||
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
||||
const currentPdf: PdfFile = {
|
||||
id: pdf.id,
|
||||
name: pdf.name,
|
||||
doc: currentDoc,
|
||||
arrayBuffer: currentArrayBuffer,
|
||||
pageCount: pages.length,
|
||||
};
|
||||
let basePdf: PdfFile | null = null;
|
||||
let insertAt = 0;
|
||||
|
||||
// 2) Load the new PDF
|
||||
const newPdf = await loadPdfFromFile(pendingFile);
|
||||
if (pdf && pages.length > 0 && mergeMode !== 'overwrite') {
|
||||
const currentBlob = await exportPages(pdf, pages);
|
||||
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
||||
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
||||
|
||||
// 3) Determine insert position (0-based)
|
||||
let insertAt = pages.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), pages.length);
|
||||
}
|
||||
} else if (mergeMode === 'append') {
|
||||
insertAt = pages.length;
|
||||
basePdf = {
|
||||
id: pdf.id,
|
||||
name: pdf.name,
|
||||
doc: currentDoc,
|
||||
arrayBuffer: currentArrayBuffer,
|
||||
pageCount: pages.length,
|
||||
};
|
||||
|
||||
insertAt =
|
||||
mergeMode === 'insertAt'
|
||||
? clampMergeInsertAt(mergeInsertAt, pages.length)
|
||||
: pages.length;
|
||||
}
|
||||
|
||||
// 4) Merge
|
||||
const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt);
|
||||
const mergedPages = createInitialPageRefs(mergedPdf.pageCount);
|
||||
const mergedPdf = await mergePdfFilesAtPosition({
|
||||
basePdf,
|
||||
incomingPdfs: readyMergePdfs,
|
||||
insertAt,
|
||||
name: createMergedPdfName(
|
||||
pdf?.name ?? null,
|
||||
readyMergePdfs.map((item) => item.name),
|
||||
mergeMode
|
||||
),
|
||||
});
|
||||
|
||||
// 5) Reset state to the merged document
|
||||
setPdf(mergedPdf);
|
||||
replaceWorkspaceState({
|
||||
pages: mergedPages,
|
||||
pages: createInitialPageRefs(mergedPdf.pageCount),
|
||||
selectedPageIds: [],
|
||||
lastSelectedVisualIndex: null,
|
||||
history: [],
|
||||
redoHistory: [],
|
||||
dirty: true,
|
||||
message: null,
|
||||
message: `Merged ${readyMergePdfs.length} queued ${
|
||||
readyMergePdfs.length === 1 ? 'PDF' : 'PDFs'
|
||||
} into a new unsaved workspace.`,
|
||||
});
|
||||
clearGeneratedOutputs();
|
||||
clearThumbnailCache();
|
||||
setPreviewPageId(null);
|
||||
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
|
||||
setActiveWorkspaceId(null);
|
||||
clearMergeQueue();
|
||||
setMergeQueueOpen(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Failed to merge PDF (see console).');
|
||||
setError('Failed to merge PDF queue (see console).');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
setPendingFile(null);
|
||||
setShowMergeOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1100,7 +1140,29 @@ const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
<FileLoader pdf={pdf} onFilesLoaded={handleFilesLoaded} />
|
||||
|
||||
{mergeQueueOpen && mergeQueueItems.length > 0 && (
|
||||
<MergeQueuePanel
|
||||
items={mergeQueueItems}
|
||||
hasCurrentPdf={hasPdf && pages.length > 0}
|
||||
currentPdfName={pdf?.name ?? null}
|
||||
currentPageCount={pages.length}
|
||||
mergeMode={mergeMode}
|
||||
mergeInsertAt={mergeInsertAt}
|
||||
isBusy={isBusy}
|
||||
canMerge={canMergeQueue}
|
||||
isLoading={mergeQueueIsLoading}
|
||||
hasErrors={mergeQueueHasErrors}
|
||||
onMergeModeChange={setMergeMode}
|
||||
onMergeInsertAtChange={setMergeInsertAt}
|
||||
onMoveUp={moveMergeQueueItemUp}
|
||||
onMoveDown={moveMergeQueueItemDown}
|
||||
onRemove={removeMergeQueueItem}
|
||||
onCancel={handleMergeCancel}
|
||||
onConfirm={handleMergeConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkspacePanel
|
||||
hasPdf={hasPdf}
|
||||
@@ -1125,105 +1187,6 @@ const App: React.FC = () => {
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
|
||||
{showMergeOptions && pendingFile && pdf && pages.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 {pages.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={pages.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, {pages.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>
|
||||
)}
|
||||
|
||||
<ReorderPanel
|
||||
pages={pages}
|
||||
thumbnails={reorderThumbnails}
|
||||
|
||||
Reference in New Issue
Block a user