v0.3.2 #3

Merged
zemion merged 7 commits from v0.3.2 into main 2026-06-06 11:52:23 +02:00
16 changed files with 1232 additions and 190 deletions
Showing only changes of commit 3a0a90bd4a - Show all commits

View File

@@ -4,6 +4,22 @@ All notable changes to `pdf-tools` are documented here.
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
## 0.3.2 — Multi-file merge queue release
### Added
- Added a multi-file merge queue for selecting, loading, reviewing, reordering, removing, and merging several incoming PDFs.
- Added queue merge modes for replacing the current document, appending after the current workspace, or inserting at a chosen one-based page position.
- Added merge queue helper tests for queue ordering, readiness checks, insert-position clamping, and merged filename generation.
- Added PDF merge service tests for queue-only and base-plus-incoming merge results.
### Changed
- Changed the file picker to accept multiple PDFs. A single file with no active workspace still opens directly; otherwise selected files are added to the merge queue.
- Replaced the old single-file merge card with a queue-based merge panel.
- Merging now creates a new unsaved workspace from the materialized merge result, preserving the current workspace state before append/insert merges.
- Bumped the app/package version to `0.3.2`.
## 0.3.1 — Split ZIP export release
### Added

327
README Normal file
View File

@@ -0,0 +1,327 @@
# pdf-tools / PDF Workbench
`pdf-tools` is a self-hostable, browser-only PDF workbench for fast page-level PDF operations. It is built for situations where uploading PDFs to a third-party service is undesirable, but users still need simple, visual tools to split, merge, reorder, rotate, duplicate, delete, extract, and export pages.
Current hosted version: <https://pdftools.add-ideas.de>
Current release: **v0.3.2 — Multi-file merge queue release**. See [`CHANGELOG.md`](CHANGELOG.md) for release notes and milestone history.
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
## Purpose
Many everyday PDF tasks are not full document-authoring tasks. They are page-workbench tasks:
- remove pages before sending a document;
- rotate scanned pages;
- split a PDF into single-page files and download them individually or as one ZIP archive;
- merge one or more PDFs into the current workspace;
- extract a subset of pages;
- reorder pages visually;
- keep a local working state while experimenting;
- undo and redo page operations before exporting.
`pdf-tools` focuses on this page-level workflow. It is intentionally not a full PDF editor, OCR solution, digital-signature workflow, DMS, or Adobe Acrobat replacement.
## Where the project comes from
The project started as a pragmatic, browser-only PDF helper for quick administrative and document-preparation tasks. The guiding idea is simple: many users need small PDF operations, but sensitive or internal documents should not have to leave the browser just to remove, rotate, split, or rearrange pages.
This makes the project especially useful for self-hosted environments, public-sector settings, universities, small organizations, and internal tools where operational simplicity and document locality matter.
## Distinguishing features
- **Browser-only processing**: PDF files are processed locally in the browser. The hosting server does not receive the selected PDFs.
- **Static self-hosting**: The production build can be served by any static web server or reverse proxy.
- **Visual page workspace**: Users work with page thumbnails, drag-and-drop ordering, selection, page preview, and page-level actions.
- **Named local workspaces**: Workspaces can be saved in the browser with the PDF binary and editing state stored in IndexedDB.
- **Undo/redo command history**: Workspace operations are recorded as commands with label, timestamp, and payload. The history view shows undo and redo states.
- **Progressive thumbnails**: Thumbnails are generated progressively so the UI becomes useful before all pages have finished rendering.
- **Thumbnail cache by page and rotation**: Rotated thumbnails are cached and only changed thumbnails need to be regenerated.
- **Stable page references**: Duplicated pages and reordered pages are tracked as workspace page references rather than only by original page number.
- **Multi-file merge queue**: Loading several PDFs opens a queue where users can review, reorder, remove, append, insert, or replace before merging.
- **In-app help**: The app includes a Help/Tutorial dialog with keyboard shortcuts and workflow explanations.
## Current features
### File and workspace handling
- Load a local PDF file.
- Save a named workspace in the browser.
- Restore saved workspaces from IndexedDB.
- Reset the active workspace, with a save prompt for unsaved changes.
- Delete saved workspaces after confirmation.
- Store workspace history and redo history.
### Page operations
- Generate page thumbnails in the browser.
- Reorder pages with drag and drop.
- Select individual pages.
- Select page ranges with Shift-click.
- Drag a selected page to move the whole selection.
- Duplicate/copy selected pages into a chosen position.
- Rotate pages clockwise and counter-clockwise.
- Delete one page or all selected pages after confirmation.
- Preview pages in a modal overlay.
- Flip through preview pages with buttons or arrow keys.
### Export tools
- Export the current reordered/rotated/duplicated/deleted workspace as a new PDF.
- Extract selected pages into a new PDF.
- Open selected pages as a new active workspace for continued editing.
- Split the source PDF into single-page PDFs.
- Download all split results as one ZIP archive.
- Merge several PDFs through a queue, then replace, append, or insert them into the current workspace.
### Keyboard shortcuts
| Shortcut | Action |
| -------------------------- | --------------------------------------------------- |
| `F1` / `?` | Open in-app help and tutorial |
| `Ctrl`/`⌘` + `A` | Select all pages |
| `Delete` / `Backspace` | Delete selected pages after confirmation |
| `Esc` | Clear the current selection or close an open dialog |
| `Ctrl`/`⌘` + `Z` | Undo |
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
| `Ctrl`/`⌘` + `Y` | Redo |
| `←` / `→` in preview | Move to previous / next page |
| `Esc` in preview | Close preview |
Keyboard shortcuts are ignored while typing in form fields.
## In-app documentation concept
The app includes a Help/Tutorial dialog reachable from the header via **Help ?**, `F1`, or `?`.
Recommended structure for in-app documentation:
1. **Quick tutorial**: short task-oriented steps from loading a PDF to exporting.
2. **Keyboard shortcuts**: a compact reference for power users.
3. **Concepts**: explain the difference between a PDF file, a workspace, command history, and exported output.
4. **Privacy model**: state clearly that processing is browser-local and workspaces are saved in local browser storage.
5. **Roadmap hints**: link users to the README or project repository for planned features, instead of overloading the app UI.
This keeps the app useful for first-time users without turning the main interface into a manual.
## Administrator notes
### Deployment model
`pdf-tools` is deployed as a static web application:
```text
browser <-- HTTPS --> static web server / reverse proxy --> built app assets
```
There is no application server to operate. Administrators only need to host the built files from `dist/`.
### Build and test from source
```bash
npm ci
npm run check
```
`npm run check` runs the main project quality gate:
```bash
npm run typecheck
npm run lint
npm run test
npm run build
```
The production build is written to `dist/`.
Useful individual development commands:
```bash
npm run dev # start the Vite development server
npm run preview # preview the production build locally
npm run test # run tests once
npm run test:watch # run tests in watch mode
npm run typecheck # run TypeScript without emitting files
npm run lint # run ESLint
npm run format # format the project with Prettier
npm run format:check # verify Prettier formatting
```
### Static hosting
Any static hosting setup should work, for example:
- nginx;
- Apache httpd;
- Caddy;
- Traefik in front of a static file container;
- GitLab Pages or another static publishing target;
- a minimal container serving `dist/`.
A typical nginx location for a single-page app looks like this:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
### Reverse proxy considerations
For production, serve the built app via HTTPS. The reverse proxy only sees requests for static app assets. It should not receive the user's PDF files, because the files are opened through browser APIs and processed locally.
For the Vite development server, `vite.config.ts` can restrict allowed hosts. The current development configuration includes `pdftools.add-ideas.de` as an allowed host for the dev server. This is not required for a normal production static build.
### Content Security Policy
A strict CSP is possible, but it must account for browser-local rendering details. Thumbnails use data URLs, downloads use blob URLs, and pdf.js uses a worker.
A starting point for testing could be:
```http
Content-Security-Policy: default-src 'self'; script-src 'self'; worker-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'none'
```
Adjust this to your hosting environment and test PDF loading, thumbnail rendering, preview, and downloads before enforcing it broadly.
### Storage and persistence
Saved workspaces are stored in the user's browser using IndexedDB. A workspace may include:
- the source PDF binary;
- workspace name and metadata;
- page order and duplicated page references;
- rotations;
- selected pages;
- undo and redo command history.
This has several operational consequences:
- Workspaces are local to the browser profile and device.
- Clearing browser data can delete saved workspaces.
- Server backups do not include user workspaces.
- Different users on different devices do not share workspaces through the server.
- Browser storage quotas apply.
### Privacy and logging
The server should only log asset requests for the app itself. It should not log PDF filenames or contents unless the hosting environment adds custom client-side telemetry or upload logic. Avoid adding analytics that could weaken the project's privacy model.
### Browser support
The app expects modern browser APIs, including:
- File API;
- Blob and object URLs;
- IndexedDB;
- Web Workers;
- Canvas;
- modern JavaScript modules.
Use current versions of Chromium, Firefox, Safari, or Edge.
## Versioning and release baseline
The application version shown in the header is defined in `src/version.ts`. The package version in `package.json` and the displayed app version should be kept in sync for releases.
The current development baseline is:
```text
v0.3.1 — Split ZIP export release
```
This release preserves the browser-only workspace baseline and adds split-result ZIP downloads on top of the selection-workspace feature. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are separated enough to support additional feature work without turning `App.tsx` back into a monolith.
## Project structure
```text
src/
App.tsx Main application orchestration and UI wiring
components/
ActionDialog.tsx Reusable confirmation/action dialog
ActionsPanel.tsx Export, extract, split, and ZIP download actions
FileLoader.tsx PDF file loading
HelpDialog.tsx In-app tutorial and shortcut reference
Layout.tsx Application shell/header
PagePreviewModal.tsx Large page preview with navigation
ReorderPanel.tsx Page grid, selection, drag/drop, copy/delete/rotate
WorkspacePanel.tsx Workspace save/load/reset and undo/redo history
pdf/
pdfService.ts pdf-lib operations: load, merge, split, export
pdfThumbnailService.ts pdf.js thumbnail rendering
pdfZipService.ts Browser-side ZIP packaging for split results
pdfTypes.ts PDF-related types
workspace/
workspaceCommands.ts Command model for undo/redo
workspaceDb.ts IndexedDB persistence
workspaceTypes.ts Workspace data model
styles.css Global styles
version.ts App version displayed in the header
```
## Roadmap
### Milestone 1: Fast preview and thumbnails
- [x] Remove unused legacy page list view.
- [x] Bound thumbnail generation by width and height.
- [x] Display thumbnails progressively.
- [x] Add preview page flipping.
- [x] Attach preview controls to the modal container.
- [x] Add first keyboard shortcuts.
- [x] Cache thumbnails by page and rotation.
- [x] Regenerate only changed rotated thumbnails.
- [x] Show software version number.
### Milestone 2: Real page workspace
- [x] Introduce stable page references instead of only original page indices.
- [x] Support duplicate selected pages.
- [x] Save and reload the last state from browser storage.
- [x] Support named workspaces.
- [x] Store PDF binaries directly in IndexedDB.
- [x] Reset workspace.
- [x] Add command history as a foundation for undo/redo.
- [x] Add undo/redo.
- [x] Display undo/redo history with redo entries visually separated.
- [x] Extract selection as a new active workspace.
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
- [ ] Add grid/list view toggle.
### Milestone 3: Better merge and mobile handling
- [x] Add a full multi-file merge queue.
- [ ] Support drag-and-drop of PDFs into the page grid at the hovered position.
- [ ] Add custom long-press drag on mobile.
- [ ] Consolidate frequently used actions into a toolbar.
### Milestone 4: Structural PDF editing
- [ ] Metadata editing.
- [ ] Crop pages.
- [ ] Add tools directly in the preview overlay.
- [ ] Read/fill/flatten forms.
- [ ] Read bookmarks, then evaluate bookmark editing.
- [ ] Read annotations, then evaluate annotation writing.
### Milestone 5: Export and power tools
- [ ] Basic text extraction.
- [x] ZIP export for split results.
- [ ] Optimize/compress MVP.
- [ ] Carefully scoped encrypted PDF handling.
## Non-goals for now
- Server-side PDF processing.
- Collaborative editing.
- User accounts.
- OCR.
- Full content-stream editing.
- Digital signature creation/validation workflows.
- DMS replacement functionality.
## License
GPL-3.0. See `LICENSE`.

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "pdf-tools",
"version": "0.3.1",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pdf-tools",
"version": "0.3.1",
"version": "0.3.2",
"dependencies": {
"fflate": "^0.8.3",
"pdf-lib": "^1.17.1",
@@ -117,7 +117,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -448,7 +447,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -497,7 +495,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1470,7 +1467,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -1517,7 +1515,6 @@
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -1528,7 +1525,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1539,7 +1535,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1589,7 +1584,6 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
@@ -1913,7 +1907,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1954,6 +1947,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -1964,6 +1958,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -1977,6 +1972,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2057,7 +2053,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2198,6 +2193,7 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -2217,7 +2213,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.357",
@@ -2275,7 +2272,6 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -2529,7 +2525,7 @@
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
@@ -2735,7 +2731,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
@@ -3135,6 +3130,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -3356,7 +3352,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3425,6 +3420,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -3449,7 +3445,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3459,7 +3454,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3472,7 +3466,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
@@ -3743,7 +3738,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3840,7 +3834,6 @@
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4164,7 +4157,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pdf-tools",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {

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';