Compare commits

...

23 Commits

Author SHA1 Message Date
0b975558d0 Merge branch 'main' into v0.3.2 2026-06-06 11:52:16 +02:00
c639864319 added favicon 2026-06-05 15:49:19 +02:00
969ede47dd changes to .gitignore 2026-06-04 20:28:21 +02:00
3a0a90bd4a merge files inital commit 2026-05-23 15:02:40 +02:00
8929080dc7 Merge branch 'v0.3.2' of git.add-ideas.de:zemion/pdf-tools into v0.3.2 2026-05-17 12:37:03 +02:00
618a8fc86e switch to README.md 2026-05-17 12:36:40 +02:00
07ef17fcaa README.md aktualisiert 2026-05-17 12:36:09 +02:00
91abda1744 README.md aktualisiert 2026-05-17 12:35:36 +02:00
f0b0f678f1 Merge pull request 'zip download of single pdfs' (#2) from v.0.3.0 into main
Reviewed-on: #2
2026-05-17 02:58:46 +02:00
4b0046a943 zip download of single pdfs 2026-05-17 02:57:39 +02:00
c27efaec90 Merge pull request 'publish initial v.0.3.0' (#1) from v.0.3.0 into main
Reviewed-on: #1
2026-05-17 02:43:54 +02:00
13097b73fc Merge branch 'v.0.3.0' of git.add-ideas.de:zemion/pdf-tools into v.0.3.0 2026-05-17 02:42:44 +02:00
1b602943cb remove zip file 2026-05-17 02:42:21 +02:00
ab757291b7 pdf-tools-v0.3.0.zip gelöscht 2026-05-17 02:41:54 +02:00
cf9a0dd0b7 mostly formatting, dependency fix 2026-05-17 02:39:32 +02:00
a5dc70aabf open as worspace implementation 2026-05-17 02:31:50 +02:00
07f4361573 refactoring, linting, formatting 2026-05-17 02:05:27 +02:00
bdbb6c0a1c freeze v0.2.0 2026-05-16 20:07:52 +02:00
afeb46a210 undo / redo behaviour, workspace concept 2026-05-16 18:41:56 +02:00
3ba993277b show version number on top 2026-05-16 03:03:29 +02:00
2461cf3d64 Roadmap, robust page refs, copy behaviour 2026-05-16 02:37:20 +02:00
a649ede010 Merge branch 'main' of git.add-ideas.de:zemion/pdf-tools 2025-12-01 14:37:30 +01:00
12892ae488 fixing return of merge not including doc 2025-12-01 14:37:05 +01:00
58 changed files with 10522 additions and 2745 deletions

3
.gitignore vendored
View File

@@ -139,3 +139,6 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
todo.txt
chatgpt_continuation.md5

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
coverage
dist
*.zip

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 80
}

86
CHANGELOG.md Normal file
View File

@@ -0,0 +1,86 @@
# Changelog
All notable changes to `pdf-tools` are documented here.
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
## 0.3.2 — Multi-file merge queue release
### Added
- Added a multi-file merge queue for selecting, loading, reviewing, reordering, removing, and merging several incoming PDFs.
- Added queue merge modes for replacing the current document, appending after the current workspace, or inserting at a chosen one-based page position.
- Added merge queue helper tests for queue ordering, readiness checks, insert-position clamping, and merged filename generation.
- Added PDF merge service tests for queue-only and base-plus-incoming merge results.
### Changed
- Changed the file picker to accept multiple PDFs. A single file with no active workspace still opens directly; otherwise selected files are added to the merge queue.
- Replaced the old single-file merge card with a queue-based merge panel.
- Merging now creates a new unsaved workspace from the materialized merge result, preserving the current workspace state before append/insert merges.
- Bumped the app/package version to `0.3.2`.
## 0.3.1 — Split ZIP export release
### Added
- 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
- Added “Open selection as workspace” to create a new unsaved active workspace from the selected pages in current visual order.
- Added selection-workspace helper tests for visual-order selection and derived naming.
- Added TypeScript type-check, ESLint, Prettier, and aggregate `check` scripts.
- Added ESLint flat config with TypeScript, React Hooks, React Refresh, browser, and Node config support.
- Added Prettier configuration and ignore file.
- Added Vite client type declarations for worker URL imports.
- Added Vitest-based test scripts for one-off and watch-mode test runs.
- Added pure tests for workspace command cloning, snapshot command stability, and serializable command record round-tripping.
- Added hook-level tests for workspace load/replace behavior, command execution, undo, redo, history, redo clearing, dirty-state updates, and content-change callbacks.
### Changed
- Bumped the app/package version to `0.3.0`.
- Switched to `@vitejs/plugin-react`.
- Marked the package as an ES module package to remove the Vite CJS Node API deprecation warning during local tooling runs.
- Ran Prettier across the project after adding the formatting configuration.
- Split the former monolithic `ReorderPanel` into focused page-workspace components: `PageGrid`, `PageCard`, `PageSelectionToolbar`, `DropIndicator`, and `CopyPagesDialog`.
- Kept drag/drop move, Ctrl/⌘ copy-drag, selection, rotation, deletion, preview opening, and copy-by-position behavior wired through the existing `ReorderPanel` API.
- Extracted generated PDF download URL creation and cleanup for split, extract, and export results into `src/hooks/usePdfGeneratedOutputs.ts`.
- Updated `ActionsPanel` to render prepared download objects instead of creating object URLs during render.
- Extracted workspace page, selection, dirty-state, message, undo/redo history, command creation, command execution, and reset/load helpers from `App.tsx` into `src/workspace/useWorkspaceState.ts`.
- Extracted thumbnail state, caching, invalidation, progressive rendering, rotation-aware rendering, copied-page thumbnail reuse, and thumbnail error reporting into `src/pdf/usePdfThumbnails.ts`.
- Kept PDF loading, IndexedDB persistence, dialogs, preview, merge, export, and split orchestration in `App.tsx` for now.
### Fixed
- Renamed Prettier config files to `.prettierrc.json` and `.prettierignore` so Prettier picks them up automatically.
- Fixed existing `tsc --noEmit` failures for Vite worker URL imports and `Uint8Array`/`BlobPart` PDF byte handling.
- Removed a duplicate copy-dialog validation error assignment in `ReorderPanel`.
- Rotated thumbnails from loaded/saved workspaces are now regenerated from the actual current page rotation instead of relying only on rotation changes after load.
- Copied/duplicated pages now receive thumbnails through the shared thumbnail hook/cache path instead of ad-hoc copy handling in `App.tsx`.
## 0.2.0 — Browser-only PDF workspace baseline
### Added
- Browser-only PDF loading and processing.
- Visual page workspace with thumbnails, page preview, reordering, selection, rotation, deletion, duplication, extraction, split, merge, and export.
- Named local workspaces persisted in IndexedDB, including the PDF binary and workspace state.
- Undo/redo command history with labels, timestamps, payload snapshots, and visible redo entries.
- Reset and delete-workspace confirmation dialogs.
- In-app Help/Tutorial dialog and keyboard shortcut overview.
- Administrator-focused README for static self-hosting.
### Notes
This release is the baseline for the next refactoring phase. The goal of the upcoming internal changes is to preserve behavior while extracting workspace state, thumbnail handling, generated object URLs, UI subcomponents, tests, and linting into clearer modules.

1
README
View File

@@ -1 +0,0 @@
empty

327
README.md Normal file
View File

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

55
eslint.config.mjs Normal file
View File

@@ -0,0 +1,55 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist', 'coverage', 'node_modules'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2022,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-hooks/set-state-in-effect': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
files: ['*.config.{js,ts}', 'eslint.config.js'],
languageOptions: {
globals: {
...globals.node,
...globals.es2022,
},
},
},
eslintConfigPrettier
);

View File

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

4378
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,46 @@
{
"name": "pdf-workbench",
"version": "0.1.0",
"name": "pdf-tools",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo \"no lint configured\""
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run --environment jsdom",
"test:watch": "vitest --environment jsdom",
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
},
"dependencies": {
"fflate": "^0.8.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.6.82",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"pdfjs-dist": "^5.7.284",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.2",
"@vitejs/plugin-react-swc": "^3.7.0",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13",
"vitest": "^4.1.6"
},
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

17
public/favicon.svg Normal file
View 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
View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import React, { useEffect } from 'react';
export interface ActionDialogAction {
label: string;
onClick: () => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
autoFocus?: boolean;
title?: string;
}
interface ActionDialogProps {
open: boolean;
title: string;
children: React.ReactNode;
actions: ActionDialogAction[];
onClose: () => void;
}
const backgroundByVariant: Record<
NonNullable<ActionDialogAction['variant']>,
string
> = {
primary: '#2563eb',
secondary: '#e5e7eb',
danger: '#dc2626',
};
const colorByVariant: Record<
NonNullable<ActionDialogAction['variant']>,
string
> = {
primary: 'white',
secondary: '#111827',
danger: 'white',
};
const ActionDialog: React.FC<ActionDialogProps> = ({
open,
title,
children,
actions,
onClose,
}) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="action-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 70,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<div
style={{
width: '100%',
maxWidth: '440px',
background: 'white',
borderRadius: '0.75rem',
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
}}
>
<h2
id="action-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
{title}
</h2>
<button
type="button"
onClick={onClose}
style={{
border: 'none',
borderRadius: '999px',
width: '1.8rem',
height: '1.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close dialog"
>
×
</button>
</div>
<div
style={{
fontSize: '0.9rem',
color: '#4b5563',
lineHeight: 1.45,
}}
>
{children}
</div>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
flexWrap: 'wrap',
marginTop: '0.25rem',
}}
>
{actions.map((action) => {
const variant = action.variant ?? 'secondary';
return (
<button
key={action.label}
type="button"
onClick={() => {
void action.onClick();
}}
disabled={action.disabled}
autoFocus={action.autoFocus}
title={action.title}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: action.disabled
? '#e5e7eb'
: backgroundByVariant[variant],
color: action.disabled ? '#6b7280' : colorByVariant[variant],
cursor: action.disabled ? 'default' : 'pointer',
fontSize: '0.9rem',
}}
>
{action.label}
</button>
);
})}
</div>
</div>
</div>
);
};
export default ActionDialog;

View File

