Compare commits
22 Commits
c27efaec90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b34572a9 | |||
| b76886e3ec | |||
| 92dd3bcab3 | |||
| f22714c37f | |||
| 5cfbd48282 | |||
| 8425d44658 | |||
| d40a288a4e | |||
| bff9e059d8 | |||
| 5f017d3b22 | |||
| 6386326473 | |||
| 54d4dc9545 | |||
| 499db16fce | |||
| 0b975558d0 | |||
| c639864319 | |||
| 969ede47dd | |||
| 3a0a90bd4a | |||
| 8929080dc7 | |||
| 618a8fc86e | |||
| 07ef17fcaa | |||
| 91abda1744 | |||
| f0b0f678f1 | |||
| 4b0046a943 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -141,3 +141,7 @@ vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
todo.txt
|
||||
chatgpt_continuation.md5
|
||||
|
||||
# Generated release artifacts
|
||||
release/
|
||||
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
90
CHANGELOG.md
90
CHANGELOG.md
@@ -4,6 +4,96 @@ 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.4.4 — LocalToolBox organization migration
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved canonical repository, Toolbox schema, corresponding-source, and package-registry URLs from the personal `zemion` namespace to the `lotobo` LocalToolBox organization.
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.3` from the organization-owned registry.
|
||||
- Bumped the app/package version to `0.4.4`.
|
||||
|
||||
## 0.4.3 — Aligned Toolbox content width
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the PDF workspace's maximum content width from `900px` to `90rem` so it aligns with the shared Toolbox shell bar and central catalogue.
|
||||
- Bumped the app/package version to `0.4.3`.
|
||||
|
||||
## 0.4.2 — Corrected Toolbox header orientation
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.2`.
|
||||
- Moved the Toolbox icon and `add·ideas Toolbox` label to the fixed far-left block, kept the app title and one tagline centered, and moved the fixed control cluster to the far right in the reverse order: Help, the PDF-specific Gitea source link, Apps, and Personalize.
|
||||
- Bumped the app/package version to `0.4.2`.
|
||||
|
||||
## 0.4.1 — Stable Toolbox header layout
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.1`.
|
||||
- Adopted the stable shared top bar: the fixed left cluster contains Personalize, Apps, the PDF-specific Gitea source link, and Help; the app title and one tagline remain centered; and the fixed far-right block contains the Toolbox icon and `add·ideas Toolbox` label.
|
||||
|
||||
## 0.4.0 — Shared Toolbox header and themes
|
||||
|
||||
### Added
|
||||
|
||||
- Added the standard Toolbox header controls for in-app help, the app-specific Gitea source link, appearance personalization, and application switching.
|
||||
- Added explicit light, dark, and system-following themes for the PDF workspace, saved-workspace list, merge queue, dialogs, status messages, and controls.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.0`.
|
||||
- The header now uses the manifest's repository metadata for the source link instead of carrying a release-specific source action.
|
||||
- Replaced application color literals with semantic PDF workspace variables while intentionally keeping rendered PDF pages on a white paper surface.
|
||||
- Scoped application button and card styles to the shell's main content so they cannot override shared header controls.
|
||||
- Bumped the app/package version to `0.4.0`.
|
||||
|
||||
## 0.3.4 — PDF.js compatibility and Toolbox integration
|
||||
|
||||
### Added
|
||||
|
||||
- Added a generated Toolbox App Contract manifest and shared Toolbox Shell integration with standalone fallback.
|
||||
- Added deterministic, checksummed release ZIP packaging for artifact-based toolbox assembly.
|
||||
|
||||
### Changed
|
||||
|
||||
- Centralized PDF.js setup in a shared module so thumbnail rendering and page preview use the same legacy PDF.js build and worker configuration.
|
||||
- Added a local PDF.js worker wrapper that installs a `Uint8Array.prototype.toHex` compatibility shim before PDF.js worker code runs.
|
||||
- Added the same `toHex` compatibility shim in the main app bundle for browsers that do not provide it natively.
|
||||
- Made Vite asset URLs relative so the app can be hosted at arbitrary nested paths.
|
||||
- Corrected the documented licence identifier to `AGPL-3.0-only`.
|
||||
- Bumped the app/package version to `0.3.4`.
|
||||
|
||||
## 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
|
||||
|
||||
- Added browser-side ZIP packaging for split results via `src/pdf/pdfZipService.ts`.
|
||||
- Added a “Download all as ZIP” link next to the individual single-page split downloads.
|
||||
- Added tests for split-result ZIP creation, ZIP entry name sanitization/deduplication, ZIP filename generation, and empty-result handling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended generated-output handling so split downloads can include both individual page files and one ZIP archive, with object URL cleanup handled by `usePdfGeneratedOutputs`.
|
||||
- Bumped the app/package version to `0.3.1`.
|
||||
|
||||
## 0.3.0 — Selection workspace and maintenance release
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
Current hosted version: <https://pdftools.add-ideas.de>
|
||||
|
||||
Current release: **v0.3.0 — Selection workspace and maintenance release**. See [`CHANGELOG.md`](CHANGELOG.md) for release notes and milestone history.
|
||||
Current development release: **v0.4.4 — LocalToolBox organization migration**. See [`CHANGELOG.md`](CHANGELOG.md) for milestone history.
|
||||
|
||||
Active work is tracked in Gitea issues. Durable project context is mirrored into the Gitea wiki from repository docs, including [`ROADMAP.md`](ROADMAP.md).
|
||||
|
||||
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.
|
||||
|
||||
@@ -14,7 +16,7 @@ Many everyday PDF tasks are not full document-authoring tasks. They are page-wor
|
||||
|
||||
- remove pages before sending a document;
|
||||
- rotate scanned pages;
|
||||
- split a PDF into single-page files;
|
||||
- split a PDF into single-page files and download them individually or as one ZIP archive;
|
||||
- merge another PDF into the current document;
|
||||
- extract a subset of pages;
|
||||
- reorder pages visually;
|
||||
@@ -40,6 +42,8 @@ This makes the project especially useful for self-hosted environments, public-se
|
||||
- **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.
|
||||
- **Merge choices**: Loading another PDF can replace the current document, append pages, or insert pages at a chosen position.
|
||||
- **PDF.js compatibility setup**: PDF.js is configured through one shared module and a local worker wrapper so the app can polyfill `Uint8Array.prototype.toHex` before PDF.js runs in older browsers.
|
||||
- **Toolbox appearance controls**: The shared header provides light, dark, and system-following themes and keeps the app switcher, help, and source controls in the same place as the Toolbox portal.
|
||||
- **In-app help**: The app includes a Help/Tutorial dialog with keyboard shortcuts and workflow explanations.
|
||||
|
||||
## Current features
|
||||
@@ -72,6 +76,7 @@ This makes the project especially useful for self-hosted environments, public-se
|
||||
- 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 another PDF by replacing, appending, or inserting it into the current workspace.
|
||||
|
||||
### Keyboard shortcuts
|
||||
@@ -92,7 +97,7 @@ 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 `?`.
|
||||
The app includes a Help/Tutorial dialog reachable from the header's **?** control, `F1`, or `?`.
|
||||
|
||||
Recommended structure for in-app documentation:
|
||||
|
||||
@@ -116,6 +121,18 @@ 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/`.
|
||||
|
||||
The production build is relocatable and can also be assembled into an
|
||||
[Add Ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal) deployment.
|
||||
`toolbox-app.json` describes this app through the framework-independent
|
||||
[Toolbox App Contract](https://git.add-ideas.de/lotobo/toolbox-sdk). The same
|
||||
artifact remains fully usable on its own.
|
||||
|
||||
The shared first-party shell always provides help, source, and appearance
|
||||
controls. When launched with a same-origin catalogue URL, for example
|
||||
`?toolbox=/toolbox.catalog.json`, it also connects the application switcher to
|
||||
the current Toolbox catalogue. Missing, invalid, and cross-origin catalogues
|
||||
are ignored so the app falls back to standalone mode.
|
||||
|
||||
### Build and test from source
|
||||
|
||||
```bash
|
||||
@@ -134,6 +151,19 @@ npm run build
|
||||
|
||||
The production build is written to `dist/`.
|
||||
|
||||
Create a versioned, checksummed release artifact with:
|
||||
|
||||
```bash
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
This writes `release/pdf-tools-<version>.zip` and its `.sha256` file. The ZIP
|
||||
contains the static app, its toolbox manifest and changelog, plus the application
|
||||
source-code location, license, and the full license/notice texts found for
|
||||
bundled runtime dependencies under `LICENSES/`. Existing artifacts for the same
|
||||
version are never replaced implicitly; after intentionally reviewing a rebuild, use
|
||||
`npm run release:artifact -- --force`.
|
||||
|
||||
Useful individual development commands:
|
||||
|
||||
```bash
|
||||
@@ -218,7 +248,7 @@ The app expects modern browser APIs, including:
|
||||
- Canvas;
|
||||
- modern JavaScript modules.
|
||||
|
||||
Use current versions of Chromium, Firefox, Safari, or Edge.
|
||||
The app includes a small `Uint8Array.prototype.toHex` compatibility shim for PDF.js fingerprint handling in browsers that do not provide that typed-array helper natively. Use current versions of Chromium, Firefox, Safari, or Edge where possible.
|
||||
|
||||
## Versioning and release baseline
|
||||
|
||||
@@ -227,10 +257,10 @@ The application version shown in the header is defined in `src/version.ts`. The
|
||||
The current development baseline is:
|
||||
|
||||
```text
|
||||
v0.3.0 — Selection workspace and maintenance release
|
||||
v0.4.4 — LocalToolBox organization migration
|
||||
```
|
||||
|
||||
This release preserves the browser-only workspace baseline and adds the first post-refactor feature: opening selected pages as a new active workspace. 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.
|
||||
This branch preserves the browser-only workspace baseline and PDF.js compatibility setup while using Toolbox SDK `0.2.3` from the `lotobo` package registry. The displayed app/package version is `0.4.4`; canonical source, schema, and release links use the LocalToolBox organization. The shared header keeps the Toolbox icon and `add·ideas Toolbox` label in a fixed far-left block, the app title and one tagline centered, and Help, the PDF source link, Apps, and Personalize in a fixed far-right cluster. The PDF workspace shares the shell bar's `90rem` maximum width. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting remain separated enough to support additional feature work without turning `App.tsx` back into a monolith.
|
||||
|
||||
## Project structure
|
||||
|
||||
@@ -239,7 +269,7 @@ src/
|
||||
App.tsx Main application orchestration and UI wiring
|
||||
components/
|
||||
ActionDialog.tsx Reusable confirmation/action dialog
|
||||
ActionsPanel.tsx Export, extract, and split actions
|
||||
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
|
||||
@@ -247,8 +277,12 @@ src/
|
||||
ReorderPanel.tsx Page grid, selection, drag/drop, copy/delete/rotate
|
||||
WorkspacePanel.tsx Workspace save/load/reset and undo/redo history
|
||||
pdf/
|
||||
pdfJs.ts Shared pdf.js setup and worker configuration
|
||||
pdfjsWorker.ts Local pdf.js worker wrapper with compatibility shim
|
||||
uint8ArrayToHexPolyfill.ts Compatibility shim for PDF.js fingerprint handling
|
||||
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
|
||||
@@ -258,68 +292,10 @@ src/
|
||||
version.ts App version displayed in the header
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
## Roadmap and backlog
|
||||
|
||||
### 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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
The roadmap and non-goals now live in [`ROADMAP.md`](ROADMAP.md). Open roadmap items are imported into Gitea issues so priorities, status, and implementation notes stay out of the README.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0. See `LICENSE`.
|
||||
AGPL-3.0-only. See `LICENSE`.
|
||||
63
ROADMAP.md
Normal file
63
ROADMAP.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# pdf-tools Roadmap
|
||||
|
||||
Active roadmap items are tracked as Gitea issues. This file is kept as the source for issue import and wiki sync; completed items remain here as release context.
|
||||
|
||||
## 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.
|
||||
@@ -43,7 +43,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.config.{js,ts}', 'eslint.config.js'],
|
||||
files: ['*.config.{js,ts}', 'eslint.config.js', 'scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Self-hosted PDF Workbench</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="PDFTools" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
24
licenses/core-js-3.49.0-LICENSE.txt
Normal file
24
licenses/core-js-3.49.0-LICENSE.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
core-js 3.49.0 — MIT License
|
||||
Source: https://github.com/zloirock/core-js/tree/v3.49.0
|
||||
|
||||
Copyright (c) 2013–2025 Denis Pushkarev (zloirock.ru)
|
||||
|
||||
Copyright (c) 2025–2026 CoreJS Company (core-js.io)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
545
package-lock.json
generated
545
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "pdf-tools",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "npm run manifest:generate",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run manifest:generate",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -12,16 +15,24 @@
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run --environment jsdom",
|
||||
"test:toolbox": "toolbox-check dist",
|
||||
"test:watch": "vitest --environment jsdom",
|
||||
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
"release:artifact": "npm run check && node scripts/package-release.mjs",
|
||||
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run test && npm run build && npm run test:toolbox"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "^0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.3",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "^0.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -41,5 +52,13 @@
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
|
||||
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/pdf-tools.git"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"bugs": {
|
||||
"url": "https://git.add-ideas.de/lotobo/pdf-tools/issues"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="pdftools favicon" width="64" height="64"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><defs>
|
||||
<linearGradient id="pdf-bg" x1="10" y1="4" x2="54" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff4b3f"></stop>
|
||||
<stop offset="1" stop-color="#c91424"></stop>
|
||||
</linearGradient>
|
||||
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="1.25" flood-color="#7a0b12" flood-opacity="0.28"></feDropShadow>
|
||||
</filter>
|
||||
</defs><rect width="64" height="64" rx="14" fill="url(#pdf-bg)"></rect><g filter="url(#soft-shadow)">
|
||||
<path d="M19 11h22l9 9v31a4 4 0 0 1-4 4H19a4 4 0 0 1-4-4V15a4 4 0 0 1 4-4z" fill="#fff"></path>
|
||||
<path d="M41 11v9h9z" fill="#ffd9d6"></path>
|
||||
<path d="M23 24h19" stroke="#d41627" stroke-width="4" stroke-linecap="round"></path>
|
||||
<path d="M23 34h14" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.82"></path>
|
||||
<path d="M23 44h19" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.64"></path>
|
||||
</g><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
21
public/site.webmanifest
Normal file
21
public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "PDFTools",
|
||||
"short_name": "PDFTools",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
32
public/toolbox-app.json
Normal file
32
public/toolbox-app.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"name": "PDF Workbench",
|
||||
"description": "Page-level PDF operations performed locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "pdf"],
|
||||
"tags": ["merge", "split", "rotate", "reorder"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": true,
|
||||
"crossOriginIsolated": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
},
|
||||
"version": "0.4.4"
|
||||
}
|
||||
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
64
scripts/generate-toolbox-manifest.mjs
Normal file
64
scripts/generate-toolbox-manifest.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import { format } from 'prettier';
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const definitionPath = path.join(
|
||||
projectRoot,
|
||||
'src',
|
||||
'toolboxApp.definition.json'
|
||||
);
|
||||
const versionSourcePath = path.join(projectRoot, 'src', 'version.ts');
|
||||
const manifestPath = path.join(projectRoot, 'public', 'toolbox-app.json');
|
||||
|
||||
const [packageJson, definition, versionSource] = await Promise.all([
|
||||
readJson(packageJsonPath),
|
||||
readJson(definitionPath),
|
||||
readFile(versionSourcePath, 'utf8'),
|
||||
]);
|
||||
|
||||
const sourceVersion = versionSource.match(
|
||||
/APP_VERSION\s*=\s*['"]([^'"]+)['"]/
|
||||
)?.[1];
|
||||
|
||||
if (!sourceVersion) {
|
||||
throw new Error('Could not read APP_VERSION from src/version.ts.');
|
||||
}
|
||||
|
||||
if (sourceVersion !== packageJson.version) {
|
||||
throw new Error(
|
||||
`Version mismatch: package.json is ${packageJson.version}, while src/version.ts is ${sourceVersion}.`
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
...definition,
|
||||
version: packageJson.version,
|
||||
};
|
||||
parseToolboxApp(manifest);
|
||||
const output = await format(JSON.stringify(manifest), { parser: 'json' });
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
|
||||
if (existing !== output) {
|
||||
throw new Error(
|
||||
'public/toolbox-app.json is out of date. Run npm run manifest:generate.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await writeFile(manifestPath, output);
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
}
|
||||
100
scripts/package-release.mjs
Normal file
100
scripts/package-release.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
collectDirectoryFiles,
|
||||
createDeterministicZip,
|
||||
createThirdPartyLicenseBundle,
|
||||
} from './release-archive.mjs';
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(projectRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const releaseDirectory = path.join(projectRoot, 'release');
|
||||
const archiveName = `pdf-tools-${packageJson.version}.zip`;
|
||||
const archivePath = path.join(releaseDirectory, archiveName);
|
||||
const checksumPath = path.join(
|
||||
releaseDirectory,
|
||||
`pdf-tools-${packageJson.version}.sha256`
|
||||
);
|
||||
const force = process.argv.slice(2).includes('--force');
|
||||
const unknownArguments = process.argv
|
||||
.slice(2)
|
||||
.filter((argument) => argument !== '--force');
|
||||
if (unknownArguments.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
|
||||
}
|
||||
|
||||
await mkdir(releaseDirectory, { recursive: true });
|
||||
if (!force) {
|
||||
await assertDoesNotExist(archivePath);
|
||||
await assertDoesNotExist(checksumPath);
|
||||
}
|
||||
|
||||
const files = await collectDirectoryFiles(path.join(projectRoot, 'dist'));
|
||||
|
||||
files['CHANGELOG.md'] = await readFile(path.join(projectRoot, 'CHANGELOG.md'));
|
||||
files['SOURCE.md'] = Buffer.from(
|
||||
[
|
||||
'# Corresponding source',
|
||||
'',
|
||||
`The source code corresponding to PDF Tools ${packageJson.version} is available at:`,
|
||||
'',
|
||||
`https://git.add-ideas.de/lotobo/pdf-tools/src/tag/v${packageJson.version}`,
|
||||
'',
|
||||
'Third-party components remain available under the separate terms reproduced in LICENSES/.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
files['LICENSES/pdf-tools-LICENSE.txt'] = await readFile(
|
||||
path.join(projectRoot, 'LICENSE')
|
||||
);
|
||||
files['LICENSES/core-js-3.49.0-LICENSE.txt'] = await readFile(
|
||||
path.join(projectRoot, 'licenses', 'core-js-3.49.0-LICENSE.txt')
|
||||
);
|
||||
files['LICENSES/THIRD-PARTY-LICENSES.txt'] =
|
||||
await createThirdPartyLicenseBundle(projectRoot, [
|
||||
'@add-ideas/toolbox-contract',
|
||||
'@add-ideas/toolbox-shell-react',
|
||||
'@pdf-lib/standard-fonts',
|
||||
'@pdf-lib/upng',
|
||||
'fflate',
|
||||
'pako',
|
||||
'pdf-lib',
|
||||
'pdfjs-dist',
|
||||
'react',
|
||||
'react-dom',
|
||||
'scheduler',
|
||||
'tslib',
|
||||
]);
|
||||
|
||||
const archive = createDeterministicZip(files);
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
|
||||
const writeFlag = force ? 'w' : 'wx';
|
||||
await writeFile(archivePath, archive, { flag: writeFlag });
|
||||
await writeFile(checksumPath, `${checksum} ${archiveName}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: writeFlag,
|
||||
});
|
||||
|
||||
console.log(`Created release/${archiveName}`);
|
||||
console.log(`SHA-256 ${checksum}`);
|
||||
|
||||
async function assertDoesNotExist(outputPath) {
|
||||
try {
|
||||
await access(outputPath);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`${path.relative(projectRoot, outputPath)} already exists; pass --force to replace this exact version.`
|
||||
);
|
||||
}
|
||||
12
scripts/release-archive.d.ts
vendored
Normal file
12
scripts/release-archive.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
export function compareCodePoints(left: string, right: string): number;
|
||||
export function createDeterministicZip(
|
||||
files: Record<string, Uint8Array>
|
||||
): Uint8Array;
|
||||
export function collectDirectoryFiles(
|
||||
directory: string,
|
||||
prefix?: string
|
||||
): Promise<Record<string, Buffer>>;
|
||||
export function createThirdPartyLicenseBundle(
|
||||
projectRoot: string,
|
||||
packageNames: string[]
|
||||
): Promise<Buffer>;
|
||||
123
scripts/release-archive.mjs
Normal file
123
scripts/release-archive.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
|
||||
|
||||
export function compareCodePoints(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function createDeterministicZip(files) {
|
||||
const orderedFiles = Object.fromEntries(
|
||||
Object.entries(files).sort(([left], [right]) =>
|
||||
compareCodePoints(left, right)
|
||||
)
|
||||
);
|
||||
return zipSync(orderedFiles, {
|
||||
level: 9,
|
||||
// ZIP stores a wall-clock DOS timestamp. Constructing the epoch in local
|
||||
// time keeps its encoded bytes stable in every process timezone.
|
||||
mtime: new Date(1980, 0, 1, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectDirectoryFiles(directory, prefix = '') {
|
||||
const result = {};
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const relativePath = path.posix.join(prefix, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
Object.assign(
|
||||
result,
|
||||
await collectDirectoryFiles(absolutePath, relativePath)
|
||||
);
|
||||
} else if (entry.isFile()) {
|
||||
result[relativePath] = await readFile(absolutePath);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported filesystem entry in release input: ${relativePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createThirdPartyLicenseBundle(projectRoot, packageNames) {
|
||||
const sections = [];
|
||||
|
||||
for (const packageName of [...new Set(packageNames)].sort(
|
||||
compareCodePoints
|
||||
)) {
|
||||
const packageDirectory = path.join(
|
||||
projectRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/')
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, 'package.json'), 'utf8')
|
||||
);
|
||||
const licenseFiles = await collectLicenseFiles(packageDirectory);
|
||||
if (licenseFiles.length === 0) {
|
||||
throw new Error(`No license text found for ${packageName}.`);
|
||||
}
|
||||
|
||||
const contents = [];
|
||||
for (const licenseFile of licenseFiles) {
|
||||
const text = await readFile(
|
||||
path.join(packageDirectory, ...licenseFile.split('/')),
|
||||
'utf8'
|
||||
);
|
||||
contents.push(
|
||||
`--- ${licenseFile} ---\n${text.replaceAll('\r\n', '\n').trim()}\n`
|
||||
);
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
`================================================================================`,
|
||||
`${packageJson.name ?? packageName}@${packageJson.version ?? 'unknown'}`,
|
||||
`Declared license: ${String(packageJson.license ?? 'unspecified')}`,
|
||||
`================================================================================`,
|
||||
...contents,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
[
|
||||
'Third-party software license texts bundled with PDF Tools',
|
||||
'Generated deterministically from the installed production packages.',
|
||||
'',
|
||||
...sections,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function collectLicenseFiles(directory, prefix = '') {
|
||||
const matches = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const relativePath = path.posix.join(prefix, entry.name);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
matches.push(...(await collectLicenseFiles(absolutePath, relativePath)));
|
||||
} else if (entry.isFile() && LICENSE_FILE.test(entry.name)) {
|
||||
matches.push(relativePath);
|
||||
} else if (!entry.isFile()) {
|
||||
throw new Error(
|
||||
`Unsupported filesystem entry in license input: ${relativePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
283
src/App.tsx
283
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Layout from './components/Layout';
|
||||
import { AppShell } from '@add-ideas/toolbox-shell-react';
|
||||
import FileLoader from './components/FileLoader';
|
||||
import ReorderPanel from './components/ReorderPanel';
|
||||
import ActionsPanel from './components/ActionsPanel';
|
||||
@@ -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,17 +33,29 @@ import {
|
||||
import type { PageRef, PdfFile } from './pdf/pdfTypes';
|
||||
import {
|
||||
loadPdfFromFile,
|
||||
mergePdfFiles,
|
||||
mergePdfFilesAtPosition,
|
||||
splitIntoSinglePages,
|
||||
exportPages,
|
||||
} from './pdf/pdfService';
|
||||
import { usePdfThumbnails } from './pdf/usePdfThumbnails';
|
||||
import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
|
||||
import {
|
||||
createSplitResultsZip,
|
||||
createSplitZipFilename,
|
||||
} from './pdf/pdfZipService';
|
||||
import { toolboxApp } from './toolboxApp';
|
||||
import {
|
||||
createSelectionPdfName,
|
||||
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;
|
||||
@@ -76,15 +89,26 @@ 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,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
replaceSplitResults,
|
||||
@@ -417,41 +441,63 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileLoaded = (file: File) => {
|
||||
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) {
|
||||
void loadFileAsNew(file);
|
||||
setMergeMode('overwrite');
|
||||
setMergeInsertAt('1');
|
||||
} else {
|
||||
setPendingFile(file);
|
||||
setShowMergeOptions(true);
|
||||
setMergeMode('append');
|
||||
setMergeInsertAt(String(pages.length + 1));
|
||||
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)
|
||||
let basePdf: PdfFile | null = null;
|
||||
let insertAt = 0;
|
||||
|
||||
if (pdf && pages.length > 0 && mergeMode !== 'overwrite') {
|
||||
const currentBlob = await exportPages(pdf, pages);
|
||||
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
||||
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
||||
const currentPdf: PdfFile = {
|
||||
|
||||
basePdf = {
|
||||
id: pdf.id,
|
||||
name: pdf.name,
|
||||
doc: currentDoc,
|
||||
@@ -459,47 +505,47 @@ const App: React.FC = () => {
|
||||
pageCount: pages.length,
|
||||
};
|
||||
|
||||
// 2) Load the new PDF
|
||||
const newPdf = await loadPdfFromFile(pendingFile);
|
||||
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -916,7 +962,11 @@ const App: React.FC = () => {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const result = await splitIntoSinglePages(pdf);
|
||||
replaceSplitResults(result);
|
||||
const zipBlob = await createSplitResultsZip(result);
|
||||
replaceSplitResults(result, {
|
||||
blob: zipBlob,
|
||||
filename: createSplitZipFilename(pdf.name),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Error while splitting PDF (see console).');
|
||||
@@ -1090,8 +1140,37 @@ const App: React.FC = () => {
|
||||
previewVisualIndex >= 0 && previewVisualIndex < pages.length - 1;
|
||||
|
||||
return (
|
||||
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
<AppShell
|
||||
app={toolboxApp}
|
||||
className="pdf-toolbox-shell"
|
||||
helpAction={{
|
||||
onClick: () => setHelpOpen(true),
|
||||
title: 'Open help and keyboard shortcuts (F1 or ?)',
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
@@ -1116,105 +1195,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}
|
||||
@@ -1242,6 +1222,7 @@ const App: React.FC = () => {
|
||||
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
|
||||
onExportReordered={handleExportReordered}
|
||||
splitDownloads={splitDownloads}
|
||||
splitZipDownload={splitZipDownload}
|
||||
subsetDownload={subsetDownload}
|
||||
exportDownload={exportDownload}
|
||||
/>
|
||||
@@ -1249,7 +1230,11 @@ const App: React.FC = () => {
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ border: '1px solid #fecaca', background: '#fef2f2' }}
|
||||
style={{
|
||||
border: '1px solid var(--pdf-danger-border)',
|
||||
background: 'var(--pdf-danger-surface)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
}}
|
||||
>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
@@ -1279,7 +1264,7 @@ const App: React.FC = () => {
|
||||
</ActionDialog>
|
||||
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</Layout>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,18 +21,18 @@ const backgroundByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
string
|
||||
> = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#e5e7eb',
|
||||
danger: '#dc2626',
|
||||
primary: 'var(--pdf-primary)',
|
||||
secondary: 'var(--pdf-control)',
|
||||
danger: 'var(--pdf-danger-text)',
|
||||
};
|
||||
|
||||
const colorByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
string
|
||||
> = {
|
||||
primary: 'white',
|
||||
secondary: '#111827',
|
||||
danger: 'white',
|
||||
primary: 'var(--pdf-on-primary)',
|
||||
secondary: 'var(--pdf-control-text)',
|
||||
danger: 'var(--pdf-on-primary)',
|
||||
};
|
||||
|
||||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
@@ -75,7 +75,7 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
background: 'var(--pdf-overlay)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -86,9 +86,10 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
background: 'white',
|
||||
background: 'var(--pdf-surface-raised)',
|
||||
color: 'var(--pdf-text)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
boxShadow: 'var(--pdf-dialog-shadow)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -121,8 +122,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
@@ -139,7 +140,7 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
color: 'var(--pdf-muted)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
@@ -173,9 +174,11 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: action.disabled
|
||||
? '#e5e7eb'
|
||||
? 'var(--pdf-control)'
|
||||
: backgroundByVariant[variant],
|
||||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
||||
color: action.disabled
|
||||
? 'var(--pdf-muted-soft)'
|
||||
: colorByVariant[variant],
|
||||
cursor: action.disabled ? 'default' : 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ActionsPanelProps {
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitDownloads: SplitPdfDownload[];
|
||||
splitZipDownload: PdfDownload | null;
|
||||
subsetDownload: PdfDownload | null;
|
||||
exportDownload: PdfDownload | null;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
onOpenSelectionAsWorkspace,
|
||||
onExportReordered,
|
||||
splitDownloads,
|
||||
splitZipDownload,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
}) => {
|
||||
@@ -47,7 +49,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Tools</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Use these tools on the current in-memory document (reordered, rotated,
|
||||
with deletions). Nothing is uploaded to a server.
|
||||
</p>
|
||||
@@ -132,7 +134,18 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
{splitDownloads.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<strong>Single-page PDFs:</strong>
|
||||
<div>
|
||||
{splitZipDownload && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
<a
|
||||
className="download-link"
|
||||
href={splitZipDownload.url}
|
||||
download={splitZipDownload.filename}
|
||||
>
|
||||
Download all as ZIP ({splitDownloads.length} files)
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
{splitDownloads.map((download) => (
|
||||
<a
|
||||
key={download.id}
|
||||
|
||||
@@ -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,11 +18,25 @@ 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' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--pdf-muted)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Loaded:</strong> {pdf.name}
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
@@ -49,11 +49,15 @@ const tutorialSteps = [
|
||||
body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
|
||||
},
|
||||
{
|
||||
title: '5. Save your workspace or export a PDF',
|
||||
title: '5. Split and download results',
|
||||
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',
|
||||
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
||||
},
|
||||
{
|
||||
title: '6. Use history deliberately',
|
||||
title: '7. Use history deliberately',
|
||||
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
||||
},
|
||||
];
|
||||
@@ -98,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>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
<div className="app-header-content">
|
||||
<div className="app-header-title">
|
||||
<span className="app-logo">📄</span>
|
||||
<div>
|
||||
<h1>PDF Workbench</h1>
|
||||
<small>All in your browser</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={onOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1 or ?)"
|
||||
>
|
||||
Help <span aria-hidden="true">?</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
235
src/components/MergeQueuePanel.tsx
Normal file
235
src/components/MergeQueuePanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
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 var(--pdf-info-border)',
|
||||
background: 'var(--pdf-info-surface)',
|
||||
}}
|
||||
>
|
||||
<h2>Merge PDF queue</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
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: 'var(--pdf-info-text)' }}>
|
||||
Current workspace:{' '}
|
||||
<strong>{currentPdfName ?? 'Untitled document'}</strong> with{' '}
|
||||
{currentPageCount} {currentPageCount === 1 ? 'page' : 'pages'}.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
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: 'var(--pdf-info-text)' }}>
|
||||
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: 'var(--pdf-muted-soft)' }}>
|
||||
(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;
|
||||
@@ -1,11 +1,6 @@
|
||||
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;
|
||||
import { pdfjsLib } from '../pdf/pdfJs';
|
||||
|
||||
interface PagePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -48,7 +48,7 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
background: 'var(--pdf-overlay)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -60,9 +60,10 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
background: 'white',
|
||||
background: 'var(--pdf-surface-raised)',
|
||||
color: 'var(--pdf-text)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
boxShadow: 'var(--pdf-dialog-shadow)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -95,8 +96,8 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
@@ -114,7 +115,7 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
color: 'var(--pdf-muted)',
|
||||
}}
|
||||
>
|
||||
Copy{' '}
|
||||
@@ -145,7 +146,7 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
style={{
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
border: '1px solid var(--pdf-border-strong)',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
/>
|
||||
@@ -154,7 +155,7 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#6b7280',
|
||||
color: 'var(--pdf-muted-soft)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
@@ -166,9 +167,9 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#b91c1c',
|
||||
background: 'var(--pdf-danger-surface)',
|
||||
border: '1px solid var(--pdf-danger-border)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
@@ -192,8 +193,8 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
@@ -207,8 +208,8 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#16a34a',
|
||||
color: 'white',
|
||||
background: 'var(--pdf-success-text)',
|
||||
color: 'var(--pdf-on-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
|
||||
@@ -53,11 +53,11 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
}) => {
|
||||
const background = isDraggingCard
|
||||
? isCopyDragging
|
||||
? '#dcfce7'
|
||||
: '#dbeafe'
|
||||
? 'var(--pdf-success-soft)'
|
||||
: 'var(--pdf-info-surface)'
|
||||
: selected
|
||||
? '#eff6ff'
|
||||
: '#f9fafb';
|
||||
? 'var(--pdf-info-surface)'
|
||||
: 'var(--pdf-surface-soft)';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,7 +71,7 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
width: '162px',
|
||||
padding: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
border: '1px solid var(--pdf-border)',
|
||||
background,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -91,9 +91,11 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '0.4rem',
|
||||
border: '1px solid #9ca3af',
|
||||
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
|
||||
color: selected ? 'white' : 'transparent',
|
||||
border: '1px solid var(--pdf-border-strong)',
|
||||
background: selected
|
||||
? 'var(--pdf-primary)'
|
||||
: 'color-mix(in srgb, var(--pdf-surface) 92%, transparent)',
|
||||
color: selected ? 'var(--pdf-on-primary)' : 'transparent',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -131,8 +133,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
border: '1px solid var(--pdf-paper-border)',
|
||||
background: 'var(--pdf-paper)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -141,8 +143,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed #d1d5db',
|
||||
background: '#f3f4f6',
|
||||
border: '1px dashed var(--pdf-paper-border)',
|
||||
background: 'var(--pdf-paper-placeholder)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -151,7 +153,7 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
<span style={{ fontSize: '0.8rem' }}>
|
||||
Page {page.sourcePageIndex + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Pos {visualIndex + 1} · Rot {page.rotation}°
|
||||
</span>
|
||||
|
||||
@@ -170,7 +172,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: '#e5e7eb',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
@@ -184,7 +187,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: '#e5e7eb',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
@@ -198,8 +202,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: '#fecaca',
|
||||
color: '#b91c1c',
|
||||
background: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
}}
|
||||
title="Remove this page from the exported PDF"
|
||||
>
|
||||
|
||||
@@ -53,8 +53,8 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||
disabled={!hasSelection}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
background: 'var(--pdf-success-soft)',
|
||||
color: 'var(--pdf-success-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Copy selected pages to another position"
|
||||
@@ -69,8 +69,8 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: '#fee2e2',
|
||||
color: '#b91c1c',
|
||||
background: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -83,8 +83,8 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: '#8dcd8d',
|
||||
color: '#111827',
|
||||
background: 'var(--pdf-success-border)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -97,8 +97,10 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||
disabled={!hasSelection}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: '#e5e7eb',
|
||||
color: hasSelection ? '#111827' : '#6b7280',
|
||||
background: 'var(--pdf-control)',
|
||||
color: hasSelection
|
||||
? 'var(--pdf-control-text)'
|
||||
: 'var(--pdf-muted-soft)',
|
||||
cursor: hasSelection ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -279,13 +279,15 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
draggingPage != null &&
|
||||
selectedPageIds.length > 0 &&
|
||||
selectedPageIds.includes(draggingPage.id);
|
||||
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
||||
const dropIndicatorColor = isCopyDragging
|
||||
? 'var(--pdf-success-text)'
|
||||
: 'var(--pdf-primary)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Pages</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Tap/click a page to preview it. Use the checkbox to select pages
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||
|
||||
@@ -54,7 +54,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
@@ -78,7 +78,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
minWidth: 0,
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
border: '1px solid var(--pdf-border-strong)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
@@ -140,7 +140,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: '#92400e',
|
||||
color: 'var(--pdf-warning-text)',
|
||||
}}
|
||||
>
|
||||
Unsaved workspace changes.
|
||||
@@ -152,7 +152,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#166534',
|
||||
color: 'var(--pdf-success-text)',
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
@@ -178,10 +178,12 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
border: '1px solid var(--pdf-border)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
background: active ? '#eff6ff' : '#f9fafb',
|
||||
background: active
|
||||
? 'var(--pdf-info-surface)'
|
||||
: 'var(--pdf-surface-soft)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
@@ -193,11 +195,19 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: '#2563eb' }}> · active</span>
|
||||
<span style={{ color: 'var(--pdf-primary)' }}>
|
||||
{' '}
|
||||
· active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--pdf-muted-soft)',
|
||||
}}
|
||||
>
|
||||
{workspace.pdfName} · source pages:{' '}
|
||||
{workspace.sourcePageCount} · workspace pages:{' '}
|
||||
{workspace.workspacePageCount} · undo:{' '}
|
||||
@@ -228,8 +238,8 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
background: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
@@ -261,8 +271,8 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#374151',
|
||||
borderLeft: '3px solid #2563eb',
|
||||
color: 'var(--pdf-muted)',
|
||||
borderLeft: '3px solid var(--pdf-primary)',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
@@ -272,7 +282,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
<span style={{ color: 'var(--pdf-muted-soft)' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -282,12 +292,12 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
borderRadius: '999px',
|
||||
background: '#ecfdf5',
|
||||
color: '#166534',
|
||||
background: 'var(--pdf-success-surface)',
|
||||
color: 'var(--pdf-success-text)',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
alignSelf: 'flex-start',
|
||||
border: '2px solid #166534',
|
||||
border: '2px solid var(--pdf-success-text)',
|
||||
width: '100%',
|
||||
}}
|
||||
></div>
|
||||
@@ -300,8 +310,8 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#9ca3af',
|
||||
borderLeft: '3px solid #d1d5db',
|
||||
color: 'var(--pdf-muted-faint)',
|
||||
borderLeft: '3px solid var(--pdf-border-strong)',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
@@ -312,7 +322,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
Redo {index + 1}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#9ca3af' }}>
|
||||
<span style={{ color: 'var(--pdf-muted-faint)' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface SplitPdfDownload extends PdfDownload {
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
export interface PdfBlobResult {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
function revokeDownload(download: PdfDownload | null): void {
|
||||
if (download) {
|
||||
URL.revokeObjectURL(download.url);
|
||||
@@ -37,12 +42,17 @@ export function usePdfGeneratedOutputs() {
|
||||
const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
|
||||
null
|
||||
);
|
||||
const [splitZipDownload, setSplitZipDownload] = useState<PdfDownload | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
|
||||
const subsetDownloadRef = useRef<PdfDownload | null>(null);
|
||||
const exportDownloadRef = useRef<PdfDownload | null>(null);
|
||||
const splitZipDownloadRef = useRef<PdfDownload | null>(null);
|
||||
|
||||
const replaceSplitResults = useCallback((results: SplitResult[]) => {
|
||||
const replaceSplitResults = useCallback(
|
||||
(results: SplitResult[], zipResult?: PdfBlobResult) => {
|
||||
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
|
||||
...createDownload(
|
||||
`split-${result.pageIndex}-${result.filename}`,
|
||||
@@ -52,15 +62,31 @@ export function usePdfGeneratedOutputs() {
|
||||
pageIndex: result.pageIndex,
|
||||
}));
|
||||
|
||||
const nextZipDownload = zipResult
|
||||
? createDownload('split-zip', zipResult.filename, zipResult.blob)
|
||||
: null;
|
||||
|
||||
revokeDownloads(splitDownloadsRef.current);
|
||||
revokeDownload(splitZipDownloadRef.current);
|
||||
|
||||
splitDownloadsRef.current = nextDownloads;
|
||||
splitZipDownloadRef.current = nextZipDownload;
|
||||
|
||||
setSplitDownloads(nextDownloads);
|
||||
}, []);
|
||||
setSplitZipDownload(nextZipDownload);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearSplitResults = useCallback(() => {
|
||||
revokeDownloads(splitDownloadsRef.current);
|
||||
revokeDownload(splitZipDownloadRef.current);
|
||||
|
||||
splitDownloadsRef.current = [];
|
||||
splitZipDownloadRef.current = null;
|
||||
|
||||
setSplitDownloads([]);
|
||||
setSplitZipDownload(null);
|
||||
}, []);
|
||||
|
||||
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
|
||||
@@ -95,14 +121,17 @@ export function usePdfGeneratedOutputs() {
|
||||
revokeDownloads(splitDownloadsRef.current);
|
||||
revokeDownload(subsetDownloadRef.current);
|
||||
revokeDownload(exportDownloadRef.current);
|
||||
revokeDownload(splitZipDownloadRef.current);
|
||||
|
||||
splitDownloadsRef.current = [];
|
||||
subsetDownloadRef.current = null;
|
||||
exportDownloadRef.current = null;
|
||||
splitZipDownloadRef.current = null;
|
||||
|
||||
setSplitDownloads([]);
|
||||
setSubsetDownload(null);
|
||||
setExportDownload(null);
|
||||
setSplitZipDownload(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,6 +139,7 @@ export function usePdfGeneratedOutputs() {
|
||||
revokeDownloads(splitDownloadsRef.current);
|
||||
revokeDownload(subsetDownloadRef.current);
|
||||
revokeDownload(exportDownloadRef.current);
|
||||
revokeDownload(splitZipDownloadRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -117,6 +147,7 @@ export function usePdfGeneratedOutputs() {
|
||||
splitDownloads,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
splitZipDownload,
|
||||
replaceSplitResults,
|
||||
clearSplitResults,
|
||||
replaceSubsetResult,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
||||
82
src/merge/mergeQueueHelpers.test.ts
Normal file
82
src/merge/mergeQueueHelpers.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
86
src/merge/mergeQueueHelpers.ts
Normal file
86
src/merge/mergeQueueHelpers.ts
Normal 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
16
src/merge/mergeTypes.ts
Normal 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
102
src/merge/useMergeQueue.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
9
src/pdf/pdfJs.ts
Normal file
9
src/pdf/pdfJs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import './uint8ArrayToHexPolyfill';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pdfjsWorker from './pdfjsWorker?worker&url';
|
||||
|
||||
// pdf.js worker setup for Vite.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
export { pdfjsLib };
|
||||
57
src/pdf/pdfMergeService.test.ts
Normal file
57
src/pdf/pdfMergeService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
|
||||
// pdf.js worker setup for Vite
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
import { pdfjsLib } from './pdfJs';
|
||||
|
||||
type RotationsMap = Record<number, number>; // key: 0-based page index, value: degrees
|
||||
|
||||
|
||||
86
src/pdf/pdfZipService.test.ts
Normal file
86
src/pdf/pdfZipService.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { unzipSync } from 'fflate';
|
||||
import { createSplitResultsZip, createSplitZipFilename } from './pdfZipService';
|
||||
import type { SplitResult } from './pdfTypes';
|
||||
|
||||
async function unzipBlob(blob: Blob): Promise<Record<string, Uint8Array>> {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
return unzipSync(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
|
||||
describe('pdfZipService', () => {
|
||||
it('creates a ZIP archive from split PDF blobs', async () => {
|
||||
const results: SplitResult[] = [
|
||||
{
|
||||
pageIndex: 0,
|
||||
filename: 'document_page_001.pdf',
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
},
|
||||
{
|
||||
pageIndex: 1,
|
||||
filename: 'document_page_002.pdf',
|
||||
blob: new Blob([new Uint8Array([4, 5, 6])], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const zipBlob = await createSplitResultsZip(results);
|
||||
const entries = await unzipBlob(zipBlob);
|
||||
|
||||
expect(zipBlob.type).toBe('application/zip');
|
||||
expect(Object.keys(entries)).toEqual([
|
||||
'document_page_001.pdf',
|
||||
'document_page_002.pdf',
|
||||
]);
|
||||
expect(Array.from(entries['document_page_001.pdf'])).toEqual([1, 2, 3]);
|
||||
expect(Array.from(entries['document_page_002.pdf'])).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it('sanitizes and deduplicates ZIP entry names', async () => {
|
||||
const results: SplitResult[] = [
|
||||
{
|
||||
pageIndex: 0,
|
||||
filename: '../page.pdf',
|
||||
blob: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
|
||||
},
|
||||
{
|
||||
pageIndex: 1,
|
||||
filename: '../page.pdf',
|
||||
blob: new Blob([new Uint8Array([2])], { type: 'application/pdf' }),
|
||||
},
|
||||
{
|
||||
pageIndex: 2,
|
||||
filename: '',
|
||||
blob: new Blob([new Uint8Array([3])], { type: 'application/pdf' }),
|
||||
},
|
||||
];
|
||||
|
||||
const zipBlob = await createSplitResultsZip(results);
|
||||
const entries = await unzipBlob(zipBlob);
|
||||
|
||||
expect(Object.keys(entries)).toEqual([
|
||||
'.._page.pdf',
|
||||
'.._page_2.pdf',
|
||||
'page_003.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a readable ZIP filename from the source PDF name', () => {
|
||||
expect(createSplitZipFilename('contract.pdf')).toBe(
|
||||
'contract_split_pages.zip'
|
||||
);
|
||||
expect(createSplitZipFilename('contract.final.PDF')).toBe(
|
||||
'contract.final_split_pages.zip'
|
||||
);
|
||||
expect(createSplitZipFilename('')).toBe('document_split_pages.zip');
|
||||
});
|
||||
|
||||
it('rejects empty split results', async () => {
|
||||
await expect(createSplitResultsZip([])).rejects.toThrow(
|
||||
'Cannot create a ZIP archive without split results.'
|
||||
);
|
||||
});
|
||||
});
|
||||
78
src/pdf/pdfZipService.ts
Normal file
78
src/pdf/pdfZipService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { zipSync } from 'fflate';
|
||||
import type { SplitResult } from './pdfTypes';
|
||||
|
||||
function bytesToBlob(bytes: Uint8Array, type: string): Blob {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return new Blob([buffer], { type });
|
||||
}
|
||||
|
||||
function removeControlCharacters(value: string): string {
|
||||
return Array.from(value)
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function safeZipEntryName(filename: string, fallback: string): string {
|
||||
const cleaned = removeControlCharacters(filename)
|
||||
.replace(/[\\/]+/g, '_')
|
||||
.trim();
|
||||
|
||||
return cleaned.length > 0 ? cleaned : fallback;
|
||||
}
|
||||
|
||||
function uniqueZipEntryName(filename: string, usedNames: Set<string>): string {
|
||||
if (!usedNames.has(filename)) {
|
||||
usedNames.add(filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
const hasExtension = dotIndex > 0;
|
||||
const base = hasExtension ? filename.slice(0, dotIndex) : filename;
|
||||
const extension = hasExtension ? filename.slice(dotIndex) : '';
|
||||
|
||||
let counter = 2;
|
||||
let candidate = `${base}_${counter}${extension}`;
|
||||
|
||||
while (usedNames.has(candidate)) {
|
||||
counter += 1;
|
||||
candidate = `${base}_${counter}${extension}`;
|
||||
}
|
||||
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function createSplitZipFilename(pdfName: string): string {
|
||||
const baseName = pdfName.replace(/\.pdf$/i, '').trim() || 'document';
|
||||
return `${baseName}_split_pages.zip`;
|
||||
}
|
||||
|
||||
export async function createSplitResultsZip(
|
||||
results: SplitResult[]
|
||||
): Promise<Blob> {
|
||||
if (results.length === 0) {
|
||||
throw new Error('Cannot create a ZIP archive without split results.');
|
||||
}
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const result of results) {
|
||||
const fallback = `page_${String(result.pageIndex + 1).padStart(3, '0')}.pdf`;
|
||||
const entryName = uniqueZipEntryName(
|
||||
safeZipEntryName(result.filename, fallback),
|
||||
usedNames
|
||||
);
|
||||
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
entries[entryName] = new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
const zippedBytes = zipSync(entries, { level: 6 });
|
||||
return bytesToBlob(zippedBytes, 'application/zip');
|
||||
}
|
||||
2
src/pdf/pdfjsWorker.ts
Normal file
2
src/pdf/pdfjsWorker.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './uint8ArrayToHexPolyfill';
|
||||
import 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||
22
src/pdf/uint8ArrayToHexPolyfill.ts
Normal file
22
src/pdf/uint8ArrayToHexPolyfill.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
type Uint8ArrayWithToHex = Uint8Array & {
|
||||
toHex?: () => string;
|
||||
};
|
||||
|
||||
const uint8ArrayPrototype = Uint8Array.prototype as Uint8ArrayWithToHex;
|
||||
|
||||
if (typeof uint8ArrayPrototype.toHex !== 'function') {
|
||||
Object.defineProperty(Uint8Array.prototype, 'toHex', {
|
||||
value: function toHex(this: Uint8Array): string {
|
||||
const hex = new Array<string>(this.length);
|
||||
|
||||
for (let index = 0; index < this.length; index += 1) {
|
||||
const value = this[index].toString(16);
|
||||
hex[index] = value.length === 1 ? `0${value}` : value;
|
||||
}
|
||||
|
||||
return hex.join('');
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
436
src/styles.css
436
src/styles.css
@@ -12,89 +12,30 @@ body {
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
background-color: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 260px;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-sidebar h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-sidebar small {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-nav button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-nav button.active {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.app-nav button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card {
|
||||
border: 1px solid var(--pdf-border);
|
||||
background: var(--pdf-surface);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||||
box-shadow: var(--pdf-card-shadow);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.primary {
|
||||
background: var(--pdf-primary);
|
||||
color: var(--pdf-on-primary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
@@ -102,14 +43,14 @@ button.primary {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.secondary {
|
||||
background: var(--pdf-control);
|
||||
color: var(--pdf-control-text);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
@@ -117,121 +58,175 @@ button.secondary {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
.pdf-toolbox-shell {
|
||||
--pdf-surface: var(--toolbox-surface);
|
||||
--pdf-surface-soft: #f9fafb;
|
||||
--pdf-surface-raised: #ffffff;
|
||||
--pdf-text: var(--toolbox-text);
|
||||
--pdf-muted: #4b5563;
|
||||
--pdf-muted-soft: #6b7280;
|
||||
--pdf-muted-faint: #9ca3af;
|
||||
--pdf-border: #e5e7eb;
|
||||
--pdf-border-strong: #d1d5db;
|
||||
--pdf-primary: #2563eb;
|
||||
--pdf-on-primary: #ffffff;
|
||||
--pdf-control: #e5e7eb;
|
||||
--pdf-control-text: #111827;
|
||||
--pdf-info-surface: #eff6ff;
|
||||
--pdf-info-border: #bfdbfe;
|
||||
--pdf-info-text: #1e3a8a;
|
||||
--pdf-success-surface: #ecfdf5;
|
||||
--pdf-success-soft: #dcfce7;
|
||||
--pdf-success-border: #86efac;
|
||||
--pdf-success-text: #166534;
|
||||
--pdf-warning-surface: #fff7ed;
|
||||
--pdf-warning-border: #fed7aa;
|
||||
--pdf-warning-text: #9a3412;
|
||||
--pdf-danger-surface: #fef2f2;
|
||||
--pdf-danger-soft: #fee2e2;
|
||||
--pdf-danger-border: #fecaca;
|
||||
--pdf-danger-text: #b91c1c;
|
||||
--pdf-overlay: rgba(15, 23, 42, 0.62);
|
||||
--pdf-dialog-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
|
||||
--pdf-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||||
--pdf-paper: #ffffff;
|
||||
--pdf-paper-placeholder: #f3f4f6;
|
||||
--pdf-paper-border: #d1d5db;
|
||||
--pdf-paper-text: #111827;
|
||||
}
|
||||
|
||||
.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);
|
||||
.pdf-toolbox-shell[data-toolbox-theme='dark'] {
|
||||
--pdf-surface-soft: #20283a;
|
||||
--pdf-surface-raised: #1b2334;
|
||||
--pdf-muted: #b6bfd2;
|
||||
--pdf-muted-soft: #a9b3ca;
|
||||
--pdf-muted-faint: #8792aa;
|
||||
--pdf-border: #303a50;
|
||||
--pdf-border-strong: #465169;
|
||||
--pdf-primary: #8798f5;
|
||||
--pdf-on-primary: #101728;
|
||||
--pdf-control: #293247;
|
||||
--pdf-control-text: #edf1fb;
|
||||
--pdf-info-surface: #182946;
|
||||
--pdf-info-border: #345a8c;
|
||||
--pdf-info-text: #c6dcff;
|
||||
--pdf-success-surface: #12382e;
|
||||
--pdf-success-soft: #174436;
|
||||
--pdf-success-border: #33765d;
|
||||
--pdf-success-text: #a7e8cf;
|
||||
--pdf-warning-surface: #402814;
|
||||
--pdf-warning-border: #80522d;
|
||||
--pdf-warning-text: #ffd2a6;
|
||||
--pdf-danger-surface: #421f29;
|
||||
--pdf-danger-soft: #4f2530;
|
||||
--pdf-danger-border: #81394b;
|
||||
--pdf-danger-text: #ffc0cc;
|
||||
--pdf-overlay: rgba(2, 6, 16, 0.76);
|
||||
--pdf-dialog-shadow: 0 24px 52px rgba(0, 0, 0, 0.5);
|
||||
--pdf-card-shadow: 0 2px 7px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
.app-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pdf-toolbox-shell[data-toolbox-theme='system'] {
|
||||
--pdf-surface-soft: #20283a;
|
||||
--pdf-surface-raised: #1b2334;
|
||||
--pdf-muted: #b6bfd2;
|
||||
--pdf-muted-soft: #a9b3ca;
|
||||
--pdf-muted-faint: #8792aa;
|
||||
--pdf-border: #303a50;
|
||||
--pdf-border-strong: #465169;
|
||||
--pdf-primary: #8798f5;
|
||||
--pdf-on-primary: #101728;
|
||||
--pdf-control: #293247;
|
||||
--pdf-control-text: #edf1fb;
|
||||
--pdf-info-surface: #182946;
|
||||
--pdf-info-border: #345a8c;
|
||||
--pdf-info-text: #c6dcff;
|
||||
--pdf-success-surface: #12382e;
|
||||
--pdf-success-soft: #174436;
|
||||
--pdf-success-border: #33765d;
|
||||
--pdf-success-text: #a7e8cf;
|
||||
--pdf-warning-surface: #402814;
|
||||
--pdf-warning-border: #80522d;
|
||||
--pdf-warning-text: #ffd2a6;
|
||||
--pdf-danger-surface: #421f29;
|
||||
--pdf-danger-soft: #4f2530;
|
||||
--pdf-danger-border: #81394b;
|
||||
--pdf-danger-text: #ffc0cc;
|
||||
--pdf-overlay: rgba(2, 6, 16, 0.76);
|
||||
--pdf-dialog-shadow: 0 24px 52px rgba(0, 0, 0, 0.5);
|
||||
--pdf-card-shadow: 0 2px 7px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.pdf-toolbox-shell .toolbox-shell__main {
|
||||
padding: 0.75rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
max-width: 90rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Make cards full-width on mobile */
|
||||
.card {
|
||||
.pdf-toolbox-shell .toolbox-shell__main input,
|
||||
.pdf-toolbox-shell .toolbox-shell__main select,
|
||||
.pdf-toolbox-shell .toolbox-shell__main textarea {
|
||||
border-color: var(--pdf-border-strong);
|
||||
background: var(--pdf-surface);
|
||||
color: var(--pdf-text);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main input::placeholder,
|
||||
.pdf-toolbox-shell .toolbox-shell__main textarea::placeholder {
|
||||
color: var(--pdf-muted-soft);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main a {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main a:hover {
|
||||
color: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main button:disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main button:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main a:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main input:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main select:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 72%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .download-link {
|
||||
display: inline-block;
|
||||
margin: 0.15rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card hr {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-help-button {
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-help-button:hover,
|
||||
.app-help-button:focus-visible {
|
||||
background: #374151;
|
||||
outline: none;
|
||||
border-top: 1px solid var(--pdf-border);
|
||||
}
|
||||
|
||||
.help-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
background: var(--pdf-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -242,9 +237,9 @@ button.secondary {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
background: var(--pdf-surface-raised);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
|
||||
box-shadow: var(--pdf-dialog-shadow);
|
||||
}
|
||||
|
||||
.help-dialog-header {
|
||||
@@ -256,8 +251,8 @@ button.secondary {
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface-raised);
|
||||
border-bottom: 1px solid var(--pdf-border);
|
||||
}
|
||||
|
||||
.help-dialog-header h2 {
|
||||
@@ -267,7 +262,7 @@ button.secondary {
|
||||
|
||||
.help-dialog-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -278,8 +273,8 @@ button.secondary {
|
||||
border-radius: 999px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
background: var(--pdf-control);
|
||||
color: var(--pdf-control-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@@ -293,10 +288,10 @@ button.secondary {
|
||||
}
|
||||
|
||||
.help-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--pdf-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
background: #f9fafb;
|
||||
background: var(--pdf-surface-soft);
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
@@ -312,8 +307,8 @@ button.secondary {
|
||||
|
||||
.help-step {
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface);
|
||||
border: 1px solid var(--pdf-border);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -325,7 +320,7 @@ button.secondary {
|
||||
.help-step p,
|
||||
.help-note {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -341,18 +336,18 @@ button.secondary {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--pdf-border-strong);
|
||||
background: var(--pdf-surface);
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #111827;
|
||||
box-shadow: inset 0 -1px 0 #d1d5db;
|
||||
color: var(--pdf-text);
|
||||
box-shadow: inset 0 -1px 0 var(--pdf-border-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-grid span {
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -369,8 +364,8 @@ button.secondary {
|
||||
}
|
||||
|
||||
.help-concepts dl > div {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface);
|
||||
border: 1px solid var(--pdf-border);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
@@ -383,7 +378,7 @@ button.secondary {
|
||||
|
||||
.help-concepts dd {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -393,8 +388,95 @@ button.secondary {
|
||||
.help-concepts dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
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 var(--pdf-info-border);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--pdf-surface) 86%, transparent);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.merge-queue-order {
|
||||
border-radius: 999px;
|
||||
background: var(--pdf-info-surface);
|
||||
color: var(--pdf-info-text);
|
||||
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: var(--pdf-muted);
|
||||
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 var(--pdf-warning-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--pdf-warning-surface);
|
||||
color: var(--pdf-warning-text);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/toolboxApp.definition.json
Normal file
29
src/toolboxApp.definition.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"name": "PDF Workbench",
|
||||
"description": "Page-level PDF operations performed locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "pdf"],
|
||||
"tags": ["merge", "split", "rotate", "reorder"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": true,
|
||||
"crossOriginIsolated": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
}
|
||||
17
src/toolboxApp.test.ts
Normal file
17
src/toolboxApp.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import generatedManifest from '../public/toolbox-app.json';
|
||||
import { toolboxApp } from './toolboxApp';
|
||||
|
||||
describe('toolbox app definition', () => {
|
||||
it('matches the generated public manifest', () => {
|
||||
expect(toolboxApp).toEqual(parseToolboxApp(generatedManifest));
|
||||
});
|
||||
|
||||
it('uses repository metadata for the shared source control', () => {
|
||||
expect(toolboxApp.source?.repository).toBe(
|
||||
'https://git.add-ideas.de/lotobo/pdf-tools'
|
||||
);
|
||||
expect(toolboxApp.actions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
9
src/toolboxApp.ts
Normal file
9
src/toolboxApp.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import definition from './toolboxApp.definition.json';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
export const toolboxApp = parseToolboxApp({
|
||||
schemaVersion: 1,
|
||||
...definition,
|
||||
version: APP_VERSION,
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.3.0';
|
||||
export const APP_VERSION = '0.4.4';
|
||||
|
||||
13
tests/layoutStyles.test.ts
Normal file
13
tests/layoutStyles.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Toolbox layout styles', () => {
|
||||
it('keeps the PDF workspace aligned to the 90rem shell width', async () => {
|
||||
const stylesheet = await readFile('src/styles.css', 'utf8');
|
||||
const mainRule = stylesheet.match(
|
||||
/\.pdf-toolbox-shell \.toolbox-shell__main\s*\{(?<body>[^}]*)\}/u
|
||||
);
|
||||
|
||||
expect(mainRule?.groups?.body).toMatch(/\bmax-width:\s*90rem\s*;/u);
|
||||
});
|
||||
});
|
||||
96
tests/releaseArchive.test.ts
Normal file
96
tests/releaseArchive.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareCodePoints,
|
||||
collectDirectoryFiles,
|
||||
createThirdPartyLicenseBundle,
|
||||
} from '../scripts/release-archive.mjs';
|
||||
|
||||
describe('release archive helpers', () => {
|
||||
it('sorts archive paths by code point', () => {
|
||||
expect(['z', 'a', 'ä'].sort(compareCodePoints)).toEqual(['a', 'z', 'ä']);
|
||||
});
|
||||
|
||||
it('creates identical ZIP bytes in different process timezones', () => {
|
||||
const hashes = ['UTC', 'Europe/Berlin', 'America/New_York'].map(
|
||||
(timezone) => {
|
||||
const script = `
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createDeterministicZip } from './scripts/release-archive.mjs';
|
||||
const archive = createDeterministicZip({
|
||||
'z.txt': Buffer.from('last'),
|
||||
'a.txt': Buffer.from('first'),
|
||||
});
|
||||
process.stdout.write(createHash('sha256').update(archive).digest('hex'));
|
||||
`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '--eval', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TZ: timezone },
|
||||
}
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return result.stdout;
|
||||
}
|
||||
);
|
||||
expect(new Set(hashes)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collects installed runtime license and notice texts', async () => {
|
||||
const bundle = await createThirdPartyLicenseBundle(process.cwd(), [
|
||||
'react',
|
||||
'pdfjs-dist',
|
||||
'@add-ideas/toolbox-contract',
|
||||
]);
|
||||
const text = bundle.toString('utf8');
|
||||
expect(text).toContain('react@');
|
||||
expect(text).toContain('pdfjs-dist@');
|
||||
expect(text).toContain('@add-ideas/toolbox-contract@');
|
||||
expect(createHash('sha256').update(bundle).digest('hex')).toMatch(
|
||||
/^[a-f0-9]{64}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects symbolic links in release input', async () => {
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'pdf-tools-release-')
|
||||
);
|
||||
try {
|
||||
const outsideFile = path.join(temporaryDirectory, 'outside.txt');
|
||||
const inputDirectory = path.join(temporaryDirectory, 'input');
|
||||
await writeFile(outsideFile, 'private');
|
||||
await mkdir(inputDirectory);
|
||||
await symlink(outsideFile, path.join(inputDirectory, 'linked.txt'));
|
||||
|
||||
await expect(collectDirectoryFiles(inputDirectory)).rejects.toThrow(
|
||||
'Unsupported filesystem entry in release input: linked.txt'
|
||||
);
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the embedded core-js license used by the PDF.js legacy build', async () => {
|
||||
const license = await readFile(
|
||||
'licenses/core-js-3.49.0-LICENSE.txt',
|
||||
'utf8'
|
||||
);
|
||||
expect(license).toContain('core-js 3.49.0');
|
||||
expect(license).toContain('CoreJS Company');
|
||||
expect(license).toContain('Permission is hereby granted');
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,10 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
||||
allowedHosts: ['pdftools.add-ideas.de'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user