merge files inital commit

This commit is contained in:
2026-05-23 15:02:40 +02:00
parent 8929080dc7
commit 3a0a90bd4a
16 changed files with 1232 additions and 190 deletions

View File

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

View File

@@ -3,14 +3,14 @@ import type { PdfFile } from '../pdf/pdfTypes';
interface FileLoaderProps {
pdf: PdfFile | null;
onFileLoaded: (file: File) => void;
onFilesLoaded: (files: File[]) => void;
}
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFilesLoaded }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFileLoaded(file);
const files = Array.from(e.target.files ?? []);
if (files.length > 0) {
onFilesLoaded(files);
e.target.value = '';
}
};
@@ -18,8 +18,16 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
return (
<div className="card">
<h2>1. Load PDF</h2>
<p>Select a PDF file. Processing happens entirely in your browser.</p>
<input type="file" accept="application/pdf" onChange={handleChange} />
<p>
Select one PDF to open it directly, or select several PDFs to place them
in the merge queue. Processing happens entirely in your browser.
</p>
<input
type="file"
accept="application/pdf"
multiple
onChange={handleChange}
/>
{pdf && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>

View File

@@ -34,7 +34,7 @@ const shortcuts = [
const tutorialSteps = [
{
title: '1. Open a PDF or load a workspace',
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.',
body: 'Start by selecting a local PDF file. Select several PDFs to open the merge queue, or restore a saved workspace from browser storage.',
},
{
title: '2. Arrange pages visually',
@@ -50,7 +50,7 @@ const tutorialSteps = [
},
{
title: '5. Split and download results',
body: 'Splitting creates individual one-page PDF downloads and a ZIP archive that contains all generated page files.',
body: 'Splitting creates individual one-page PDF downloads and a ZIP archive that contains all generated page files. For merging, review the incoming PDF queue, reorder it if needed, then replace, append, or insert the queued PDFs.',
},
{
title: '6. Save your workspace or export a PDF',
@@ -102,7 +102,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h2 id="help-dialog-title">Help & tutorial</h2>
<p>
PDF Workbench is a browser-only page workspace. Use it to quickly
rearrange, split, merge, rotate, duplicate, and export PDFs
rearrange, split, queue-merge, rotate, duplicate, and export PDFs
without uploading documents to a server.
</p>
</div>

View File

@@ -0,0 +1,232 @@
import React from 'react';
import type { MergeQueueItem, MergeMode } from '../merge/mergeTypes';
interface MergeQueuePanelProps {
items: MergeQueueItem[];
hasCurrentPdf: boolean;
currentPdfName: string | null;
currentPageCount: number;
mergeMode: MergeMode;
mergeInsertAt: string;
isBusy: boolean;
canMerge: boolean;
isLoading: boolean;
hasErrors: boolean;
onMergeModeChange: (mode: MergeMode) => void;
onMergeInsertAtChange: (value: string) => void;
onMoveUp: (itemId: string) => void;
onMoveDown: (itemId: string) => void;
onRemove: (itemId: string) => void;
onCancel: () => void;
onConfirm: () => void;
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const MergeQueuePanel: React.FC<MergeQueuePanelProps> = ({
items,
hasCurrentPdf,
currentPdfName,
currentPageCount,
mergeMode,
mergeInsertAt,
isBusy,
canMerge,
isLoading,
hasErrors,
onMergeModeChange,
onMergeInsertAtChange,
onMoveUp,
onMoveDown,
onRemove,
onCancel,
onConfirm,
}) => {
const queuedPageCount = items.reduce(
(sum, item) => sum + (item.pageCount ?? 0),
0
);
return (
<div
className="card merge-queue-card"
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
>
<h2>Merge PDF queue</h2>
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
Add several PDFs, reorder the queue, then merge them into a new unsaved
workspace. Processing still happens entirely in your browser.
</p>
{hasCurrentPdf ? (
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
Current workspace:{' '}
<strong>{currentPdfName ?? 'Untitled document'}</strong> with{' '}
{currentPageCount} {currentPageCount === 1 ? 'page' : 'pages'}.
</p>
) : (
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
No current workspace is open. The queue will become a new workspace.
</p>
)}
<div className="merge-queue-list" aria-label="PDF merge queue">
{items.map((item, index) => (
<div key={item.id} className="merge-queue-item">
<div className="merge-queue-order">#{index + 1}</div>
<div className="merge-queue-details">
<strong>{item.name}</strong>
<span>
{formatFileSize(item.size)} ·{' '}
{item.status === 'ready' && item.pageCount != null
? `${item.pageCount} ${item.pageCount === 1 ? 'page' : 'pages'}`
: item.status === 'loading'
? 'Loading…'
: (item.error ?? 'Error')}
</span>
</div>
<div className="merge-queue-actions">
<button
type="button"
className="secondary"
onClick={() => onMoveUp(item.id)}
disabled={isBusy || index === 0}
title="Move up"
>
</button>
<button
type="button"
className="secondary"
onClick={() => onMoveDown(item.id)}
disabled={isBusy || index === items.length - 1}
title="Move down"
>
</button>
<button
type="button"
className="secondary"
onClick={() => onRemove(item.id)}
disabled={isBusy}
title="Remove from queue"
>
Remove
</button>
</div>
</div>
))}
</div>
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
Queue total:{' '}
<strong>
{items.length} {items.length === 1 ? 'PDF' : 'PDFs'}
</strong>
{queuedPageCount > 0 && (
<>
{' '}
· {queuedPageCount} {queuedPageCount === 1 ? 'page' : 'pages'}
</>
)}
</p>
<div className="merge-mode-group">
<label>
<input
type="radio"
name="mergeMode"
value="overwrite"
checked={mergeMode === 'overwrite'}
onChange={() => onMergeModeChange('overwrite')}
disabled={isBusy}
/>
<span>
{hasCurrentPdf ? 'Replace current document' : 'Create from queue'}
</span>
</label>
<label>
<input
type="radio"
name="mergeMode"
value="append"
checked={mergeMode === 'append'}
onChange={() => onMergeModeChange('append')}
disabled={isBusy || !hasCurrentPdf}
/>
<span>Append queue after current workspace</span>
</label>
<label>
<input
type="radio"
name="mergeMode"
value="insertAt"
checked={mergeMode === 'insertAt'}
onChange={() => onMergeModeChange('insertAt')}
disabled={isBusy || !hasCurrentPdf}
/>
<span>
Insert queue starting at position{' '}
<input
type="number"
min={1}
max={currentPageCount + 1}
value={mergeInsertAt}
onChange={(e) => onMergeInsertAtChange(e.target.value)}
disabled={isBusy || !hasCurrentPdf || mergeMode !== 'insertAt'}
style={{
width: '4rem',
padding: '0.15rem 0.3rem',
fontSize: '0.85rem',
}}
/>{' '}
<span style={{ color: '#6b7280' }}>
(1 = before first page, {currentPageCount + 1} = after last page)
</span>
</span>
</label>
</div>
{mergeMode === 'overwrite' && hasCurrentPdf && (
<p className="merge-warning">
Replace mode discards the current in-memory workspace after the merge.
Save it first if you want to keep the current state separately.
</p>
)}
{hasErrors && (
<p className="merge-warning">
One or more queued files could not be loaded. Remove failed items
before merging.
</p>
)}
<div className="button-row" style={{ marginTop: '0.75rem' }}>
<button
className="secondary"
type="button"
onClick={onCancel}
disabled={isBusy}
>
Cancel
</button>
<button
className="primary"
type="button"
onClick={onConfirm}
disabled={isBusy || isLoading || !canMerge || hasErrors}
>
{isBusy ? 'Working…' : isLoading ? 'Loading PDFs…' : 'Merge queue'}
</button>
</div>
</div>
);
};
export default MergeQueuePanel;

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import type { MergeQueueItem } from './mergeTypes';
import {
canMergeQueue,
clampMergeInsertAt,
createMergedPdfName,
getReadyMergeQueuePdfs,
moveMergeQueueItem,
} from './mergeQueueHelpers';
function makeItem(
id: string,
status: MergeQueueItem['status']
): MergeQueueItem {
return {
id,
file: new File(['x'], `${id}.pdf`, { type: 'application/pdf' }),
name: `${id}.pdf`,
size: 1,
pageCount: status === 'ready' ? 1 : null,
pdf:
status === 'ready'
? {
id: `pdf-${id}`,
name: `${id}.pdf`,
// The helper tests never dereference the PDFDocument.
doc: {} as never,
pageCount: 1,
arrayBuffer: new ArrayBuffer(0),
}
: null,
status,
};
}
describe('merge queue helpers', () => {
it('moves queued items up and down without mutating the original array', () => {
const items = [
makeItem('a', 'ready'),
makeItem('b', 'ready'),
makeItem('c', 'ready'),
];
expect(moveMergeQueueItem(items, 'b', 'up').map((item) => item.id)).toEqual(
['b', 'a', 'c']
);
expect(
moveMergeQueueItem(items, 'b', 'down').map((item) => item.id)
).toEqual(['a', 'c', 'b']);
expect(items.map((item) => item.id)).toEqual(['a', 'b', 'c']);
});
it('only allows merging when every queued item is ready', () => {
const readyItems = [makeItem('a', 'ready'), makeItem('b', 'ready')];
const mixedItems = [makeItem('a', 'ready'), makeItem('b', 'loading')];
expect(canMergeQueue(readyItems)).toBe(true);
expect(getReadyMergeQueuePdfs(readyItems)).toHaveLength(2);
expect(canMergeQueue(mixedItems)).toBe(false);
expect(canMergeQueue([])).toBe(false);
});
it('clamps one-based merge positions to zero-based insert slots', () => {
expect(clampMergeInsertAt('1', 10)).toBe(0);
expect(clampMergeInsertAt('5', 10)).toBe(4);
expect(clampMergeInsertAt('99', 10)).toBe(10);
expect(clampMergeInsertAt('-10', 10)).toBe(0);
expect(clampMergeInsertAt('not-a-number', 10)).toBe(10);
});
it('creates readable merged PDF filenames', () => {
expect(createMergedPdfName('base.pdf', ['a.pdf', 'b.pdf'], 'append')).toBe(
'base_plus_2_pdfs.pdf'
);
expect(createMergedPdfName('base.pdf', ['a.pdf'], 'overwrite')).toBe(
'a_merged.pdf'
);
expect(createMergedPdfName(null, ['a.pdf', 'b.pdf'], 'overwrite')).toBe(
'merged_2_pdfs.pdf'
);
});
});

View File

@@ -0,0 +1,86 @@
import type { PdfFile } from '../pdf/pdfTypes';
import type { MergeMode, MergeQueueItem } from './mergeTypes';
export function createMergeQueueItemId(): string {
return `merge_${Math.random().toString(36).slice(2)}`;
}
export function moveMergeQueueItem(
items: MergeQueueItem[],
itemId: string,
direction: 'up' | 'down'
): MergeQueueItem[] {
const index = items.findIndex((item) => item.id === itemId);
if (index < 0) return items;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= items.length) return items;
const next = [...items];
const [item] = next.splice(index, 1);
next.splice(targetIndex, 0, item);
return next;
}
export function getReadyMergeQueuePdfs(items: MergeQueueItem[]): PdfFile[] {
return items
.filter((item) => item.status === 'ready' && item.pdf)
.map((item) => item.pdf as PdfFile);
}
export function canMergeQueue(items: MergeQueueItem[]): boolean {
return (
items.length > 0 &&
items.every((item) => item.status === 'ready' && item.pdf !== null)
);
}
export function hasMergeQueueErrors(items: MergeQueueItem[]): boolean {
return items.some((item) => item.status === 'error');
}
export function isMergeQueueLoading(items: MergeQueueItem[]): boolean {
return items.some((item) => item.status === 'loading');
}
export function clampMergeInsertAt(value: string, pageCount: number): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return pageCount;
return Math.min(Math.max(parsed - 1, 0), pageCount);
}
export function defaultMergeInsertPosition(pageCount: number): string {
return String(pageCount + 1);
}
export function createMergedPdfName(
currentPdfName: string | null,
incomingPdfNames: string[],
mode: MergeMode
): string {
const incomingBaseNames = incomingPdfNames.map(stripPdfExtension);
if (incomingBaseNames.length === 0) {
return currentPdfName ?? 'merged.pdf';
}
if (
incomingBaseNames.length === 1 &&
(!currentPdfName || mode === 'overwrite')
) {
return `${incomingBaseNames[0]}_merged.pdf`;
}
if (currentPdfName && mode !== 'overwrite') {
const currentBaseName = stripPdfExtension(currentPdfName);
return `${currentBaseName}_plus_${incomingBaseNames.length}_pdfs.pdf`;
}
return `merged_${incomingBaseNames.length}_pdfs.pdf`;
}
function stripPdfExtension(filename: string): string {
const base = filename.replace(/\.pdf$/i, '').trim();
return base || 'document';
}

16
src/merge/mergeTypes.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { PdfFile } from '../pdf/pdfTypes';
export type MergeMode = 'overwrite' | 'append' | 'insertAt';
export type MergeQueueItemStatus = 'loading' | 'ready' | 'error';
export interface MergeQueueItem {
id: string;
file: File;
name: string;
size: number;
pageCount: number | null;
pdf: PdfFile | null;
status: MergeQueueItemStatus;
error?: string;
}

102
src/merge/useMergeQueue.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useCallback, useState } from 'react';
import { loadPdfFromFile } from '../pdf/pdfService';
import {
canMergeQueue,
createMergeQueueItemId,
getReadyMergeQueuePdfs,
hasMergeQueueErrors,
isMergeQueueLoading,
moveMergeQueueItem,
} from './mergeQueueHelpers';
import type { MergeQueueItem } from './mergeTypes';
export function useMergeQueue() {
const [items, setItems] = useState<MergeQueueItem[]>([]);
const addFiles = useCallback((files: File[]) => {
const pdfFiles = files.filter(
(file) =>
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf')
);
if (pdfFiles.length === 0) return;
const queuedItems: MergeQueueItem[] = pdfFiles.map((file) => ({
id: createMergeQueueItemId(),
file,
name: file.name,
size: file.size,
pageCount: null,
pdf: null,
status: 'loading',
}));
setItems((current) => [...current, ...queuedItems]);
queuedItems.forEach((item) => {
void (async () => {
try {
const loadedPdf = await loadPdfFromFile(item.file);
setItems((current) =>
current.map((currentItem) =>
currentItem.id === item.id
? {
...currentItem,
pdf: loadedPdf,
pageCount: loadedPdf.pageCount,
status: 'ready',
error: undefined,
}
: currentItem
)
);
} catch (error) {
console.error(error);
setItems((current) =>
current.map((currentItem) =>
currentItem.id === item.id
? {
...currentItem,
status: 'error',
error: 'Could not load this PDF.',
}
: currentItem
)
);
}
})();
});
}, []);
const removeItem = useCallback((itemId: string) => {
setItems((current) => current.filter((item) => item.id !== itemId));
}, []);
const moveItemUp = useCallback((itemId: string) => {
setItems((current) => moveMergeQueueItem(current, itemId, 'up'));
}, []);
const moveItemDown = useCallback((itemId: string) => {
setItems((current) => moveMergeQueueItem(current, itemId, 'down'));
}, []);
const clearQueue = useCallback(() => {
setItems([]);
}, []);
return {
items,
addFiles,
removeItem,
moveItemUp,
moveItemDown,
clearQueue,
readyPdfs: getReadyMergeQueuePdfs(items),
canMerge: canMergeQueue(items),
hasErrors: hasMergeQueueErrors(items),
isLoading: isMergeQueueLoading(items),
};
}

View File

@@ -0,0 +1,57 @@
import { PDFDocument } from 'pdf-lib';
import { describe, expect, it } from 'vitest';
import type { PdfFile } from './pdfTypes';
import { mergePdfFilesAtPosition } from './pdfService';
async function makePdf(name: string, pageCount: number): Promise<PdfFile> {
const doc = await PDFDocument.create();
for (let i = 0; i < pageCount; i += 1) {
doc.addPage([100, 100]);
}
const bytes = await doc.save();
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(arrayBuffer).set(bytes);
return {
id: name,
name,
doc,
pageCount,
arrayBuffer,
};
}
describe('mergePdfFilesAtPosition', () => {
it('merges a queue without a current base PDF', async () => {
const first = await makePdf('first.pdf', 1);
const second = await makePdf('second.pdf', 2);
const merged = await mergePdfFilesAtPosition({
basePdf: null,
incomingPdfs: [first, second],
insertAt: 0,
name: 'merged.pdf',
});
expect(merged.name).toBe('merged.pdf');
expect(merged.pageCount).toBe(3);
expect(merged.doc.getPageCount()).toBe(3);
});
it('inserts queued PDFs into a current base PDF at the requested slot', async () => {
const base = await makePdf('base.pdf', 3);
const incoming = await makePdf('incoming.pdf', 2);
const merged = await mergePdfFilesAtPosition({
basePdf: base,
incomingPdfs: [incoming],
insertAt: 1,
name: 'base_plus_incoming.pdf',
});
expect(merged.name).toBe('base_plus_incoming.pdf');
expect(merged.pageCount).toBe(5);
expect(merged.doc.getPageCount()).toBe(5);
});
});

View File

@@ -77,6 +77,76 @@ export async function mergePdfFiles(
};
}
interface MergePdfFilesAtPositionOptions {
basePdf: PdfFile | null;
incomingPdfs: PdfFile[];
insertAt: number;
name: string;
}
export async function mergePdfFilesAtPosition({
basePdf,
incomingPdfs,
insertAt,
name,
}: MergePdfFilesAtPositionOptions): Promise<PdfFile> {
if (!basePdf && incomingPdfs.length === 0) {
throw new Error('At least one PDF is required for merging');
}
const mergedDoc = await PDFDocument.create();
const addAllPages = async (sourcePdf: PdfFile) => {
const sourceDoc =
sourcePdf.doc ?? (await PDFDocument.load(sourcePdf.arrayBuffer));
const pageCount = sourceDoc.getPageCount();
const pages = await mergedDoc.copyPages(
sourceDoc,
Array.from({ length: pageCount }, (_, i) => i)
);
pages.forEach((page) => mergedDoc.addPage(page));
};
if (!basePdf) {
for (const incomingPdf of incomingPdfs) {
await addAllPages(incomingPdf);
}
} else {
const baseDoc =
basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
const basePageCount = baseDoc.getPageCount();
const clampedInsertAt = Math.min(Math.max(insertAt, 0), basePageCount);
const basePages = await mergedDoc.copyPages(
baseDoc,
Array.from({ length: basePageCount }, (_, i) => i)
);
for (let i = 0; i < clampedInsertAt; i += 1) {
mergedDoc.addPage(basePages[i]);
}
for (const incomingPdf of incomingPdfs) {
await addAllPages(incomingPdf);
}
for (let i = clampedInsertAt; i < basePages.length; i += 1) {
mergedDoc.addPage(basePages[i]);
}
}
const bytes = await mergedDoc.save();
const buffer = pdfBytesToArrayBuffer(bytes);
return {
id: createId(),
name,
arrayBuffer: buffer,
pageCount: mergedDoc.getPageCount(),
doc: mergedDoc,
};
}
export async function splitIntoSinglePages(
pdf: PdfFile
): Promise<SplitResult[]> {

View File

@@ -398,3 +398,94 @@ button.secondary {
align-items: flex-start;
}
}
.merge-queue-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.75rem 0;
}
.merge-queue-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
border: 1px solid #bfdbfe;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
padding: 0.6rem;
}
.merge-queue-order {
border-radius: 999px;
background: #dbeafe;
color: #1e3a8a;
font-size: 0.8rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
}
.merge-queue-details {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
font-size: 0.9rem;
}
.merge-queue-details strong {
overflow-wrap: anywhere;
}
.merge-queue-details span {
color: #4b5563;
font-size: 0.8rem;
}
.merge-queue-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.35rem;
}
.merge-queue-actions button.secondary {
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
}
.merge-mode-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
font-size: 0.9rem;
}
.merge-mode-group label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.merge-warning {
margin: 0.75rem 0 0;
border: 1px solid #fed7aa;
border-radius: 0.5rem;
background: #fff7ed;
color: #9a3412;
padding: 0.55rem 0.65rem;
font-size: 0.85rem;
}
@media (max-width: 700px) {
.merge-queue-item {
grid-template-columns: 1fr;
align-items: stretch;
}
.merge-queue-actions {
justify-content: flex-start;
}
}

View File

@@ -1 +1 @@
export const APP_VERSION = '0.3.1';
export const APP_VERSION = '0.3.2';