@@ -1,5 +1,8 @@
import React from 'react';
import type { SplitResult } from '../pdf/pdfTypes';
import type {
PdfDownload,
SplitPdfDownload,
} from '../hooks/usePdfGeneratedOutputs';
interface ActionsPanelProps {
hasPdf: boolean;
@@ -9,13 +12,13 @@ interface ActionsPanelProps {
onSplit: () => void;
onExtractSelected: () => void;
onOpenSelectionAsWorkspace: () => void;
onExportReordered: () => void;
splitResults: SplitResult[];
subsetDownloadUrl: string | null;
subsetFilename: string | null;
exportDownloadUrl: string | null;
exportFilename: string | null;
splitDownloads: SplitPdfDownload[];
splitZipDownload: PdfDownload | null;
subsetDownload: PdfDownload | null;
exportDownload: PdfDownload | null;
}
const ActionsPanel: React.FC<ActionsPanelProps> = ({
@@ -24,12 +27,12 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
selectedCount,
onSplit,
onExtractSelected,
onOpenSelectionAsWorkspace,
onExportReordered,
splitResults,
subsetDownloadUrl,
subsetFilename,
exportDownloadUrl,
exportFilename,
splitDownloads,
splitZipDownload,
subsetDownload,
exportDownload,
}) => {
const disabled = !hasPdf || isBusy;
@@ -38,6 +41,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
onExtractSelected();
};
const handleOpenSelectionAsWorkspaceClick = () => {
if (selectedCount === 0) return;
onOpenSelectionAsWorkspace();
};
return (
<div className="card">
<h2>Tools</h2>
@@ -73,6 +81,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
📤 Extract selected ({selectedCount})
</button>
<button
className="secondary"
disabled={disabled || selectedCount === 0}
onClick={handleOpenSelectionAsWorkspaceClick}
style={{ flex: '1 1 45%' }}
title={
selectedCount === 0
? 'Select at least one page'
: 'Open selected pages as a new unsaved workspace'
}
>
🧩 Open selection as workspace
</button>
<button
className="secondary"
disabled={disabled}
@@ -83,52 +105,57 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
</button>
</div>
{subsetDownloadUrl && subsetFilename && (
{subsetDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Subset result:</strong>{' '}
<a
className="download-link"
href={subsetDownloadUrl}
download={subsetFilename}
href={subsetDownload.url}
download={subsetDownload.filename}
>
Download {subsetFilename}
Download {subsetDownload.filename}
</a>
</div>
)}
{exportDownloadUrl && exportFilename && (
{exportDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Exported document:</strong>{' '}
<a
className="download-link"
href={exportDownloadUrl}
download={exportFilename}
href={exportDownload.url}
download={exportDownload.filename}
>
Download {exportFilename}
Download {exportDownload.filename}
</a>
</div>
)}
{splitResults.length > 0 && (
{splitDownloads.length > 0 && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
<strong>Single-page PDFs:</strong>
<div>
{splitResults.map((r) => {
const url = URL.createObjectURL(r.blob);
return (
{splitZipDownload && (
<div style={{ marginTop: '0.25rem' }}>
<a
key={r.pageIndex}
className="download-link"
href={url}
download={r.filename}
onClick={() => {
setTimeout(() => URL.revokeObjectURL(url), 5000);
}}
href={splitZipDownload.url}
download={splitZipDownload.filename}
>
{r.filename}
Download all as ZIP ({splitDownloads.length} files)
</a>
);
})}
</div>
)}
<div style={{ marginTop: '0.25rem' }}>
{splitDownloads.map((download) => (
<a
key={download.id}
className="download-link"
href={download.url}
download={download.filename}
>
{download.filename}
</a>
))}
</div>
</div>
)}

View File

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

View File

@@ -0,0 +1,189 @@
import React, { useEffect } from 'react';
interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
const shortcuts = [
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
{
keys: 'Ctrl/⌘ + A',
description: 'Select all pages in the current workspace',
},
{
keys: 'Delete / Backspace',
description: 'Delete the selected pages after confirmation',
},
{
keys: 'Esc',
description: 'Clear the page selection or close an open dialog',
},
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
{
keys: 'Ctrl/⌘ + Shift + Z',
description: 'Redo the next workspace command',
},
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
{
keys: '← / → in preview',
description: 'Move to the previous or next page in the preview overlay',
},
];
const tutorialSteps = [
{
title: '1. Open a PDF or load a workspace',
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',
body: 'Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.',
},
{
title: '3. Select, copy, and delete pages',
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.',
},
{
title: '4. Extract selected pages or branch into a new workspace',
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. 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: '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.',
},
];
const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onClose();
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="help-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="help-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="help-dialog-panel">
<div className="help-dialog-header">
<div>
<h2 id="help-dialog-title">Help & tutorial</h2>
<p>
PDF Workbench is a browser-only page workspace. Use it to quickly
rearrange, split, queue-merge, rotate, duplicate, and export PDFs
without uploading documents to a server.
</p>
</div>
<button
type="button"
className="help-close-button"
onClick={onClose}
aria-label="Close help"
title="Close help (Esc)"
>
×
</button>
</div>
<div className="help-dialog-content">
<section className="help-section">
<h3>Quick tutorial</h3>
<div className="help-step-list">
{tutorialSteps.map((step) => (
<article key={step.title} className="help-step">
<h4>{step.title}</h4>
<p>{step.body}</p>
</article>
))}
</div>
</section>
<section className="help-section">
<h3>Keyboard shortcuts</h3>
<div className="shortcut-grid">
{shortcuts.map((shortcut) => (
<React.Fragment key={shortcut.keys}>
<kbd>{shortcut.keys}</kbd>
<span>{shortcut.description}</span>
</React.Fragment>
))}
</div>
<p className="help-note">
Shortcuts are ignored while typing in text fields or other
editable controls.
</p>
</section>
<section className="help-section help-concepts">
<h3>Important concepts</h3>
<dl>
<div>
<dt>Browser-only processing</dt>
<dd>
PDF operations run in your browser. A self-hosted server only
delivers the static app files.
</dd>
</div>
<div>
<dt>Workspace</dt>
<dd>
A named local editing state, including the PDF binary, page
order, rotations, selection, and command history.
</dd>
</div>
<div>
<dt>Export</dt>
<dd>
A generated PDF download. Exported files are separate from
saved workspaces.
</dd>
</div>
<div>
<dt>History</dt>
<dd>
Undoable commands show what changed, when it changed, and
where the current point in history is.
</dd>
</div>
</dl>
</section>
</div>
</div>
</div>
);
};
export default HelpDialog;

View File

@@ -1,13 +1,16 @@
import React from 'react';
import { APP_VERSION } from '../version';
interface LayoutProps {
children: React.ReactNode;
onOpenHelp?: () => void;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
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>
@@ -15,7 +18,27 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<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>
);

View File

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

View File

@@ -1,79 +0,0 @@
import React from 'react';
interface PageListProps {
pageCount: number;
selectedPages: number[];
onTogglePage: (index: number) => void;
thumbnails: string[] | null;
}
const PageList: React.FC<PageListProps> = ({
pageCount,
selectedPages,
onTogglePage,
thumbnails,
}) => {
if (pageCount === 0) return null;
const pages = Array.from({ length: pageCount }, (_, i) => i);
return (
<div className="card">
<h2>2. Pages</h2>
<p>
Thumbnails are generated in your browser. Click to select pages (used by
future tools).
</p>
<div className="page-list">
{pages.map((i) => {
const selected = selectedPages.includes(i);
const thumb = thumbnails?.[i];
return (
<button
key={i}
type="button"
className={`page-pill ${selected ? 'selected' : ''}`}
onClick={() => onTogglePage(i)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
padding: '0.4rem',
minWidth: '90px',
}}
>
{thumb ? (
<img
src={thumb}
alt={`Page ${i + 1}`}
style={{
maxHeight: '100px',
width: 'auto',
borderRadius: '0.25rem',
border: '1px solid #e5e7eb',
background: 'white',
}}
/>
) : (
<div
style={{
width: '60px',
height: '80px',
borderRadius: '0.25rem',
border: '1px dashed #d1d5db',
background: '#f3f4f6',
}}
/>
)}
<span>Page {i + 1}</span>
</button>
);
})}
</div>
</div>
);
};
export default PageList;

View File

@@ -10,8 +10,17 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
interface PagePreviewModalProps {
isOpen: boolean;
pdf: PdfFile | null;
pageIndex: number | null; // original page index (0-based)
pageIndex: number | null; // original page index, 0-based
rotation: number; // degrees
visualIndex: number | null; // current position in order, 0-based
totalPages: number;
canGoPrevious: boolean;
canGoNext: boolean;
onPrevious: () => void;
onNext: () => void;
onClose: () => void;
}
@@ -20,10 +29,45 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
pdf,
pageIndex,
rotation,
visualIndex,
totalPages,
canGoPrevious,
canGoNext,
onPrevious,
onNext,
onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'ArrowLeft' && canGoPrevious) {
e.preventDefault();
onPrevious();
return;
}
if (e.key === 'ArrowRight' && canGoNext) {
e.preventDefault();
onNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
useEffect(() => {
if (!isOpen || !pdf || pageIndex == null) return;
@@ -31,6 +75,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
(async () => {
try {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
// copy data for pdf.js (avoid detaching original ArrayBuffer)
const src = new Uint8Array(pdf.arrayBuffer);
const copy = new Uint8Array(src.byteLength);
@@ -44,16 +96,23 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
if (cancelled) return;
const viewport = page.getViewport({ scale: 1 });
const maxWidth = Math.min(window.innerWidth * 0.9, 800);
const scale = maxWidth / viewport.width;
const maxHeight = window.innerHeight * 0.75;
const scale = Math.min(
maxWidth / viewport.width,
maxHeight / viewport.height
);
const scaledViewport = page.getViewport({ scale });
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const visibleCanvas = canvasRef.current;
if (!visibleCanvas) return;
const visibleCtx = visibleCanvas.getContext('2d');
if (!visibleCtx) return;
// base size
let canvasWidth = scaledViewport.width;
let canvasHeight = scaledViewport.height;
@@ -64,10 +123,9 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
canvasHeight = scaledViewport.width;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
visibleCanvas.width = canvasWidth;
visibleCanvas.height = canvasHeight;
// render into an offscreen canvas first
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) return;
@@ -76,34 +134,33 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx,
viewport: scaledViewport,
});
await renderTask.promise;
if (cancelled) return;
// draw rotated onto visible canvas
ctx.save();
visibleCtx.save();
switch (angle) {
case 90:
ctx.translate(canvasWidth, 0);
ctx.rotate((angle * Math.PI) / 180);
visibleCtx.translate(canvasWidth, 0);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
case 180:
ctx.translate(canvasWidth, canvasHeight);
ctx.rotate((angle * Math.PI) / 180);
visibleCtx.translate(canvasWidth, canvasHeight);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
case 270:
ctx.translate(0, canvasHeight);
ctx.rotate((angle * Math.PI) / 180);
break;
default:
visibleCtx.translate(0, canvasHeight);
visibleCtx.rotate((angle * Math.PI) / 180);
break;
}
ctx.drawImage(baseCanvas, 0, 0);
ctx.restore();
visibleCtx.drawImage(baseCanvas, 0, 0);
visibleCtx.restore();
} catch (e) {
console.error('Error rendering preview', e);
}
@@ -116,6 +173,11 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
if (!isOpen || !pdf || pageIndex == null) return null;
const positionLabel =
visualIndex != null && visualIndex >= 0
? `${visualIndex + 1} / ${totalPages}`
: `Page ${pageIndex + 1}`;
return (
<div
onClick={onClose}
@@ -133,6 +195,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
<div
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
background: '#111827',
borderRadius: '0.75rem',
padding: '0.75rem',
@@ -142,25 +205,107 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
overflow: 'visible',
}}
>
<div style={{ alignSelf: 'flex-end' }}>
{/* Previous page */}
<button
type="button"
onClick={onClose}
onClick={(e) => {
e.stopPropagation();
onPrevious();
}}
disabled={!canGoPrevious}
style={{
border: 'none',
position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px',
padding: '0.25rem 0.5rem',
fontSize: '0.8rem',
border: 'none',
background: canGoPrevious ? '#374151' : '#1f2937',
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
cursor: canGoPrevious ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Previous page (←)"
aria-label="Previous page"
>
</button>
{/* Next page */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
disabled={!canGoNext}
style={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translate(50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px',
border: 'none',
background: canGoNext ? '#374151' : '#1f2937',
color: canGoNext ? '#e5e7eb' : '#6b7280',
cursor: canGoNext ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Next page (→)"
aria-label="Next page"
>
</button>
{/* Close */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
position: 'absolute',
top: 0,
right: 0,
transform: 'translate(50%, -50%)',
width: '2.25rem',
height: '2.25rem',
borderRadius: '999px',
border: 'none',
background: '#374151',
color: '#e5e7eb',
cursor: 'pointer',
fontSize: '1.2rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 3,
}}
title="Close preview (Esc)"
aria-label="Close preview"
>
Close
×
</button>
</div>
<canvas
ref={canvasRef}
style={{
@@ -170,8 +315,9 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
borderRadius: '0.5rem',
}}
/>
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
Page {pageIndex + 1} · Rot {rotation}°
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
</div>
</div>
</div>

View File

@@ -0,0 +1,224 @@
import React, { useEffect } from 'react';
interface CopyPagesDialogProps {
selectedCount: number;
pageCount: number;
targetPosition: string;
error: string | null;
onTargetPositionChange: (value: string) => void;
onCancel: () => void;
onConfirm: (e?: React.FormEvent) => void;
}
const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
selectedCount,
pageCount,
targetPosition,
error,
onTargetPositionChange,
onCancel,
onConfirm,
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onCancel]);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="copy-pages-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
onCancel();
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<form
onSubmit={onConfirm}
style={{
width: '100%',
maxWidth: '420px',
background: 'white',
borderRadius: '0.75rem',
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
}}
>
<h2
id="copy-pages-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
Copy selected pages
</h2>
<button
type="button"
onClick={onCancel}
style={{
border: 'none',
borderRadius: '999px',
width: '1.8rem',
height: '1.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close copy dialog"
>
×
</button>
</div>
<p
style={{
margin: 0,
fontSize: '0.9rem',
color: '#4b5563',
}}
>
Copy{' '}
<strong>
{selectedCount === 1
? '1 selected page'
: `${selectedCount} selected pages`}
</strong>{' '}
to a new position.
</p>
<label
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
fontSize: '0.9rem',
}}
>
Insert before position
<input
type="number"
min={1}
max={pageCount + 1}
value={targetPosition}
autoFocus
onChange={(e) => onTargetPositionChange(e.target.value)}
style={{
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.95rem',
}}
/>
</label>
<div
style={{
fontSize: '0.8rem',
color: '#6b7280',
lineHeight: 1.4,
}}
>
<div>1 = before the first page</div>
<div>{pageCount + 1} = after the last page</div>
</div>
{error && (
<div
style={{
borderRadius: '0.5rem',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
padding: '0.5rem',
fontSize: '0.85rem',
}}
>
{error}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={onCancel}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Cancel
</button>
<button
type="submit"
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#16a34a',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Copy pages
</button>
</div>
</form>
</div>
);
};
export default CopyPagesDialog;

View File

@@ -0,0 +1,27 @@
import React from 'react';
interface DropIndicatorProps {
side: 'left' | 'right' | 'end';
color: string;
}
const DropIndicator: React.FC<DropIndicatorProps> = ({ side, color }) => {
const isEnd = side === 'end';
return (
<div
style={{
position: 'absolute',
left: side === 'left' ? '-4px' : isEnd ? '8px' : undefined,
right: side === 'right' ? '-4px' : undefined,
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: color,
}}
/>
);
};
export default DropIndicator;

View File

@@ -0,0 +1,213 @@
import React from 'react';
import type { PageRef } from '../../pdf/pdfTypes';
import DropIndicator from './DropIndicator';
interface PageCardProps {
page: PageRef;
visualIndex: number;
thumbnail?: string;
selected: boolean;
isDraggingCard: boolean;
isBusy: boolean;
isCopyDragging: boolean;
showLeftLine: boolean;
showRightLine: boolean;
dropIndicatorColor: string;
onDragStart: React.DragEventHandler<HTMLDivElement>;
onDragEnd: React.DragEventHandler<HTMLDivElement>;
onDragOver: React.DragEventHandler<HTMLDivElement>;
onOpenPreview: () => void;
onToggleSelect: React.MouseEventHandler<HTMLButtonElement>;
onRotateClockwise: () => void;
onRotateCounterclockwise: () => void;
onDelete: () => void;
}
const pageActionButtonStyle: React.CSSProperties = {
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
cursor: 'pointer',
};
const PageCard: React.FC<PageCardProps> = ({
page,
visualIndex,
thumbnail,
selected,
isDraggingCard,
isBusy,
isCopyDragging,
showLeftLine,
showRightLine,
dropIndicatorColor,
onDragStart,
onDragEnd,
onDragOver,
onOpenPreview,
onToggleSelect,
onRotateClockwise,
onRotateCounterclockwise,
onDelete,
}) => {
const background = isDraggingCard
? isCopyDragging
? '#dcfce7'
: '#dbeafe'
: selected
? '#eff6ff'
: '#f9fafb';
return (
<div
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onClick={onOpenPreview}
style={{
position: 'relative',
width: '162px',
padding: '0.4rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
opacity: isBusy ? 0.7 : 1,
}}
>
<button
type="button"
onClick={onToggleSelect}
style={{
position: 'absolute',
top: '4px',
left: '4px',
width: '20px',
height: '20px',
borderRadius: '0.4rem',
border: '1px solid #9ca3af',
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
color: selected ? 'white' : 'transparent',
fontSize: '0.8rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
cursor: 'pointer',
}}
title="Select page"
>
</button>
{showLeftLine && <DropIndicator side="left" color={dropIndicatorColor} />}
{showRightLine && (
<DropIndicator side="right" color={dropIndicatorColor} />
)}
<div
style={{
width: '110px',
height: '90px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{thumbnail ? (
<img
src={thumbnail}
alt={`Page ${page.sourcePageIndex + 1}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: '0.25rem',
border: '1px solid #e5e7eb',
background: 'white',
}}
/>
) : (
<div
style={{
width: '60px',
height: '80px',
borderRadius: '0.25rem',
border: '1px dashed #d1d5db',
background: '#f3f4f6',
}}
/>
)}
</div>
<span style={{ fontSize: '0.8rem' }}>
Page {page.sourcePageIndex + 1}
</span>
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
Pos {visualIndex + 1} · Rot {page.rotation}°
</span>
<div
style={{
display: 'flex',
gap: '0.25rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRotateClockwise();
}}
style={{
...pageActionButtonStyle,
background: '#e5e7eb',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRotateCounterclockwise();
}}
style={{
...pageActionButtonStyle,
background: '#e5e7eb',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
style={{
...pageActionButtonStyle,
background: '#fecaca',
color: '#b91c1c',
}}
title="Remove this page from the exported PDF"
>
</button>
</div>
</div>
);
};
export default PageCard;

View File

@@ -0,0 +1,130 @@
import React from 'react';
import type { PageRef } from '../../pdf/pdfTypes';
import DropIndicator from './DropIndicator';
import PageCard from './PageCard';
interface PageGridProps {
pages: PageRef[];
thumbnails: Record<string, string>;
selectedPageIds: string[];
isBusy: boolean;
draggingIndex: number | null;
dropIndex: number | null;
draggingSelectionActive: boolean;
isCopyDragging: boolean;
dropIndicatorColor: string;
onDragStart: (visualIndex: number) => React.DragEventHandler<HTMLDivElement>;
onDragEnd: React.DragEventHandler<HTMLDivElement>;
onCardDragOver: (
visualIndex: number
) => React.DragEventHandler<HTMLDivElement>;
onEndSlotDragOver: React.DragEventHandler<HTMLDivElement>;
onDrop: React.DragEventHandler<HTMLDivElement>;
onOpenPreview: (pageId: string) => void;
onToggleSelect: (
pageId: string,
visualIndex: number
) => React.MouseEventHandler<HTMLButtonElement>;
onRotateClockwise: (pageId: string) => void;
onRotateCounterclockwise: (pageId: string) => void;
onDelete: (pageId: string) => void;
}
const PageGrid: React.FC<PageGridProps> = ({
pages,
thumbnails,
selectedPageIds,
isBusy,
draggingIndex,
dropIndex,
draggingSelectionActive,
isCopyDragging,
dropIndicatorColor,
onDragStart,
onDragEnd,
onCardDragOver,
onEndSlotDragOver,
onDrop,
onOpenPreview,
onToggleSelect,
onRotateClockwise,
onRotateCounterclockwise,
onDelete,
}) => {
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
const showLeftLine = (visualIndex: number) =>
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
const showRightLine = (visualIndex: number) =>
dropIndex !== null &&
dropIndex === visualIndex + 1 &&
draggingIndex !== null;
const showEndLine = () =>
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
alignItems: 'flex-start',
marginBottom: '0.75rem',
}}
onDrop={onDrop}
>
{pages.map((page, visualIndex) => {
const selected = isSelected(page.id);
const isDraggingCard =
draggingIndex != null &&
((draggingSelectionActive && selected) ||
(!draggingSelectionActive && visualIndex === draggingIndex));
return (
<PageCard
key={page.id}
page={page}
visualIndex={visualIndex}
thumbnail={thumbnails[page.id]}
selected={selected}
isDraggingCard={isDraggingCard}
isBusy={isBusy}
isCopyDragging={isCopyDragging}
showLeftLine={showLeftLine(visualIndex)}
showRightLine={showRightLine(visualIndex)}
dropIndicatorColor={dropIndicatorColor}
onDragStart={onDragStart(visualIndex)}
onDragEnd={onDragEnd}
onDragOver={onCardDragOver(visualIndex)}
onOpenPreview={() => onOpenPreview(page.id)}
onToggleSelect={onToggleSelect(page.id, visualIndex)}
onRotateClockwise={() => onRotateClockwise(page.id)}
onRotateCounterclockwise={() => onRotateCounterclockwise(page.id)}
onDelete={() => onDelete(page.id)}
/>
);
})}
{pages.length > 0 && (
<div
onDragOver={onEndSlotDragOver}
onDrop={onDrop}
style={{
width: '20px',
height: '120px',
position: 'relative',
alignSelf: 'stretch',
}}
>
{showEndLine() && (
<DropIndicator side="end" color={dropIndicatorColor} />
)}
</div>
)}
</div>
);
};
export default PageGrid;

View File

@@ -0,0 +1,112 @@
import React from 'react';
interface PageSelectionToolbarProps {
selectedCount: number;
onCopySelected: () => void;
onDeleteSelected: () => void;
onSelectAll: () => void;
onClearSelection: () => void;
}
const pillButtonStyle: React.CSSProperties = {
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
};
const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
selectedCount,
onCopySelected,
onDeleteSelected,
onSelectAll,
onClearSelection,
}) => {
const hasSelection = selectedCount > 0;
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
fontSize: '0.85rem',
}}
>
<span>
Selected: <strong>{selectedCount}</strong>
</span>
<div
style={{
display: 'flex',
gap: '0.4rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
{hasSelection && (
<button
type="button"
onClick={onCopySelected}
disabled={!hasSelection}
style={{
...pillButtonStyle,
background: '#dcfce7',
color: '#166534',
cursor: 'pointer',
}}
title="Copy selected pages to another position"
>
Copy selected
</button>
)}
{hasSelection && (
<button
type="button"
onClick={onDeleteSelected}
style={{
...pillButtonStyle,
background: '#fee2e2',
color: '#b91c1c',
cursor: 'pointer',
}}
>
Delete selected
</button>
)}
<button
type="button"
onClick={onSelectAll}
style={{
...pillButtonStyle,
background: '#8dcd8d',
color: '#111827',
cursor: 'pointer',
}}
>
Select all
</button>
<button
type="button"
onClick={onClearSelection}
disabled={!hasSelection}
style={{
...pillButtonStyle,
background: '#e5e7eb',
color: hasSelection ? '#111827' : '#6b7280',
cursor: hasSelection ? 'pointer' : 'default',
}}
>
Clear selection
</button>
</div>
</div>
);
};
export default PageSelectionToolbar;

View File

@@ -1,41 +1,45 @@
import React, { useState, useRef } from 'react';
import React, { useRef, useState } from 'react';
import type { PageRef } from '../pdf/pdfTypes';
import CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
import PageGrid from './PageWorkspace/PageGrid';
import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
interface ReorderPanelProps {
order: number[]; // current page order (page indices)
thumbnails: string[] | null; // thumbnails by original page index
pages: PageRef[];
thumbnails: Record<string, string>;
isBusy: boolean;
hasPdf: boolean;
rotations: Record<number, number>;
selectedPages: number[]; // selected original page indices
selectedPageIds: string[];
onRotateClockwise: (pageIndex: number) => void;
onRotateCounterclockwise: (pageIndex: number) => void;
onDelete: (pageIndex: number) => void;
onReorder: (newOrder: number[]) => void;
onRotateClockwise: (pageId: string) => void;
onRotateCounterclockwise: (pageId: string) => void;
onDelete: (pageId: string) => void;
onReorder: (newPages: PageRef[]) => void;
onCopyPagesToSlot: (pageIds: string[], insertSlot: number) => void;
onToggleSelect: (
pageIndex: number,
pageId: string,
visualIndex: number,
e: React.MouseEvent<HTMLButtonElement>
) => void;
onSelectAll: () => void;
onOpenPreview: (pageIndex: number) => void;
onOpenPreview: (pageId: string) => void;
onClearSelection: () => void;
onDeleteSelected: () => void;
}
const ReorderPanel: React.FC<ReorderPanelProps> = ({
order,
pages,
thumbnails,
isBusy,
hasPdf,
rotations,
selectedPages,
selectedPageIds,
onRotateClockwise,
onRotateCounterclockwise,
onDelete,
onReorder,
onCopyPagesToSlot,
onToggleSelect,
onSelectAll,
onOpenPreview,
@@ -43,10 +47,14 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
onDeleteSelected,
}) => {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length
const dragGhostRef = useRef<HTMLDivElement | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const isSelected = (pageIndex: number) => selectedPages.includes(pageIndex);
const [isCopyDragging, setIsCopyDragging] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyTargetPosition, setCopyTargetPosition] = useState('');
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
const dragGhostRef = useRef<HTMLDivElement | null>(null);
const cleanupDragGhost = () => {
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
@@ -55,11 +63,31 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
dragGhostRef.current = null;
};
const isCopyModifierPressed = (e: React.DragEvent) => {
return e.ctrlKey || e.metaKey;
};
const getDraggedPages = (visualIndex: number): PageRef[] => {
const draggedPage = pages[visualIndex];
if (!draggedPage) return [];
const selectedInVisualOrder = pages.filter((page) =>
selectedPageIds.includes(page.id)
);
const draggingIsSelected =
selectedInVisualOrder.length > 0 &&
selectedInVisualOrder.some((page) => page.id === draggedPage.id);
return draggingIsSelected ? selectedInVisualOrder : [draggedPage];
};
const createDragGhost = (e: React.DragEvent, count: number) => {
cleanupDragGhost();
const ghost = document.createElement('div');
ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
ghost.style.position = 'fixed';
ghost.style.top = '0';
ghost.style.left = '0';
@@ -68,49 +96,52 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
ghost.style.background = '#111827';
ghost.style.color = '#e5e7eb';
ghost.style.fontSize = '12px';
ghost.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ghost.style.boxShadow = '0 4px 8px rgba(15, 23, 42, 0.4)';
ghost.style.fontFamily =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ghost.style.zIndex = '9999';
document.body.appendChild(ghost);
dragGhostRef.current = ghost;
// center drag image under cursor
const rect = ghost.getBoundingClientRect();
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
};
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
setDraggingIndex(visualIndex);
setDropIndex(visualIndex); // initial: before itself
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(visualIndex)); // Firefox
const draggedPageIndex = order[visualIndex];
const selectedInVisualOrder = order.filter((p) =>
selectedPages.includes(p)
);
const draggingIsSelected =
selectedInVisualOrder.length > 0 &&
selectedInVisualOrder.includes(draggedPageIndex);
const movingPages = draggingIsSelected
? selectedInVisualOrder
: [draggedPageIndex];
createDragGhost(e, movingPages.length);
};
const handleDragEnd = () => {
const resetDragState = () => {
cleanupDragGhost();
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
};
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
setDraggingIndex(visualIndex);
setDropIndex(visualIndex);
const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying);
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
e.dataTransfer.setData('text/plain', String(visualIndex));
const draggedPages = getDraggedPages(visualIndex);
createDragGhost(e, draggedPages.length);
};
const handleDragEnd = () => {
resetDragState();
};
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
if (draggingIndex == null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - rect.left;
@@ -121,9 +152,15 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const handleEndSlotDragOver = (e: React.DragEvent) => {
if (draggingIndex == null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropIndex(order.length);
const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
setDropIndex(pages.length);
};
const handleDrop = (e: React.DragEvent) => {
@@ -132,76 +169,100 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
if (draggingIndex == null || dropIndex == null) return;
const draggedPageIndex = order[draggingIndex];
const draggedPages = getDraggedPages(draggingIndex);
if (draggedPages.length === 0) return;
// Selected pages in current visual order
const selectedInVisualOrder = order.filter((p) =>
selectedPages.includes(p)
const shouldCopy = isCopyModifierPressed(e) || isCopyDragging;
if (shouldCopy) {
onCopyPagesToSlot(
draggedPages.map((page) => page.id),
dropIndex
);
const draggingIsSelected =
selectedInVisualOrder.length > 0 &&
selectedInVisualOrder.includes(draggedPageIndex);
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
return;
}
// Pages that will move:
// - if dragging selected -> move full selection (in visual order)
// - else -> only the dragged page
const movingPages = draggingIsSelected
? selectedInVisualOrder
: [draggedPageIndex];
const indexMap = new Map<string, number>();
pages.forEach((page, idx) => indexMap.set(page.id, idx));
// Map from page index to visual position
const indexMap = new Map<number, number>();
order.forEach((p, idx) => indexMap.set(p, idx));
// how many of the moving pages were before the drop slot?
const countBefore = movingPages.reduce((count, p) => {
const idx = indexMap.get(p);
const countBefore = draggedPages.reduce((count, page) => {
const idx = indexMap.get(page.id);
if (idx != null && idx < dropIndex) return count + 1;
return count;
}, 0);
const adjustedSlot = dropIndex - countBefore;
const movingSet = new Set(draggedPages.map((page) => page.id));
const remaining = pages.filter((page) => !movingSet.has(page.id));
// Remove moving pages from current order
const movingSet = new Set(movingPages);
const remaining = order.filter((p) => !movingSet.has(p));
// Insert in same relative order at new slot
const newOrder = [
const newPages = [
...remaining.slice(0, adjustedSlot),
...movingPages,
...draggedPages,
...remaining.slice(adjustedSlot),
];
onReorder(newOrder);
onReorder(newPages);
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
};
const handleDeleteClick = (pageIndex: number) => () => {
onDelete(pageIndex);
const handleDeleteClick = (pageId: string) => {
onDelete(pageId);
setDraggingIndex(null);
setDropIndex(null);
};
const handleRotateClickClockwise = (pageIndex: number) => () => {
onRotateClockwise(pageIndex);
};
const handleRotateClickCounterclockwise = (pageIndex: number) => () => {
onRotateCounterclockwise(pageIndex);
};
const handleCardClick = (pageIndex: number) => () => {
onOpenPreview(pageIndex);
};
const handleCheckboxClick =
(pageIndex: number, visualIndex: number) =>
(pageId: string, visualIndex: number) =>
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // don't trigger preview
onToggleSelect(pageIndex, visualIndex, e);
e.stopPropagation();
onToggleSelect(pageId, visualIndex, e);
};
const handleCopySelectedClick = () => {
if (selectedPageIds.length === 0) return;
setCopyTargetPosition(String(pages.length + 1));
setCopyDialogError(null);
setCopyDialogOpen(true);
};
const handleCopyDialogCancel = () => {
setCopyDialogOpen(false);
setCopyDialogError(null);
};
const handleCopyTargetPositionChange = (value: string) => {
setCopyTargetPosition(value);
setCopyDialogError(null);
};
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
e?.preventDefault();
if (selectedPageIds.length === 0) {
setCopyDialogError('No pages selected.');
return;
}
const maxPosition = pages.length + 1;
const parsed = Number.parseInt(copyTargetPosition.trim(), 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > maxPosition) {
setCopyDialogError(`Please enter a number between 1 and ${maxPosition}.`);
return;
}
onCopyPagesToSlot(selectedPageIds, parsed - 1);
setCopyDialogOpen(false);
setCopyDialogError(null);
};
if (!hasPdf) {
@@ -213,330 +274,68 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
);
}
const showLeftLine = (visualIndex: number) =>
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
const showRightLine = (visualIndex: number) =>
dropIndex !== null &&
dropIndex === visualIndex + 1 &&
draggingIndex !== null;
const showEndLine = () =>
dropIndex !== null && dropIndex === order.length && draggingIndex !== null;
// For highlighting the whole selection while dragging it
const draggingPageIndex =
draggingIndex != null ? order[draggingIndex] : null;
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
const draggingSelectionActive =
draggingPageIndex != null &&
selectedPages.length > 0 &&
selectedPages.includes(draggingPageIndex);
draggingPage != null &&
selectedPageIds.length > 0 &&
selectedPageIds.includes(draggingPage.id);
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
return (
<>
<div className="card">
<h2>Pages</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
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.
(Shift for ranges). Drag to reorder; dragging a selected page moves
the whole selection. Hold Ctrl/ while dropping to copy instead of
move. Shortcuts: Ctrl/+A selects all, Delete removes selected pages,
Esc clears selection.
</p>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
fontSize: '0.85rem',
}}
>
<span>
Selected: <strong>{selectedPages.length}</strong>
</span>
<div
style={{
display: 'flex',
gap: '0.4rem',
}}
>
{selectedPages.length > 0 && (
<button
type="button"
onClick={onDeleteSelected}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#fee2e2',
color: '#b91c1c',
cursor: 'pointer',
}}
>
Delete selected
</button>
)}
<button
type="button"
onClick={onSelectAll}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#8dcd8d',
color: '#111827',
cursor: 'pointer',
}}
>
Select all
</button>
<button
type="button"
onClick={onClearSelection}
disabled={selectedPages.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background:
selectedPages.length === 0 ? '#e5e7eb' : '#e5e7eb',
color:
selectedPages.length === 0 ? '#6b7280' : '#111827',
cursor: selectedPages.length === 0 ? 'default' : 'pointer',
}}
>
Clear selection
</button>
</div>
</div>
<PageSelectionToolbar
selectedCount={selectedPageIds.length}
onCopySelected={handleCopySelectedClick}
onDeleteSelected={onDeleteSelected}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
/>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
alignItems: 'flex-start',
marginBottom: '0.75rem',
}}
onDrop={handleDrop}
>
{order.map((pageIndex, visualIndex) => {
const thumb = thumbnails?.[pageIndex];
const rotation = rotations[pageIndex] ?? 0;
const selected = isSelected(pageIndex);
const isDraggingCard =
draggingIndex != null &&
((draggingSelectionActive && selected) ||
(!draggingSelectionActive && visualIndex === draggingIndex));
return (
<div
key={`${pageIndex}-${visualIndex}`}
draggable
onDragStart={handleDragStart(visualIndex)}
<PageGrid
pages={pages}
thumbnails={thumbnails}
selectedPageIds={selectedPageIds}
isBusy={isBusy}
draggingIndex={draggingIndex}
dropIndex={dropIndex}
draggingSelectionActive={draggingSelectionActive}
isCopyDragging={isCopyDragging}
dropIndicatorColor={dropIndicatorColor}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleCardDragOver(visualIndex)}
onClick={handleCardClick(pageIndex)}
style={{
position: 'relative',
width: '162px',
padding: '0.4rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb', // constant → no jump
background: isDraggingCard
? '#dbeafe'
: selected
? '#eff6ff'
: '#f9fafb',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
cursor: isBusy ? 'default' : 'grab',
opacity: isBusy ? 0.7 : 1,
}}
>
{/* selection checkbox */}
<button
type="button"
onClick={handleCheckboxClick(pageIndex, visualIndex)}
style={{
position: 'absolute',
top: '4px',
left: '4px',
width: '20px',
height: '20px',
borderRadius: '0.4rem',
border: '1px solid #9ca3af',
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
color: selected ? 'white' : 'transparent',
fontSize: '0.8rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
cursor: 'pointer',
}}
title="Select page"
>
</button>
{/* left drop indicator */}
{showLeftLine(visualIndex) && (
<div
style={{
position: 'absolute',
left: '-4px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: '#2563eb',
}}
/>
)}
{/* right drop indicator */}
{showRightLine(visualIndex) && (
<div
style={{
position: 'absolute',
right: '-4px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: '#2563eb',
}}
/>
)}
{thumb ? (
<img
src={thumb}
alt={`Page ${pageIndex + 1}`}
style={{
maxHeight: '90px',
width: 'auto',
borderRadius: '0.25rem',
border: '1px solid #e5e7eb',
background: 'white',
}}
/>
) : (
<div
style={{
width: '60px',
height: '80px',
borderRadius: '0.25rem',
border: '1px dashed #d1d5db',
background: '#f3f4f6',
}}
/>
)}
<span style={{ fontSize: '0.8rem' }}>Page {pageIndex + 1}</span>
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
Pos {visualIndex + 1} · Rot {rotation}°
</span>
<div
style={{
display: 'flex',
gap: '0.25rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRotateClickClockwise(pageIndex)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRotateClickCounterclockwise(pageIndex)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(pageIndex)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#fecaca',
color: '#b91c1c',
cursor: 'pointer',
}}
title="Remove this page from the exported PDF"
>
</button>
</div>
</div>
);
})}
{/* end slot for dropping after the last card */}
{order.length > 0 && (
<div
onDragOver={handleEndSlotDragOver}
onCardDragOver={handleCardDragOver}
onEndSlotDragOver={handleEndSlotDragOver}
onDrop={handleDrop}
style={{
width: '20px',
height: '120px',
position: 'relative',
alignSelf: 'stretch',
}}
>
{showEndLine() && (
<div
style={{
position: 'absolute',
left: '8px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: '#2563eb',
}}
onOpenPreview={onOpenPreview}
onToggleSelect={handleCheckboxClick}
onRotateClockwise={onRotateClockwise}
onRotateCounterclockwise={onRotateCounterclockwise}
onDelete={handleDeleteClick}
/>
</div>
{copyDialogOpen && (
<CopyPagesDialog
selectedCount={selectedPageIds.length}
pageCount={pages.length}
targetPosition={copyTargetPosition}
error={copyDialogError}
onTargetPositionChange={handleCopyTargetPositionChange}
onCancel={handleCopyDialogCancel}
onConfirm={handleCopyDialogConfirm}
/>
)}
</div>
)}
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,327 @@
import React from 'react';
import type { WorkspaceSummary } from '../workspace/workspaceTypes';
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
interface WorkspacePanelProps {
hasPdf: boolean;
isBusy: boolean;
activeWorkspaceId: string | null;
workspaceName: string;
workspaceDirty: boolean;
workspaceMessage: string | null;
workspaces: WorkspaceSummary[];
history: WorkspaceCommandRecord[];
redoHistory: WorkspaceCommandRecord[];
onWorkspaceNameChange: (value: string) => void;
onSaveWorkspace: () => void;
onLoadWorkspace: (workspaceId: string) => void;
onDeleteWorkspace: (workspaceId: string) => void;
onRefreshWorkspaces: () => void;
onResetWorkspace: () => void;
onUndo: () => void;
onRedo: () => void;
}
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
hasPdf,
isBusy,
activeWorkspaceId,
workspaceName,
workspaceDirty,
workspaceMessage,
workspaces,
history,
redoHistory,
onWorkspaceNameChange,
onSaveWorkspace,
onLoadWorkspace,
onDeleteWorkspace,
onRefreshWorkspaces,
onResetWorkspace,
onUndo,
onRedo,
}) => {
const canUndo = history.length > 0;
const canRedo = redoHistory.length > 0;
const latestUndo = history[history.length - 1];
const latestRedo = redoHistory[redoHistory.length - 1];
return (
<div className="card">
<h2>Workspace</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Save named workspaces in this browser. PDF binaries are stored in
IndexedDB; nothing is uploaded.
</p>
<div
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<input
type="text"
value={workspaceName}
onChange={(e) => onWorkspaceNameChange(e.target.value)}
placeholder="Workspace name"
disabled={!hasPdf || isBusy}
style={{
flex: '1 1 220px',
minWidth: 0,
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.9rem',
}}
/>
<button
type="button"
className="secondary"
onClick={onUndo}
disabled={!hasPdf || isBusy || !canUndo}
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
>
Undo
</button>
<button
type="button"
className="secondary"
onClick={onRedo}
disabled={!hasPdf || isBusy || !canRedo}
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
>
Redo
</button>
<button
type="button"
className="secondary"
onClick={onSaveWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
>
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
</button>
<button
type="button"
className="secondary"
onClick={onResetWorkspace}
disabled={!hasPdf || isBusy}
title={
!hasPdf ? 'No active workspace' : 'Close the current workspace'
}
>
Reset workspace
</button>
<button
type="button"
className="secondary"
onClick={onRefreshWorkspaces}
disabled={isBusy}
>
Refresh
</button>
</div>
{workspaceDirty && hasPdf && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.8rem',
color: '#92400e',
}}
>
Unsaved workspace changes.
</div>
)}
{workspaceMessage && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.85rem',
color: '#166534',
}}
>
{workspaceMessage}
</div>
)}
{workspaces.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.4rem',
}}
>
{workspaces.map((workspace) => {
const active = workspace.id === activeWorkspaceId;
return (
<div
key={workspace.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
padding: '0.5rem',
background: active ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.9rem' }}>
<strong>{workspace.name}</strong>
{active && (
<span style={{ color: '#2563eb' }}> · active</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
{workspace.pdfName} · source pages:{' '}
{workspace.sourcePageCount} · workspace pages:{' '}
{workspace.workspacePageCount} · undo:{' '}
{workspace.historyCount} · redo: {workspace.redoCount} ·
updated {new Date(workspace.updatedAt).toLocaleString()}
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.35rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onLoadWorkspace(workspace.id)}
>
Load
</button>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onDeleteWorkspace(workspace.id)}
style={{
background: '#fee2e2',
color: '#991b1b',
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(history.length > 0 || redoHistory.length > 0) && (
<details style={{ marginTop: '0.75rem' }} open>
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Command history ({history.length} undo / {redoHistory.length} redo)
</summary>
<div
style={{
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
{history.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#374151',
borderLeft: '3px solid #2563eb',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
}}
>
<strong>
Undo {history.length - index}. {entry.label}
</strong>
<br />
<span style={{ color: '#6b7280' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
<div
style={{
margin: '0.25rem 0',
borderRadius: '999px',
background: '#ecfdf5',
color: '#166534',
fontSize: '0.8rem',
fontWeight: 600,
alignSelf: 'flex-start',
border: '2px solid #166534',
width: '100%',
}}
></div>
{redoHistory
.slice()
.reverse()
.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#9ca3af',
borderLeft: '3px solid #d1d5db',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
opacity: 0.75,
}}
>
<strong>
Redo {index + 1}. {entry.label}
</strong>
<br />
<span style={{ color: '#9ca3af' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
</div>
</details>
)}
</div>
);
};
export default WorkspacePanel;

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SplitResult } from '../pdf/pdfTypes';
export interface PdfDownload {
id: string;
filename: string;
url: string;
}
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);
}
}
function revokeDownloads(downloads: PdfDownload[]): void {
downloads.forEach(revokeDownload);
}
function createDownload(id: string, filename: string, blob: Blob): PdfDownload {
return {
id,
filename,
url: URL.createObjectURL(blob),
};
}
export function usePdfGeneratedOutputs() {
const [splitDownloads, setSplitDownloads] = useState<SplitPdfDownload[]>([]);
const [subsetDownload, setSubsetDownload] = useState<PdfDownload | null>(
null
);
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[], zipResult?: PdfBlobResult) => {
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
...createDownload(
`split-${result.pageIndex}-${result.filename}`,
result.filename,
result.blob
),
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) => {
const nextDownload = createDownload('subset', filename, blob);
revokeDownload(subsetDownloadRef.current);
subsetDownloadRef.current = nextDownload;
setSubsetDownload(nextDownload);
}, []);
const clearSubsetResult = useCallback(() => {
revokeDownload(subsetDownloadRef.current);
subsetDownloadRef.current = null;
setSubsetDownload(null);
}, []);
const replaceExportResult = useCallback((blob: Blob, filename: string) => {
const nextDownload = createDownload('export', filename, blob);
revokeDownload(exportDownloadRef.current);
exportDownloadRef.current = nextDownload;
setExportDownload(nextDownload);
}, []);
const clearExportResult = useCallback(() => {
revokeDownload(exportDownloadRef.current);
exportDownloadRef.current = null;
setExportDownload(null);
}, []);
const clearAllResults = useCallback(() => {
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(() => {
return () => {
revokeDownloads(splitDownloadsRef.current);
revokeDownload(subsetDownloadRef.current);
revokeDownload(exportDownloadRef.current);
revokeDownload(splitZipDownloadRef.current);
};
}, []);
return {
splitDownloads,
subsetDownload,
exportDownload,
splitZipDownload,
replaceSplitResults,
clearSplitResults,
replaceSubsetResult,
clearSubsetResult,
replaceExportResult,
clearExportResult,
clearAllResults,
};
}

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,10 +1,20 @@
import { PDFDocument, degrees } from 'pdf-lib';
import type { PdfFile, SplitResult, Range } from './pdfTypes';
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
function createId() {
return Math.random().toString(36).slice(2);
}
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function pdfBytesToBlob(bytes: Uint8Array): Blob {
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: 'application/pdf' });
}
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFDocument.load(arrayBuffer);
@@ -23,8 +33,8 @@ export async function mergePdfFiles(
newPdf: PdfFile,
insertAt: number
): Promise<PdfFile> {
const baseDoc = await PDFDocument.load(basePdf.arrayBuffer);
const newDoc = await PDFDocument.load(newPdf.arrayBuffer);
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
const mergedDoc = await PDFDocument.create();
@@ -42,34 +52,98 @@ export async function mergePdfFiles(
Array.from({ length: newPageCount }, (_, i) => i)
);
// base pages before insertion
for (let i = 0; i < clampedInsertAt; i += 1) {
mergedDoc.addPage(basePages[i]);
}
// inserted new pages
for (let i = 0; i < newPages.length; i += 1) {
mergedDoc.addPage(newPages[i]);
}
// remaining base pages
for (let i = clampedInsertAt; i < basePages.length; i += 1) {
mergedDoc.addPage(basePages[i]);
}
const bytes = await mergedDoc.save();
const buffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
);
const buffer = pdfBytesToArrayBuffer(bytes);
const baseName = basePdf.name.replace(/\.pdf$/i, '');
const newName = newPdf.name.replace(/\.pdf$/i, '');
return {
id: createId(),
name: `${baseName}_plus_${newName}.pdf`,
arrayBuffer: buffer,
pageCount: basePageCount + newPageCount,
pageCount: mergedDoc.getPageCount(),
doc: mergedDoc,
};
}
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,
};
}
@@ -97,14 +171,14 @@ export async function splitIntoSinglePages(
if (title) newDoc.setTitle(title);
if (author) newDoc.setAuthor(author);
if (subject) newDoc.setSubject(subject);
if (keywords) newDoc.setKeywords(keywords);
if (keywords) newDoc.setKeywords([keywords]);
if (producer) newDoc.setProducer(producer);
if (creator) newDoc.setCreator(creator);
if (creationDate) newDoc.setCreationDate(creationDate);
if (modificationDate) newDoc.setModificationDate(modificationDate);
const bytes = await newDoc.save();
const blob = new Blob([bytes], { type: 'application/pdf' });
const blob = pdfBytesToBlob(bytes);
const base = name.replace(/\.pdf$/i, '');
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
@@ -119,10 +193,7 @@ export async function splitIntoSinglePages(
return results;
}
export async function extractRange(
pdf: PdfFile,
range: Range
): Promise<Blob> {
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
@@ -141,7 +212,7 @@ export async function extractRange(
copiedPages.forEach((p) => newDoc.addPage(p));
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
@@ -155,33 +226,35 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
}
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportReordered(
export async function exportPages(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
pages: PageRef[]
): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
if (order.length === 0) {
throw new Error('Order must contain at least one page');
if (pages.length === 0) {
throw new Error('Pages must contain at least one page');
}
if (order.some((i) => i < 0 || i >= pageCount)) {
throw new Error('Order contains invalid page indices');
if (
pages.some(
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount
)
) {
throw new Error('Pages contain invalid source page indices');
}
const newDoc = await PDFDocument.create();
const indices = [...order];
const indices = pages.map((page) => page.sourcePageIndex);
const copiedPages = await newDoc.copyPages(doc, indices);
copiedPages.forEach((page, idx) => {
const originalIndex = indices[idx];
const angle = rotations?.[originalIndex];
const angle = pages[idx].rotation;
if (typeof angle === 'number' && angle % 360 !== 0) {
page.setRotation(degrees(angle));
@@ -191,5 +264,20 @@ export async function exportReordered(
});
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportReordered(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
): Promise<Blob> {
return exportPages(
pdf,
order.map((sourcePageIndex) => ({
id: String(sourcePageIndex),
sourcePageIndex,
rotation: rotations?.[sourcePageIndex] ?? 0,
}))
);
}

View File

@@ -19,78 +19,163 @@ function makePdfJsDataCopy(arrayBuffer: ArrayBuffer): Uint8Array {
return copy;
}
interface ThumbnailUpdate {
pageIndex: number;
dataUrl: string;
}
interface ThumbnailGenerationOptions {
maxHeight?: number;
maxWidth?: number;
concurrency?: number;
/**
* Optional subset of 0-based page indices to render.
* If omitted, all pages are rendered.
*/
pageIndices?: number[];
signal?: AbortSignal;
onThumbnail?: (update: ThumbnailUpdate) => void;
}
/**
* Unrotated thumbnails used e.g. in the Split/Extract view.
*/
export async function generateThumbnails(
export async function generateThumbnailsProgressive(
arrayBuffer: ArrayBuffer,
maxHeight = 150
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, {}, maxHeight);
return generateThumbnailsInternal(arrayBuffer, {}, options);
}
/**
* Thumbnails that respect per-page rotations (for the Reorder view).
*/
export async function generateThumbnailsWithRotations(
export async function generateThumbnailsWithRotationsProgressive(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
maxHeight = 150
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight);
return generateThumbnailsInternal(arrayBuffer, rotations, options);
}
async function generateThumbnailsInternal(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
maxHeight: number
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
// IMPORTANT: use a COPY so pdf.js can detach it without breaking future calls
const dataCopy = makePdfJsDataCopy(arrayBuffer);
const maxHeight = options.maxHeight ?? 150;
const maxWidth = options.maxWidth ?? 140;
const concurrency = Math.max(1, Math.min(options.concurrency ?? 3, 6));
const signal = options.signal;
const dataCopy = makePdfJsDataCopy(arrayBuffer);
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
const pdf = await loadingTask.promise;
const thumbs: string[] = [];
const thumbs = Array<string>(pdf.numPages).fill('');
const pageNums = options.pageIndices
? Array.from(
new Set(
options.pageIndices
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
.map((pageIndex) => pageIndex + 1)
)
)
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
let nextPageIndex = 0;
const renderOne = async (pageNum: number) => {
if (signal?.aborted) return;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
if (signal?.aborted) return;
const pageIndex = pageNum - 1;
const dataUrl = await renderPageThumbnail(
page,
pageIndex,
rotations,
maxHeight,
maxWidth
);
if (signal?.aborted) return;
thumbs[pageIndex] = dataUrl;
options.onThumbnail?.({ pageIndex, dataUrl });
};
const worker = async () => {
while (!signal?.aborted) {
const pageNum = pageNums[nextPageIndex];
nextPageIndex += 1;
if (pageNum == null) return;
await renderOne(pageNum);
// Let React/browser paint between batches.
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
};
const workerCount = Math.min(concurrency, pageNums.length);
if (workerCount === 0) return thumbs;
await Promise.all(Array.from({ length: workerCount }, worker));
return thumbs;
}
async function renderPageThumbnail(
page: Awaited<
ReturnType<
Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']
>
>,
originalIndex: number,
rotations: RotationsMap,
maxHeight: number,
maxWidth: number
): Promise<string> {
const viewport = page.getViewport({ scale: 1 });
const scale = maxHeight / viewport.height;
const scaleH = maxHeight / viewport.height;
const scaleW = maxWidth / viewport.width;
const scale = Math.min(scaleH, scaleW);
const scaledViewport = page.getViewport({ scale });
// First render unrotated page into a canvas
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) {
thumbs.push('');
continue;
}
if (!baseCtx) return '';
baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx,
viewport: scaledViewport,
});
await renderTask.promise;
const originalIndex = pageNum - 1;
const rotationDegRaw = rotations[originalIndex] ?? 0;
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; // normalize 0359
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
if (rotationDeg === 0) {
thumbs.push(baseCanvas.toDataURL('image/png'));
continue;
return baseCanvas.toDataURL('image/png');
}
// Re-render onto a second canvas with rotation applied
const rotatedCanvas = document.createElement('canvas');
const rotatedCtx = rotatedCanvas.getContext('2d');
if (!rotatedCtx) {
thumbs.push(baseCanvas.toDataURL('image/png'));
continue;
return baseCanvas.toDataURL('image/png');
}
const rad = (rotationDeg * Math.PI) / 180;
@@ -118,16 +203,10 @@ async function generateThumbnailsInternal(
rotatedCtx.translate(0, rotatedCanvas.height);
rotatedCtx.rotate(rad);
break;
default:
// fallback: no rotation
break;
}
rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore();
thumbs.push(rotatedCanvas.toDataURL('image/png'));
}
return thumbs;
return rotatedCanvas.toDataURL('image/png');
}

View File

@@ -8,6 +8,12 @@ export interface PdfFile {
arrayBuffer: ArrayBuffer;
}
export interface PageRef {
id: string;
sourcePageIndex: number;
rotation: number;
}
export interface SplitResult {
pageIndex: number;
blob: Blob;

View 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
View 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');
}

212
src/pdf/usePdfThumbnails.ts Normal file
View File

@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PageRef, PdfFile } from './pdfTypes';
import { generateThumbnailsWithRotationsProgressive } from './pdfThumbnailService';
import { normalizeRotation } from '../workspace/useWorkspaceState';
const DEFAULT_MAX_HEIGHT = 150;
const DEFAULT_MAX_WIDTH = 140;
const DEFAULT_CONCURRENCY = 3;
interface UsePdfThumbnailsOptions {
pdf: PdfFile | null;
pages: PageRef[];
maxHeight?: number;
maxWidth?: number;
concurrency?: number;
onError?: (message: string, error: unknown) => void;
}
interface UsePdfThumbnailsResult {
thumbnails: Record<string, string>;
resetThumbnails: () => void;
clearThumbnailCache: () => void;
}
function thumbnailCacheKey(
pdfId: string,
sourcePageIndex: number,
rotation: number,
maxWidth: number,
maxHeight: number
): string {
return [
pdfId,
sourcePageIndex,
normalizeRotation(rotation),
maxWidth,
maxHeight,
].join(':');
}
function pruneAndMergeThumbnails(
previous: Record<string, string>,
pages: PageRef[],
updates: Record<string, string>
): Record<string, string> {
const pageIds = new Set(pages.map((page) => page.id));
const next: Record<string, string> = {};
for (const [pageId, thumbnail] of Object.entries(previous)) {
if (pageIds.has(pageId)) {
next[pageId] = thumbnail;
}
}
return {
...next,
...updates,
};
}
export function usePdfThumbnails({
pdf,
pages,
maxHeight = DEFAULT_MAX_HEIGHT,
maxWidth = DEFAULT_MAX_WIDTH,
concurrency = DEFAULT_CONCURRENCY,
onError,
}: UsePdfThumbnailsOptions): UsePdfThumbnailsResult {
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const thumbnailCacheRef = useRef<Map<string, string>>(new Map());
const latestPagesRef = useRef<PageRef[]>(pages);
const previousPdfIdRef = useRef<string | null>(null);
useEffect(() => {
latestPagesRef.current = pages;
}, [pages]);
const resetThumbnails = useCallback(() => {
setThumbnails({});
}, []);
const clearThumbnailCache = useCallback(() => {
thumbnailCacheRef.current.clear();
setThumbnails({});
}, []);
useEffect(() => {
const currentPdfId = pdf?.id ?? null;
if (!pdf) {
previousPdfIdRef.current = null;
clearThumbnailCache();
return;
}
if (previousPdfIdRef.current !== currentPdfId) {
previousPdfIdRef.current = currentPdfId;
clearThumbnailCache();
}
}, [clearThumbnailCache, pdf]);
useEffect(() => {
if (!pdf) return;
const controller = new AbortController();
const cachedUpdates: Record<string, string> = {};
const renderGroups = new Map<number, Set<number>>();
for (const page of pages) {
const rotation = normalizeRotation(page.rotation);
const cacheKey = thumbnailCacheKey(
pdf.id,
page.sourcePageIndex,
rotation,
maxWidth,
maxHeight
);
const cached = thumbnailCacheRef.current.get(cacheKey);
if (cached) {
cachedUpdates[page.id] = cached;
continue;
}
const pageIndices = renderGroups.get(rotation) ?? new Set<number>();
pageIndices.add(page.sourcePageIndex);
renderGroups.set(rotation, pageIndices);
}
setThumbnails((previous) =>
pruneAndMergeThumbnails(previous, pages, cachedUpdates)
);
if (renderGroups.size === 0) return;
const renderMissingThumbnails = async () => {
for (const [rotation, pageIndexSet] of renderGroups) {
if (controller.signal.aborted) return;
const pageIndices = Array.from(pageIndexSet);
const rotationsBySourcePage: Record<number, number> = {};
for (const pageIndex of pageIndices) {
rotationsBySourcePage[pageIndex] = rotation;
}
await generateThumbnailsWithRotationsProgressive(
pdf.arrayBuffer,
rotationsBySourcePage,
{
maxHeight,
maxWidth,
concurrency: Math.min(concurrency, pageIndices.length),
pageIndices,
signal: controller.signal,
onThumbnail: ({ pageIndex, dataUrl }) => {
if (controller.signal.aborted) return;
thumbnailCacheRef.current.set(
thumbnailCacheKey(
pdf.id,
pageIndex,
rotation,
maxWidth,
maxHeight
),
dataUrl
);
const updates: Record<string, string> = {};
for (const page of latestPagesRef.current) {
if (
page.sourcePageIndex === pageIndex &&
normalizeRotation(page.rotation) === rotation
) {
updates[page.id] = dataUrl;
}
}
if (Object.keys(updates).length === 0) return;
setThumbnails((previous) =>
pruneAndMergeThumbnails(
previous,
latestPagesRef.current,
updates
)
);
},
}
);
}
};
void renderMissingThumbnails().catch((error) => {
if (!controller.signal.aborted) {
onError?.('Failed to generate thumbnails (see console).', error);
}
});
return () => {
controller.abort();
};
}, [concurrency, maxHeight, maxWidth, onError, pages, pdf]);
return {
thumbnails,
resetThumbnails,
clearThumbnailCache,
};
}

View File

@@ -6,7 +6,11 @@
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background-color: #f3f4f6;
color: #111827;
@@ -120,12 +124,6 @@ button.secondary {
margin-top: 0.5rem;
}
.page-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.app-root {
min-height: 100vh;
background-color: #f3f4f6;
@@ -175,20 +173,6 @@ button.secondary {
width: 100%;
}
/* Slightly less rounded page pills so they look like rectangles */
.page-pill {
padding: 0.2rem 0.5rem;
border-radius: 0.5rem; /* was 999px */
border: 1px solid #e5e7eb;
font-size: 0.8rem;
background: #f9fafb;
}
.page-pill.selected {
background: #dbeafe;
border-color: #93c5fd;
}
.download-link {
display: inline-block;
margin: 0.15rem 0;
@@ -199,3 +183,309 @@ button.secondary {
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;
}
.help-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(15, 23, 42, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.help-dialog-panel {
width: min(920px, 100%);
max-height: min(88vh, 760px);
overflow: auto;
background: #ffffff;
border-radius: 0.9rem;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
}
.help-dialog-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.help-dialog-header h2 {
margin: 0;
font-size: 1.1rem;
}
.help-dialog-header p {
margin: 0.35rem 0 0;
color: #4b5563;
font-size: 0.9rem;
line-height: 1.45;
}
.help-close-button {
flex: 0 0 auto;
border: none;
border-radius: 999px;
width: 2rem;
height: 2rem;
background: #e5e7eb;
color: #111827;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
}
.help-dialog-content {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
gap: 1rem;
padding: 1rem;
}
.help-section {
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 0.9rem;
background: #f9fafb;
}
.help-section h3 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
.help-step-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.help-step {
border-radius: 0.65rem;
background: #ffffff;
border: 1px solid #e5e7eb;
padding: 0.75rem;
}
.help-step h4 {
margin: 0;
font-size: 0.9rem;
}
.help-step p,
.help-note {
margin: 0.35rem 0 0;
color: #4b5563;
font-size: 0.85rem;
line-height: 1.45;
}
.shortcut-grid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.45rem 0.75rem;
align-items: center;
}
.shortcut-grid kbd {
display: inline-flex;
justify-content: center;
border-radius: 0.4rem;
border: 1px solid #d1d5db;
background: #ffffff;
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;
white-space: nowrap;
}
.shortcut-grid span {
color: #4b5563;
font-size: 0.85rem;
line-height: 1.35;
}
.help-concepts {
grid-column: 1 / -1;
}
.help-concepts dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.help-concepts dl > div {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.65rem;
padding: 0.75rem;
}
.help-concepts dt {
font-weight: 700;
font-size: 0.85rem;
margin-bottom: 0.3rem;
}
.help-concepts dd {
margin: 0;
color: #4b5563;
font-size: 0.82rem;
line-height: 1.4;
}
@media (max-width: 760px) {
.help-dialog-content,
.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 #bfdbfe;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.8);
padding: 0.6rem;
}
.merge-queue-order {
border-radius: 999px;
background: #dbeafe;
color: #1e3a8a;
font-size: 0.8rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
}
.merge-queue-details {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
font-size: 0.9rem;
}
.merge-queue-details strong {
overflow-wrap: anywhere;
}
.merge-queue-details span {
color: #4b5563;
font-size: 0.8rem;
}
.merge-queue-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.35rem;
}
.merge-queue-actions button.secondary {
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
}
.merge-mode-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
font-size: 0.9rem;
}
.merge-mode-group label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.merge-warning {
margin: 0.75rem 0 0;
border: 1px solid #fed7aa;
border-radius: 0.5rem;
background: #fff7ed;
color: #9a3412;
padding: 0.55rem 0.65rem;
font-size: 0.85rem;
}
@media (max-width: 700px) {
.merge-queue-item {
grid-template-columns: 1fr;
align-items: stretch;
}
.merge-queue-actions {
justify-content: flex-start;
}
}

1
src/version.ts Normal file
View File

@@ -0,0 +1 @@
export const APP_VERSION = '0.3.2';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,208 @@
import React, { forwardRef, useImperativeHandle } from 'react';
import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { PageRef } from '../pdf/pdfTypes';
import type {
WorkspaceCommandRecord,
WorkspaceCommandState,
} from './workspaceCommands';
import { useWorkspaceState } from './useWorkspaceState';
function page(id: string, sourcePageIndex: number, rotation = 0): PageRef {
return { id, sourcePageIndex, rotation };
}
function state(
pages: PageRef[],
selectedPageIds: string[] = [],
lastSelectedVisualIndex: number | null = null
): WorkspaceCommandState {
return { pages, selectedPageIds, lastSelectedVisualIndex };
}
interface HarnessRef {
snapshot: () => {
pages: PageRef[];
selectedPageIds: string[];
lastSelectedVisualIndex: number | null;
workspaceDirty: boolean;
workspaceMessage: string | null;
workspaceHistory: WorkspaceCommandRecord[];
redoHistory: WorkspaceCommandRecord[];
};
replaceWorkspaceState: ReturnType<
typeof useWorkspaceState
>['replaceWorkspaceState'];
getCurrentCommandState: ReturnType<
typeof useWorkspaceState
>['getCurrentCommandState'];
createWorkspaceCommand: ReturnType<
typeof useWorkspaceState
>['createWorkspaceCommand'];
executeWorkspaceCommand: ReturnType<
typeof useWorkspaceState
>['executeWorkspaceCommand'];
handleUndo: ReturnType<typeof useWorkspaceState>['handleUndo'];
handleRedo: ReturnType<typeof useWorkspaceState>['handleRedo'];
}
const Harness = forwardRef<HarnessRef, { onContentChanged?: () => void }>(
({ onContentChanged }, ref) => {
const workspace = useWorkspaceState({ onContentChanged });
useImperativeHandle(ref, () => ({
snapshot: () => ({
pages: workspace.pages,
selectedPageIds: workspace.selectedPageIds,
lastSelectedVisualIndex: workspace.lastSelectedVisualIndex,
workspaceDirty: workspace.workspaceDirty,
workspaceMessage: workspace.workspaceMessage,
workspaceHistory: workspace.workspaceHistory,
redoHistory: workspace.redoHistory,
}),
replaceWorkspaceState: workspace.replaceWorkspaceState,
getCurrentCommandState: workspace.getCurrentCommandState,
createWorkspaceCommand: workspace.createWorkspaceCommand,
executeWorkspaceCommand: workspace.executeWorkspaceCommand,
handleUndo: workspace.handleUndo,
handleRedo: workspace.handleRedo,
}));
return null;
}
);
function renderHarness(onContentChanged = vi.fn()) {
const ref = React.createRef<HarnessRef>();
render(<Harness ref={ref} onContentChanged={onContentChanged} />);
if (!ref.current) {
throw new Error('Harness ref was not initialized');
}
return { ref, onContentChanged };
}
describe('useWorkspaceState', () => {
it('replaces workspace state from loaded data without marking it dirty', () => {
const { ref } = renderHarness();
const loadedPages = [page('p1', 0), page('p2', 1, 90)];
act(() => {
ref.current?.replaceWorkspaceState({
pages: loadedPages,
selectedPageIds: ['p2'],
lastSelectedVisualIndex: 1,
history: [],
redoHistory: [],
dirty: false,
message: 'Workspace loaded.',
});
});
expect(ref.current?.snapshot()).toMatchObject({
pages: loadedPages,
selectedPageIds: ['p2'],
lastSelectedVisualIndex: 1,
workspaceDirty: false,
workspaceMessage: 'Workspace loaded.',
workspaceHistory: [],
redoHistory: [],
});
});
it('executes commands, stores history, clears redo, and marks content changed', () => {
const { ref, onContentChanged } = renderHarness();
const before = state([page('p1', 0), page('p2', 1)], ['p1'], 0);
const after = state([page('p2', 1), page('p1', 0)], ['p2'], 0);
act(() => {
ref.current?.replaceWorkspaceState({
...before,
redoHistory: [
{
id: 'redo-record',
type: 'old-redo',
label: 'Old redo',
timestamp: '2026-05-17T10:00:00.000Z',
payload: { before, after },
},
],
});
});
act(() => {
const command = ref.current?.createWorkspaceCommand({
type: 'reorder-pages',
label: 'Move page 2 before page 1',
before,
after,
});
if (!command) throw new Error('Command was not created');
ref.current?.executeWorkspaceCommand(command);
});
const snapshot = ref.current?.snapshot();
expect(snapshot?.pages).toEqual(after.pages);
expect(snapshot?.selectedPageIds).toEqual(['p2']);
expect(snapshot?.workspaceDirty).toBe(true);
expect(snapshot?.workspaceMessage).toBeNull();
expect(snapshot?.workspaceHistory).toHaveLength(1);
expect(snapshot?.workspaceHistory[0]).toMatchObject({
type: 'reorder-pages',
label: 'Move page 2 before page 1',
});
expect(snapshot?.redoHistory).toHaveLength(0);
expect(onContentChanged).toHaveBeenCalledTimes(1);
});
it('undoes and redoes command records in stack order', () => {
const { ref, onContentChanged } = renderHarness();
const initial = state([page('p1', 0), page('p2', 1)], ['p1'], 0);
const reordered = state([page('p2', 1), page('p1', 0)], ['p2'], 0);
act(() => {
ref.current?.replaceWorkspaceState(initial);
});
act(() => {
const command = ref.current?.createWorkspaceCommand({
type: 'reorder-pages',
label: 'Move page',
before: initial,
after: reordered,
});
if (!command) throw new Error('Command was not created');
ref.current?.executeWorkspaceCommand(command);
});
act(() => {
ref.current?.handleUndo();
});
expect(ref.current?.snapshot()).toMatchObject({
pages: initial.pages,
selectedPageIds: initial.selectedPageIds,
lastSelectedVisualIndex: initial.lastSelectedVisualIndex,
workspaceDirty: true,
});
expect(ref.current?.snapshot().workspaceHistory).toHaveLength(0);
expect(ref.current?.snapshot().redoHistory).toHaveLength(1);
act(() => {
ref.current?.handleRedo();
});
expect(ref.current?.snapshot()).toMatchObject({
pages: reordered.pages,
selectedPageIds: reordered.selectedPageIds,
lastSelectedVisualIndex: reordered.lastSelectedVisualIndex,
workspaceDirty: true,
});
expect(ref.current?.snapshot().workspaceHistory).toHaveLength(1);
expect(ref.current?.snapshot().redoHistory).toHaveLength(0);
expect(onContentChanged).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,250 @@
import { useCallback, useRef, useState } from 'react';
import type { PageRef } from '../pdf/pdfTypes';
import type {
WorkspaceCommand,
WorkspaceCommandRecord,
WorkspaceCommandState,
} from './workspaceCommands';
import {
createSnapshotCommand,
reviveWorkspaceCommand,
toWorkspaceCommandRecord,
} from './workspaceCommands';
function createId(prefix: string): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
export function createWorkspaceId(): string {
return createId('workspace');
}
export function createPdfId(): string {
return createId('pdf');
}
export function defaultWorkspaceNameFromPdfName(pdfName: string): string {
return pdfName.replace(/\.pdf$/i, '') || 'Untitled workspace';
}
export function createPageRefId(): string {
return createId('page');
}
export function createInitialPageRefs(pageCount: number): PageRef[] {
return Array.from({ length: pageCount }, (_, sourcePageIndex) => ({
id: createPageRefId(),
sourcePageIndex,
rotation: 0,
}));
}
export function normalizeRotation(rotation: number | undefined): number {
return (((rotation ?? 0) % 360) + 360) % 360;
}
type SetStateAction<T> = T | ((previous: T) => T);
interface UseWorkspaceStateOptions {
onContentChanged?: () => void;
}
interface ReplaceWorkspaceStateOptions {
pages?: PageRef[];
selectedPageIds?: string[];
lastSelectedVisualIndex?: number | null;
history?: WorkspaceCommandRecord[];
redoHistory?: WorkspaceCommandRecord[];
dirty?: boolean;
message?: string | null;
}
export function useWorkspaceState({
onContentChanged,
}: UseWorkspaceStateOptions = {}) {
const [pages, setPagesState] = useState<PageRef[]>([]);
const [selectedPageIds, setSelectedPageIdsState] = useState<string[]>([]);
const [lastSelectedVisualIndex, setLastSelectedVisualIndexState] = useState<
number | null
>(null);
const [workspaceDirty, setWorkspaceDirty] = useState(false);
const [workspaceMessage, setWorkspaceMessage] = useState<string | null>(null);
const [workspaceHistory, setWorkspaceHistory] = useState<
WorkspaceCommandRecord[]
>([]);
const [redoHistory, setRedoHistory] = useState<WorkspaceCommandRecord[]>([]);
const latestPagesRef = useRef<PageRef[]>([]);
const selectedPageIdsRef = useRef<string[]>([]);
const lastSelectedVisualIndexRef = useRef<number | null>(null);
const setPages = useCallback((action: SetStateAction<PageRef[]>) => {
setPagesState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
latestPagesRef.current = next;
return next;
});
}, []);
const setSelectedPageIds = useCallback((action: SetStateAction<string[]>) => {
setSelectedPageIdsState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
selectedPageIdsRef.current = next;
return next;
});
}, []);
const setLastSelectedVisualIndex = useCallback(
(action: SetStateAction<number | null>) => {
setLastSelectedVisualIndexState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
lastSelectedVisualIndexRef.current = next;
return next;
});
},
[]
);
const getCurrentCommandState = useCallback(
(): WorkspaceCommandState => ({
pages: latestPagesRef.current,
selectedPageIds: selectedPageIdsRef.current,
lastSelectedVisualIndex: lastSelectedVisualIndexRef.current,
}),
[]
);
const applyCommandState = useCallback(
(state: WorkspaceCommandState) => {
setPages(state.pages);
setSelectedPageIds(state.selectedPageIds);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex);
},
[setLastSelectedVisualIndex, setPages, setSelectedPageIds]
);
const markWorkspaceChanged = useCallback(() => {
setWorkspaceDirty(true);
setWorkspaceMessage(null);
onContentChanged?.();
}, [onContentChanged]);
const createWorkspaceCommand = useCallback(
(params: {
type: string;
label: string;
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}): WorkspaceCommand =>
createSnapshotCommand({
id: createId('command'),
type: params.type,
label: params.label,
before: params.before,
after: params.after,
details: params.details,
}),
[]
);
const executeWorkspaceCommand = useCallback(
(command: WorkspaceCommand) => {
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setWorkspaceHistory((previous) => [
...previous,
toWorkspaceCommandRecord(command),
]);
setRedoHistory([]);
markWorkspaceChanged();
},
[applyCommandState, getCurrentCommandState, markWorkspaceChanged]
);
const handleUndo = useCallback(() => {
const record = workspaceHistory[workspaceHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const previousState = command.undo(getCurrentCommandState());
applyCommandState(previousState);
setWorkspaceHistory((previous) => previous.slice(0, -1));
setRedoHistory((previous) => [...previous, record]);
markWorkspaceChanged();
}, [
applyCommandState,
getCurrentCommandState,
markWorkspaceChanged,
workspaceHistory,
]);
const handleRedo = useCallback(() => {
const record = redoHistory[redoHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setRedoHistory((previous) => previous.slice(0, -1));
setWorkspaceHistory((previous) => [...previous, record]);
markWorkspaceChanged();
}, [
applyCommandState,
getCurrentCommandState,
markWorkspaceChanged,
redoHistory,
]);
const replaceWorkspaceState = useCallback(
(state: ReplaceWorkspaceStateOptions = {}) => {
setPages(state.pages ?? []);
setSelectedPageIds(state.selectedPageIds ?? []);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex ?? null);
setWorkspaceHistory(state.history ?? []);
setRedoHistory(state.redoHistory ?? []);
setWorkspaceDirty(state.dirty ?? false);
setWorkspaceMessage(state.message ?? null);
},
[setLastSelectedVisualIndex, setPages, setSelectedPageIds]
);
const resetWorkspaceState = useCallback(() => {
replaceWorkspaceState();
}, [replaceWorkspaceState]);
return {
pages,
setPages,
selectedPageIds,
setSelectedPageIds,
lastSelectedVisualIndex,
setLastSelectedVisualIndex,
latestPagesRef,
workspaceDirty,
setWorkspaceDirty,
workspaceMessage,
setWorkspaceMessage,
workspaceHistory,
setWorkspaceHistory,
redoHistory,
setRedoHistory,
getCurrentCommandState,
applyCommandState,
createWorkspaceCommand,
executeWorkspaceCommand,
handleUndo,
handleRedo,
replaceWorkspaceState,
resetWorkspaceState,
};
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import type { WorkspaceCommandState } from './workspaceCommands';
import {
cloneCommandState,
createSnapshotCommand,
reviveWorkspaceCommand,
toWorkspaceCommandRecord,
} from './workspaceCommands';
function makeState(pageIds: string[]): WorkspaceCommandState {
return {
pages: pageIds.map((id, index) => ({
id,
sourcePageIndex: index,
rotation: index * 90,
})),
selectedPageIds: pageIds.slice(0, 1),
lastSelectedVisualIndex: pageIds.length > 0 ? 0 : null,
};
}
describe('workspaceCommands', () => {
it('clones command state deeply enough for page and selection changes', () => {
const original = makeState(['a', 'b']);
const cloned = cloneCommandState(original);
original.pages[0].rotation = 270;
original.selectedPageIds.push('b');
original.lastSelectedVisualIndex = 1;
expect(cloned).toEqual({
pages: [
{ id: 'a', sourcePageIndex: 0, rotation: 0 },
{ id: 'b', sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ['a'],
lastSelectedVisualIndex: 0,
});
});
it('creates snapshot commands that are stable after source states mutate', () => {
const before = makeState(['a', 'b']);
const after = makeState(['b', 'a']);
after.selectedPageIds = ['b'];
after.lastSelectedVisualIndex = 0;
const command = createSnapshotCommand({
id: 'cmd-1',
type: 'reorder-pages',
label: 'Move page',
timestamp: '2026-05-17T10:00:00.000Z',
before,
after,
details: { moved: 1 },
});
before.pages.length = 0;
after.pages[0].rotation = 180;
after.selectedPageIds.push('a');
expect(command.undo(makeState(['ignored']))).toEqual({
pages: [
{ id: 'a', sourcePageIndex: 0, rotation: 0 },
{ id: 'b', sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ['a'],
lastSelectedVisualIndex: 0,
});
expect(command.do(makeState(['ignored']))).toEqual({
pages: [
{ id: 'b', sourcePageIndex: 0, rotation: 0 },
{ id: 'a', sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ['b'],
lastSelectedVisualIndex: 0,
});
});
it('round-trips commands through serializable records', () => {
const before = makeState(['a', 'b', 'c']);
const after: WorkspaceCommandState = {
pages: [before.pages[2], before.pages[0], before.pages[1]],
selectedPageIds: ['c'],
lastSelectedVisualIndex: 0,
};
const command = createSnapshotCommand({
id: 'cmd-2',
type: 'copy-pages',
label: 'Copy pages',
timestamp: '2026-05-17T10:05:00.000Z',
before,
after,
});
const record = toWorkspaceCommandRecord(command);
const revived = reviveWorkspaceCommand(record);
expect(record).not.toHaveProperty('do');
expect(record).not.toHaveProperty('undo');
expect(revived.do(before)).toEqual(after);
expect(revived.undo(after)).toEqual(before);
});
});

View File

@@ -0,0 +1,84 @@
import type { PageRef } from '../pdf/pdfTypes';
export interface WorkspaceCommandState {
pages: PageRef[];
selectedPageIds: string[];
lastSelectedVisualIndex: number | null;
}
export interface WorkspaceCommandPayload {
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}
export interface WorkspaceCommandRecord {
id: string;
type: string;
label: string;
timestamp: string;
payload: WorkspaceCommandPayload;
}
export interface WorkspaceCommand extends WorkspaceCommandRecord {
do: (state: WorkspaceCommandState) => WorkspaceCommandState;
undo: (state: WorkspaceCommandState) => WorkspaceCommandState;
}
export function cloneCommandState(
state: WorkspaceCommandState
): WorkspaceCommandState {
return {
pages: state.pages.map((page) => ({ ...page })),
selectedPageIds: [...state.selectedPageIds],
lastSelectedVisualIndex: state.lastSelectedVisualIndex,
};
}
export function createSnapshotCommand(params: {
id: string;
type: string;
label: string;
timestamp?: string;
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}): WorkspaceCommand {
return reviveWorkspaceCommand({
id: params.id,
type: params.type,
label: params.label,
timestamp: params.timestamp ?? new Date().toISOString(),
payload: {
before: cloneCommandState(params.before),
after: cloneCommandState(params.after),
details: params.details,
},
});
}
export function reviveWorkspaceCommand(
record: WorkspaceCommandRecord
): WorkspaceCommand {
return {
...record,
do: () => cloneCommandState(record.payload.after),
undo: () => cloneCommandState(record.payload.before),
};
}
export function toWorkspaceCommandRecord(
command: WorkspaceCommand
): WorkspaceCommandRecord {
return {
id: command.id,
type: command.type,
label: command.label,
timestamp: command.timestamp,
payload: {
before: cloneCommandState(command.payload.before),
after: cloneCommandState(command.payload.after),
details: command.payload.details,
},
};
}

View File

@@ -0,0 +1,210 @@
import type {
LoadedWorkspace,
StoredWorkspace,
WorkspaceSummary,
} from './workspaceTypes';
const DB_NAME = 'pdf-tools-workspaces';
const DB_VERSION = 1;
const WORKSPACE_STORE = 'workspaces';
const PDF_STORE = 'pdfBinaries';
interface PdfBinaryRecord {
pdfId: string;
name: string;
blob: Blob;
size: number;
createdAt: string;
updatedAt: string;
}
interface SaveWorkspaceInput {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
function openWorkspaceDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
keyPath: 'id',
});
workspaceStore.createIndex('updatedAt', 'updatedAt', {
unique: false,
});
workspaceStore.createIndex('pdfId', 'pdfId', {
unique: false,
});
}
if (!db.objectStoreNames.contains(PDF_STORE)) {
db.createObjectStore(PDF_STORE, {
keyPath: 'pdfId',
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function listWorkspaces(): Promise<WorkspaceSummary[]> {
const db = await openWorkspaceDb();
try {
const tx = db.transaction(WORKSPACE_STORE, 'readonly');
const store = tx.objectStore(WORKSPACE_STORE);
const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
await transactionDone(tx);
return records
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
pdfId: workspace.pdfId,
pdfName: workspace.pdfName,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
sourcePageCount: workspace.sourcePageCount,
workspacePageCount: workspace.pages.length,
historyCount: workspace.history.length,
redoCount: workspace.redoHistory?.length ?? 0,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} finally {
db.close();
}
}
export async function saveWorkspaceToIndexedDb({
workspace,
pdfArrayBuffer,
}: SaveWorkspaceInput): Promise<void> {
const db = await openWorkspaceDb();
try {
const now = new Date().toISOString();
const pdfRecord: PdfBinaryRecord = {
pdfId: workspace.pdfId,
name: workspace.pdfName,
blob: new Blob([pdfArrayBuffer], { type: 'application/pdf' }),
size: pdfArrayBuffer.byteLength,
createdAt: workspace.createdAt,
updatedAt: now,
};
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
tx.objectStore(PDF_STORE).put(pdfRecord);
tx.objectStore(WORKSPACE_STORE).put(workspace);
await transactionDone(tx);
} finally {
db.close();
}
}
export async function loadWorkspaceFromIndexedDb(
workspaceId: string
): Promise<LoadedWorkspace | null> {
const db = await openWorkspaceDb();
try {
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readonly');
const workspace = await requestToPromise<StoredWorkspace | undefined>(
tx.objectStore(WORKSPACE_STORE).get(workspaceId)
);
if (!workspace) {
await transactionDone(tx);
return null;
}
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
tx.objectStore(PDF_STORE).get(workspace.pdfId)
);
await transactionDone(tx);
if (!pdfRecord) {
throw new Error(`Missing PDF binary for workspace ${workspaceId}`);
}
const pdfArrayBuffer = await pdfRecord.blob.arrayBuffer();
return {
workspace,
pdfArrayBuffer,
};
} finally {
db.close();
}
}
export async function deleteWorkspaceFromIndexedDb(
workspaceId: string
): Promise<void> {
const db = await openWorkspaceDb();
try {
const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly');
const workspace = await requestToPromise<StoredWorkspace | undefined>(
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId)
);
await transactionDone(lookupTx);
if (!workspace) return;
const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId);
await transactionDone(deleteTx);
// Clean up PDF binary if no remaining workspace references it.
const remainingWorkspaces = await listWorkspaces();
const pdfStillUsed = remainingWorkspaces.some(
(summary) => summary.pdfId === workspace.pdfId
);
if (!pdfStillUsed) {
const cleanupDb = await openWorkspaceDb();
try {
const cleanupTx = cleanupDb.transaction(PDF_STORE, 'readwrite');
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
await transactionDone(cleanupTx);
} finally {
cleanupDb.close();
}
}
} finally {
db.close();
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { PageRef } from '../pdf/pdfTypes';
import {
createSelectionPdfName,
createSelectionWorkspaceName,
getSelectedPagesInVisualOrder,
} from './workspaceSelection';
function page(id: string, sourcePageIndex: number): PageRef {
return { id, sourcePageIndex, rotation: 0 };
}
describe('workspaceSelection', () => {
it('returns selected pages in current visual order', () => {
const pages = [page('page-3', 2), page('page-1', 0), page('page-2', 1)];
expect(getSelectedPagesInVisualOrder(pages, ['page-2', 'page-3'])).toEqual([
pages[0],
pages[2],
]);
});
it('ignores selected ids that are no longer present', () => {
const pages = [page('page-1', 0), page('page-2', 1)];
expect(getSelectedPagesInVisualOrder(pages, ['missing', 'page-2'])).toEqual(
[pages[1]]
);
});
it('creates readable derived workspace and PDF names', () => {
expect(createSelectionWorkspaceName('contract.pdf', 3)).toBe(
'contract - 3-page-selection'
);
expect(createSelectionPdfName('contract.pdf', 1)).toBe(
'contract - 1-page-selection.pdf'
);
});
});

View File

@@ -0,0 +1,31 @@
import type { PageRef } from '../pdf/pdfTypes';
export function getSelectedPagesInVisualOrder(
pages: PageRef[],
selectedPageIds: string[]
): PageRef[] {
if (pages.length === 0 || selectedPageIds.length === 0) return [];
const selectedSet = new Set(selectedPageIds);
return pages.filter((page) => selectedSet.has(page.id));
}
export function createSelectionWorkspaceName(
pdfName: string,
selectedPageCount: number
): string {
const baseName = pdfName.replace(/\.pdf$/i, '') || 'Untitled';
const suffix =
selectedPageCount === 1
? '1-page-selection'
: `${selectedPageCount}-page-selection`;
return `${baseName} - ${suffix}`;
}
export function createSelectionPdfName(
pdfName: string,
selectedPageCount: number
): string {
return `${createSelectionWorkspaceName(pdfName, selectedPageCount)}.pdf`;
}

View File

@@ -0,0 +1,40 @@
import type { PageRef } from '../pdf/pdfTypes';
import type { WorkspaceCommandRecord } from './workspaceCommands';
export interface StoredWorkspace {
schemaVersion: 1;
id: string;
name: string;
createdAt: string;
updatedAt: string;
pdfId: string;
pdfName: string;
sourcePageCount: number;
pages: PageRef[];
selectedPageIds: string[];
history: WorkspaceCommandRecord[];
redoHistory?: WorkspaceCommandRecord[];
}
export interface WorkspaceSummary {
id: string;
name: string;
pdfId: string;
pdfName: string;
createdAt: string;
updatedAt: string;
sourcePageCount: number;
workspacePageCount: number;
historyCount: number;
redoCount: number;
}
export interface LoadedWorkspace {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
@@ -8,4 +8,3 @@ export default defineConfig({
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
},
});