Compare commits
13 Commits
bdbb6c0a1c
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| c639864319 | |||
| 969ede47dd | |||
| 3a0a90bd4a | |||
| 8929080dc7 | |||
| 618a8fc86e | |||
| 07ef17fcaa | |||
| 4b0046a943 | |||
| 13097b73fc | |||
| 1b602943cb | |||
| ab757291b7 | |||
| cf9a0dd0b7 | |||
| a5dc70aabf | |||
| 07f4361573 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -141,3 +141,4 @@ vite.config.ts.timestamp-*
|
|||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
todo.txt
|
todo.txt
|
||||||
|
chatgpt_continuation.md5
|
||||||
|
|||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
*.zip
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -4,6 +4,71 @@ 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.
|
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
|
## 0.2.0 — Browser-only PDF workspace baseline
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Current hosted version: <https://pdftools.add-ideas.de>
|
Current hosted version: <https://pdftools.add-ideas.de>
|
||||||
|
|
||||||
Current baseline: **v0.2.0 — Browser-only PDF workspace baseline**. See [`CHANGELOG.md`](CHANGELOG.md) for the release notes and milestone history.
|
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.
|
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Many everyday PDF tasks are not full document-authoring tasks. They are page-wor
|
|||||||
|
|
||||||
- remove pages before sending a document;
|
- remove pages before sending a document;
|
||||||
- rotate scanned pages;
|
- rotate scanned pages;
|
||||||
- split a PDF into single-page files;
|
- split a PDF into single-page files and download them individually or as one ZIP archive;
|
||||||
- merge another PDF into the current document;
|
- merge another PDF into the current document;
|
||||||
- extract a subset of pages;
|
- extract a subset of pages;
|
||||||
- reorder pages visually;
|
- reorder pages visually;
|
||||||
@@ -70,22 +70,24 @@ This makes the project especially useful for self-hosted environments, public-se
|
|||||||
|
|
||||||
- Export the current reordered/rotated/duplicated/deleted workspace as a new PDF.
|
- Export the current reordered/rotated/duplicated/deleted workspace as a new PDF.
|
||||||
- Extract selected pages into 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.
|
- 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.
|
- Merge another PDF by replacing, appending, or inserting it into the current workspace.
|
||||||
|
|
||||||
### Keyboard shortcuts
|
### Keyboard shortcuts
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| -------------------------- | --------------------------------------------------- |
|
||||||
| `F1` / `?` | Open in-app help and tutorial |
|
| `F1` / `?` | Open in-app help and tutorial |
|
||||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||||
| `Esc` | Clear the current selection or close an open dialog |
|
| `Esc` | Clear the current selection or close an open dialog |
|
||||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||||
| `←` / `→` in preview | Move to previous / next page |
|
| `←` / `→` in preview | Move to previous / next page |
|
||||||
| `Esc` in preview | Close preview |
|
| `Esc` in preview | Close preview |
|
||||||
|
|
||||||
Keyboard shortcuts are ignored while typing in form fields.
|
Keyboard shortcuts are ignored while typing in form fields.
|
||||||
|
|
||||||
@@ -115,25 +117,35 @@ 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/`.
|
There is no application server to operate. Administrators only need to host the built files from `dist/`.
|
||||||
|
|
||||||
### Build from source
|
### Build and test from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
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
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The production build is written to `dist/`.
|
The production build is written to `dist/`.
|
||||||
|
|
||||||
To preview the production build locally:
|
Useful individual development commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run dev # start the Vite development server
|
||||||
```
|
npm run preview # preview the production build locally
|
||||||
|
npm run test # run tests once
|
||||||
For development:
|
npm run test:watch # run tests in watch mode
|
||||||
|
npm run typecheck # run TypeScript without emitting files
|
||||||
```bash
|
npm run lint # run ESLint
|
||||||
npm run dev
|
npm run format # format the project with Prettier
|
||||||
|
npm run format:check # verify Prettier formatting
|
||||||
```
|
```
|
||||||
|
|
||||||
### Static hosting
|
### Static hosting
|
||||||
@@ -216,17 +228,19 @@ The application version shown in the header is defined in `src/version.ts`. The
|
|||||||
The current development baseline is:
|
The current development baseline is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v0.2.0 — Browser-only PDF workspace baseline
|
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
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
App.tsx Main application state and orchestration
|
App.tsx Main application orchestration and UI wiring
|
||||||
components/
|
components/
|
||||||
ActionDialog.tsx Reusable confirmation/action dialog
|
ActionDialog.tsx Reusable confirmation/action dialog
|
||||||
ActionsPanel.tsx Export, extract, and split actions
|
ActionsPanel.tsx Export, extract, split, and ZIP download actions
|
||||||
FileLoader.tsx PDF file loading
|
FileLoader.tsx PDF file loading
|
||||||
HelpDialog.tsx In-app tutorial and shortcut reference
|
HelpDialog.tsx In-app tutorial and shortcut reference
|
||||||
Layout.tsx Application shell/header
|
Layout.tsx Application shell/header
|
||||||
@@ -236,6 +250,7 @@ src/
|
|||||||
pdf/
|
pdf/
|
||||||
pdfService.ts pdf-lib operations: load, merge, split, export
|
pdfService.ts pdf-lib operations: load, merge, split, export
|
||||||
pdfThumbnailService.ts pdf.js thumbnail rendering
|
pdfThumbnailService.ts pdf.js thumbnail rendering
|
||||||
|
pdfZipService.ts Browser-side ZIP packaging for split results
|
||||||
pdfTypes.ts PDF-related types
|
pdfTypes.ts PDF-related types
|
||||||
workspace/
|
workspace/
|
||||||
workspaceCommands.ts Command model for undo/redo
|
workspaceCommands.ts Command model for undo/redo
|
||||||
@@ -270,7 +285,7 @@ src/
|
|||||||
- [x] Add command history as a foundation for undo/redo.
|
- [x] Add command history as a foundation for undo/redo.
|
||||||
- [x] Add undo/redo.
|
- [x] Add undo/redo.
|
||||||
- [x] Display undo/redo history with redo entries visually separated.
|
- [x] Display undo/redo history with redo entries visually separated.
|
||||||
- [ ] Extract selection as a new active workspace.
|
- [x] Extract selection as a new active workspace.
|
||||||
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
|
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
|
||||||
- [ ] Add grid/list view toggle.
|
- [ ] Add grid/list view toggle.
|
||||||
|
|
||||||
@@ -293,7 +308,7 @@ src/
|
|||||||
### Milestone 5: Export and power tools
|
### Milestone 5: Export and power tools
|
||||||
|
|
||||||
- [ ] Basic text extraction.
|
- [ ] Basic text extraction.
|
||||||
- [ ] ZIP export for split results.
|
- [x] ZIP export for split results.
|
||||||
- [ ] Optimize/compress MVP.
|
- [ ] Optimize/compress MVP.
|
||||||
- [ ] Carefully scoped encrypted PDF handling.
|
- [ ] Carefully scoped encrypted PDF handling.
|
||||||
|
|
||||||
55
eslint.config.mjs
Normal file
55
eslint.config.mjs
Normal 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
|
||||||
|
);
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Self-hosted PDF Workbench</title>
|
<title>Self-hosted PDF Workbench</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4411
package-lock.json
generated
4411
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,25 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "pdf-tools",
|
"name": "pdf-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.6.82",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.11",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/react-dom": "^18.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"typescript": "^5.6.3",
|
"@types/node": "^25.8.0",
|
||||||
"vite": "^5.4.10"
|
"@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."
|
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="pdftools favicon" width="64" height="64"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><defs>
|
||||||
|
<linearGradient id="pdf-bg" x1="10" y1="4" x2="54" y2="60" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#ff4b3f"></stop>
|
||||||
|
<stop offset="1" stop-color="#c91424"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="1.25" flood-color="#7a0b12" flood-opacity="0.28"></feDropShadow>
|
||||||
|
</filter>
|
||||||
|
</defs><rect width="64" height="64" rx="14" fill="url(#pdf-bg)"></rect><g filter="url(#soft-shadow)">
|
||||||
|
<path d="M19 11h22l9 9v31a4 4 0 0 1-4 4H19a4 4 0 0 1-4-4V15a4 4 0 0 1 4-4z" fill="#fff"></path>
|
||||||
|
<path d="M41 11v9h9z" fill="#ffd9d6"></path>
|
||||||
|
<path d="M23 24h19" stroke="#d41627" stroke-width="4" stroke-linecap="round"></path>
|
||||||
|
<path d="M23 34h14" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.82"></path>
|
||||||
|
<path d="M23 44h19" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.64"></path>
|
||||||
|
</g><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
21
public/site.webmanifest
Normal file
21
public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "PDFTools",
|
||||||
|
"short_name": "PDFTools",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
1095
src/App.tsx
1095
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { SplitResult } from '../pdf/pdfTypes';
|
import type {
|
||||||
|
PdfDownload,
|
||||||
|
SplitPdfDownload,
|
||||||
|
} from '../hooks/usePdfGeneratedOutputs';
|
||||||
|
|
||||||
interface ActionsPanelProps {
|
interface ActionsPanelProps {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
@@ -9,13 +12,13 @@ interface ActionsPanelProps {
|
|||||||
|
|
||||||
onSplit: () => void;
|
onSplit: () => void;
|
||||||
onExtractSelected: () => void;
|
onExtractSelected: () => void;
|
||||||
|
onOpenSelectionAsWorkspace: () => void;
|
||||||
onExportReordered: () => void;
|
onExportReordered: () => void;
|
||||||
|
|
||||||
splitResults: SplitResult[];
|
splitDownloads: SplitPdfDownload[];
|
||||||
subsetDownloadUrl: string | null;
|
splitZipDownload: PdfDownload | null;
|
||||||
subsetFilename: string | null;
|
subsetDownload: PdfDownload | null;
|
||||||
exportDownloadUrl: string | null;
|
exportDownload: PdfDownload | null;
|
||||||
exportFilename: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||||
@@ -24,12 +27,12 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
onSplit,
|
onSplit,
|
||||||
onExtractSelected,
|
onExtractSelected,
|
||||||
|
onOpenSelectionAsWorkspace,
|
||||||
onExportReordered,
|
onExportReordered,
|
||||||
splitResults,
|
splitDownloads,
|
||||||
subsetDownloadUrl,
|
splitZipDownload,
|
||||||
subsetFilename,
|
subsetDownload,
|
||||||
exportDownloadUrl,
|
exportDownload,
|
||||||
exportFilename,
|
|
||||||
}) => {
|
}) => {
|
||||||
const disabled = !hasPdf || isBusy;
|
const disabled = !hasPdf || isBusy;
|
||||||
|
|
||||||
@@ -38,6 +41,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
onExtractSelected();
|
onExtractSelected();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenSelectionAsWorkspaceClick = () => {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
onOpenSelectionAsWorkspace();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Tools</h2>
|
<h2>Tools</h2>
|
||||||
@@ -73,6 +81,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
📤 Extract selected ({selectedCount})
|
📤 Extract selected ({selectedCount})
|
||||||
</button>
|
</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
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -83,52 +105,57 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subsetDownloadUrl && subsetFilename && (
|
{subsetDownload && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Subset result:</strong>{' '}
|
<strong>Subset result:</strong>{' '}
|
||||||
<a
|
<a
|
||||||
className="download-link"
|
className="download-link"
|
||||||
href={subsetDownloadUrl}
|
href={subsetDownload.url}
|
||||||
download={subsetFilename}
|
download={subsetDownload.filename}
|
||||||
>
|
>
|
||||||
Download {subsetFilename}
|
Download {subsetDownload.filename}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{exportDownloadUrl && exportFilename && (
|
{exportDownload && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Exported document:</strong>{' '}
|
<strong>Exported document:</strong>{' '}
|
||||||
<a
|
<a
|
||||||
className="download-link"
|
className="download-link"
|
||||||
href={exportDownloadUrl}
|
href={exportDownload.url}
|
||||||
download={exportFilename}
|
download={exportDownload.filename}
|
||||||
>
|
>
|
||||||
Download {exportFilename}
|
Download {exportDownload.filename}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{splitResults.length > 0 && (
|
{splitDownloads.length > 0 && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Single-page PDFs:</strong>
|
<strong>Single-page PDFs:</strong>
|
||||||
<div>
|
{splitZipDownload && (
|
||||||
{splitResults.map((r) => {
|
<div style={{ marginTop: '0.25rem' }}>
|
||||||
const url = URL.createObjectURL(r.blob);
|
<a
|
||||||
return (
|
className="download-link"
|
||||||
<a
|
href={splitZipDownload.url}
|
||||||
key={r.pageIndex}
|
download={splitZipDownload.filename}
|
||||||
className="download-link"
|
>
|
||||||
href={url}
|
Download all as ZIP ({splitDownloads.length} files)
|
||||||
download={r.filename}
|
</a>
|
||||||
onClick={() => {
|
</div>
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
)}
|
||||||
}}
|
<div style={{ marginTop: '0.25rem' }}>
|
||||||
>
|
{splitDownloads.map((download) => (
|
||||||
{r.filename}
|
<a
|
||||||
</a>
|
key={download.id}
|
||||||
);
|
className="download-link"
|
||||||
})}
|
href={download.url}
|
||||||
|
download={download.filename}
|
||||||
|
>
|
||||||
|
{download.filename}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { PdfFile } from '../pdf/pdfTypes';
|
|||||||
|
|
||||||
interface FileLoaderProps {
|
interface FileLoaderProps {
|
||||||
pdf: PdfFile | null;
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const files = Array.from(e.target.files ?? []);
|
||||||
if (file) {
|
if (files.length > 0) {
|
||||||
onFileLoaded(file);
|
onFilesLoaded(files);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -18,8 +18,16 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>1. Load PDF</h2>
|
<h2>1. Load PDF</h2>
|
||||||
<p>Select a PDF file. Processing happens entirely in your browser.</p>
|
<p>
|
||||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
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 && (
|
{pdf && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
|
|||||||
@@ -7,19 +7,34 @@ interface HelpDialogProps {
|
|||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
|
{ 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: 'Ctrl/⌘ + A',
|
||||||
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
|
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/⌘ + Z', description: 'Undo the latest workspace command' },
|
||||||
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' },
|
{
|
||||||
|
keys: 'Ctrl/⌘ + Shift + Z',
|
||||||
|
description: 'Redo the next workspace command',
|
||||||
|
},
|
||||||
{ keys: 'Ctrl/⌘ + Y', 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' },
|
{
|
||||||
|
keys: '← / → in preview',
|
||||||
|
description: 'Move to the previous or next page in the preview overlay',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const tutorialSteps = [
|
const tutorialSteps = [
|
||||||
{
|
{
|
||||||
title: '1. Open a PDF or load a workspace',
|
title: '1. Open a PDF or load a workspace',
|
||||||
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.',
|
body: 'Start by selecting a local PDF file. Select several PDFs to open the merge queue, or restore a saved workspace from browser storage.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '2. Arrange pages visually',
|
title: '2. Arrange pages visually',
|
||||||
@@ -30,11 +45,19 @@ const tutorialSteps = [
|
|||||||
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.',
|
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. Save your workspace or export a PDF',
|
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.',
|
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '5. Use history deliberately',
|
title: '7. Use history deliberately',
|
||||||
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -79,8 +102,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||||
<p>
|
<p>
|
||||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||||
rearrange, split, merge, rotate, duplicate, and export PDFs without
|
rearrange, split, queue-merge, rotate, duplicate, and export PDFs
|
||||||
uploading documents to a server.
|
without uploading documents to a server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,8 +142,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="help-note">
|
<p className="help-note">
|
||||||
Shortcuts are ignored while typing in text fields or other editable
|
Shortcuts are ignored while typing in text fields or other
|
||||||
controls.
|
editable controls.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
232
src/components/MergeQueuePanel.tsx
Normal file
232
src/components/MergeQueuePanel.tsx
Normal 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;
|
||||||
@@ -10,10 +10,10 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
|||||||
interface PagePreviewModalProps {
|
interface PagePreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
pdf: PdfFile | null;
|
pdf: PdfFile | null;
|
||||||
pageIndex: number | null; // original page index, 0-based
|
pageIndex: number | null; // original page index, 0-based
|
||||||
rotation: number; // degrees
|
rotation: number; // degrees
|
||||||
|
|
||||||
visualIndex: number | null; // current position in order, 0-based
|
visualIndex: number | null; // current position in order, 0-based
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
|
||||||
canGoPrevious: boolean;
|
canGoPrevious: boolean;
|
||||||
@@ -134,6 +134,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
baseCanvas.height = scaledViewport.height;
|
baseCanvas.height = scaledViewport.height;
|
||||||
|
|
||||||
const renderTask = page.render({
|
const renderTask = page.render({
|
||||||
|
canvas: baseCanvas,
|
||||||
canvasContext: baseCtx,
|
canvasContext: baseCtx,
|
||||||
viewport: scaledViewport,
|
viewport: scaledViewport,
|
||||||
});
|
});
|
||||||
|
|||||||
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal 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;
|
||||||
27
src/components/PageWorkspace/DropIndicator.tsx
Normal file
27
src/components/PageWorkspace/DropIndicator.tsx
Normal 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;
|
||||||
213
src/components/PageWorkspace/PageCard.tsx
Normal file
213
src/components/PageWorkspace/PageCard.tsx
Normal 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;
|
||||||
130
src/components/PageWorkspace/PageGrid.tsx
Normal file
130
src/components/PageWorkspace/PageGrid.tsx
Normal 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;
|
||||||
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal 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;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import type { PageRef } from '../pdf/pdfTypes';
|
import type { PageRef } from '../pdf/pdfTypes';
|
||||||
|
import CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
|
||||||
|
import PageGrid from './PageWorkspace/PageGrid';
|
||||||
|
import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
|
||||||
|
|
||||||
interface ReorderPanelProps {
|
interface ReorderPanelProps {
|
||||||
pages: PageRef[];
|
pages: PageRef[];
|
||||||
@@ -53,8 +56,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
|
|
||||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
|
||||||
|
|
||||||
const cleanupDragGhost = () => {
|
const cleanupDragGhost = () => {
|
||||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||||
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||||
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
cleanupDragGhost();
|
||||||
|
setDraggingIndex(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setIsCopyDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||||
setDraggingIndex(visualIndex);
|
setDraggingIndex(visualIndex);
|
||||||
setDropIndex(visualIndex);
|
setDropIndex(visualIndex);
|
||||||
@@ -122,10 +130,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
cleanupDragGhost();
|
resetDragState();
|
||||||
setDraggingIndex(null);
|
|
||||||
setDropIndex(null);
|
|
||||||
setIsCopyDragging(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||||
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setIsCopyDragging(false);
|
setIsCopyDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (pageId: string) => () => {
|
const handleDeleteClick = (pageId: string) => {
|
||||||
onDelete(pageId);
|
onDelete(pageId);
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRotateClickClockwise = (pageId: string) => () => {
|
|
||||||
onRotateClockwise(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRotateClickCounterclockwise = (pageId: string) => () => {
|
|
||||||
onRotateCounterclockwise(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardClick = (pageId: string) => () => {
|
|
||||||
onOpenPreview(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckboxClick =
|
const handleCheckboxClick =
|
||||||
(pageId: string, visualIndex: number) =>
|
(pageId: string, visualIndex: number) =>
|
||||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation(); // don't trigger preview
|
e.stopPropagation();
|
||||||
onToggleSelect(pageId, visualIndex, e);
|
onToggleSelect(pageId, visualIndex, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopySelectedClick = () => {
|
const handleCopySelectedClick = () => {
|
||||||
if (selectedPageIds.length === 0) return;
|
if (selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
|
setCopyTargetPosition(String(pages.length + 1));
|
||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
setCopyDialogOpen(true);
|
setCopyDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -245,6 +238,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyTargetPositionChange = (value: string) => {
|
||||||
|
setCopyTargetPosition(value);
|
||||||
|
setCopyDialogError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copyDialogOpen) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCopyDialogCancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [copyDialogOpen]);
|
|
||||||
|
|
||||||
if (!hasPdf) {
|
if (!hasPdf) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -293,18 +274,6 @@ 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 === pages.length && draggingIndex !== null;
|
|
||||||
|
|
||||||
// For highlighting the whole selection while dragging it
|
|
||||||
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
||||||
const draggingSelectionActive =
|
const draggingSelectionActive =
|
||||||
draggingPage != null &&
|
draggingPage != null &&
|
||||||
@@ -318,529 +287,53 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
<h2>Pages</h2>
|
<h2>Pages</h2>
|
||||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
Tap/click a page to preview it. Use the checkbox to select pages
|
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
|
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||||
whole selection. Hold Ctrl/⌘ while dropping to copy instead of move.
|
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||||
Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears
|
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
||||||
selection.
|
Esc clears selection.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<PageSelectionToolbar
|
||||||
style={{
|
selectedCount={selectedPageIds.length}
|
||||||
display: 'flex',
|
onCopySelected={handleCopySelectedClick}
|
||||||
justifyContent: 'space-between',
|
onDeleteSelected={onDeleteSelected}
|
||||||
alignItems: 'center',
|
onSelectAll={onSelectAll}
|
||||||
marginBottom: '0.5rem',
|
onClearSelection={onClearSelection}
|
||||||
fontSize: '0.85rem',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Selected: <strong>{selectedPageIds.length}</strong>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPageIds.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopySelectedClick}
|
|
||||||
disabled={selectedPageIds.length === 0}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#dcfce7',
|
|
||||||
color: '#166534',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title={'Copy selected pages to another position'}
|
|
||||||
>
|
|
||||||
Copy selected
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{selectedPageIds.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={selectedPageIds.length === 0}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
|
|
||||||
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<PageGrid
|
||||||
style={{
|
pages={pages}
|
||||||
display: 'flex',
|
thumbnails={thumbnails}
|
||||||
flexWrap: 'wrap',
|
selectedPageIds={selectedPageIds}
|
||||||
gap: '0.5rem',
|
isBusy={isBusy}
|
||||||
alignItems: 'flex-start',
|
draggingIndex={draggingIndex}
|
||||||
marginBottom: '0.75rem',
|
dropIndex={dropIndex}
|
||||||
}}
|
draggingSelectionActive={draggingSelectionActive}
|
||||||
|
isCopyDragging={isCopyDragging}
|
||||||
|
dropIndicatorColor={dropIndicatorColor}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onCardDragOver={handleCardDragOver}
|
||||||
|
onEndSlotDragOver={handleEndSlotDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
onOpenPreview={onOpenPreview}
|
||||||
{pages.map((page, visualIndex) => {
|
onToggleSelect={handleCheckboxClick}
|
||||||
const thumb = thumbnails[page.id];
|
onRotateClockwise={onRotateClockwise}
|
||||||
const rotation = page.rotation;
|
onRotateCounterclockwise={onRotateCounterclockwise}
|
||||||
const selected = isSelected(page.id);
|
onDelete={handleDeleteClick}
|
||||||
|
/>
|
||||||
const isDraggingCard =
|
|
||||||
draggingIndex != null &&
|
|
||||||
((draggingSelectionActive && selected) ||
|
|
||||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={page.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={handleDragStart(visualIndex)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleCardDragOver(visualIndex)}
|
|
||||||
onClick={handleCardClick(page.id)}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '162px',
|
|
||||||
padding: '0.4rem',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
background: isDraggingCard
|
|
||||||
? isCopyDragging
|
|
||||||
? '#dcfce7'
|
|
||||||
: '#dbeafe'
|
|
||||||
: selected
|
|
||||||
? '#eff6ff'
|
|
||||||
: '#f9fafb',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem',
|
|
||||||
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
|
|
||||||
opacity: isBusy ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* selection checkbox */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCheckboxClick(page.id, 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: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* right drop indicator */}
|
|
||||||
{showRightLine(visualIndex) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: '-4px',
|
|
||||||
top: '4px',
|
|
||||||
bottom: '4px',
|
|
||||||
width: '3px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '110px',
|
|
||||||
height: '90px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{thumb ? (
|
|
||||||
<img
|
|
||||||
src={thumb}
|
|
||||||
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 {rotation}°
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.25rem',
|
|
||||||
marginTop: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRotateClickClockwise(page.id)();
|
|
||||||
}}
|
|
||||||
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(page.id)();
|
|
||||||
}}
|
|
||||||
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(page.id)();
|
|
||||||
}}
|
|
||||||
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 */}
|
|
||||||
{pages.length > 0 && (
|
|
||||||
<div
|
|
||||||
onDragOver={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: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{copyDialogOpen && (
|
{copyDialogOpen && (
|
||||||
<div
|
<CopyPagesDialog
|
||||||
role="dialog"
|
selectedCount={selectedPageIds.length}
|
||||||
aria-modal="true"
|
pageCount={pages.length}
|
||||||
aria-labelledby="copy-pages-dialog-title"
|
targetPosition={copyTargetPosition}
|
||||||
onPointerDown={(e) => {
|
error={copyDialogError}
|
||||||
if (e.target === e.currentTarget) {
|
onTargetPositionChange={handleCopyTargetPositionChange}
|
||||||
handleCopyDialogCancel();
|
onCancel={handleCopyDialogCancel}
|
||||||
}
|
onConfirm={handleCopyDialogConfirm}
|
||||||
}}
|
/>
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 60,
|
|
||||||
background: 'rgba(15, 23, 42, 0.55)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
onSubmit={handleCopyDialogConfirm}
|
|
||||||
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={handleCopyDialogCancel}
|
|
||||||
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>
|
|
||||||
{selectedPageIds.length === 1
|
|
||||||
? '1 selected page'
|
|
||||||
: `${selectedPageIds.length} 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={pages.length + 1}
|
|
||||||
value={copyTargetPosition}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => {
|
|
||||||
setCopyTargetPosition(e.target.value);
|
|
||||||
setCopyDialogError(null);
|
|
||||||
}}
|
|
||||||
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>{pages.length + 1} = after the last page</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{copyDialogError && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
background: '#fef2f2',
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
color: '#b91c1c',
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copyDialogError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: '0.5rem',
|
|
||||||
marginTop: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyDialogCancel}
|
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,325 +3,325 @@ import type { WorkspaceSummary } from '../workspace/workspaceTypes';
|
|||||||
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
||||||
|
|
||||||
interface WorkspacePanelProps {
|
interface WorkspacePanelProps {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
|
||||||
activeWorkspaceId: string | null;
|
activeWorkspaceId: string | null;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
workspaceDirty: boolean;
|
workspaceDirty: boolean;
|
||||||
workspaceMessage: string | null;
|
workspaceMessage: string | null;
|
||||||
|
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
history: WorkspaceCommandRecord[];
|
history: WorkspaceCommandRecord[];
|
||||||
redoHistory: WorkspaceCommandRecord[];
|
redoHistory: WorkspaceCommandRecord[];
|
||||||
|
|
||||||
onWorkspaceNameChange: (value: string) => void;
|
onWorkspaceNameChange: (value: string) => void;
|
||||||
onSaveWorkspace: () => void;
|
onSaveWorkspace: () => void;
|
||||||
onLoadWorkspace: (workspaceId: string) => void;
|
onLoadWorkspace: (workspaceId: string) => void;
|
||||||
onDeleteWorkspace: (workspaceId: string) => void;
|
onDeleteWorkspace: (workspaceId: string) => void;
|
||||||
onRefreshWorkspaces: () => void;
|
onRefreshWorkspaces: () => void;
|
||||||
onResetWorkspace: () => void;
|
onResetWorkspace: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||||
hasPdf,
|
hasPdf,
|
||||||
isBusy,
|
isBusy,
|
||||||
activeWorkspaceId,
|
activeWorkspaceId,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
workspaceDirty,
|
workspaceDirty,
|
||||||
workspaceMessage,
|
workspaceMessage,
|
||||||
workspaces,
|
workspaces,
|
||||||
history,
|
history,
|
||||||
redoHistory,
|
redoHistory,
|
||||||
onWorkspaceNameChange,
|
onWorkspaceNameChange,
|
||||||
onSaveWorkspace,
|
onSaveWorkspace,
|
||||||
onLoadWorkspace,
|
onLoadWorkspace,
|
||||||
onDeleteWorkspace,
|
onDeleteWorkspace,
|
||||||
onRefreshWorkspaces,
|
onRefreshWorkspaces,
|
||||||
onResetWorkspace,
|
onResetWorkspace,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
}) => {
|
}) => {
|
||||||
const canUndo = history.length > 0;
|
const canUndo = history.length > 0;
|
||||||
const canRedo = redoHistory.length > 0;
|
const canRedo = redoHistory.length > 0;
|
||||||
|
|
||||||
const latestUndo = history[history.length - 1];
|
const latestUndo = history[history.length - 1];
|
||||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Workspace</h2>
|
<h2>Workspace</h2>
|
||||||
|
|
||||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
Save named workspaces in this browser. PDF binaries are stored in
|
Save named workspaces in this browser. PDF binaries are stored in
|
||||||
IndexedDB; nothing is uploaded.
|
IndexedDB; nothing is uploaded.
|
||||||
</p>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
margin: '0.25rem 0',
|
||||||
gap: '0.5rem',
|
borderRadius: '999px',
|
||||||
flexWrap: 'wrap',
|
background: '#ecfdf5',
|
||||||
alignItems: 'center',
|
color: '#166534',
|
||||||
}}
|
fontSize: '0.8rem',
|
||||||
>
|
fontWeight: 600,
|
||||||
<input
|
alignSelf: 'flex-start',
|
||||||
type="text"
|
border: '2px solid #166534',
|
||||||
value={workspaceName}
|
width: '100%',
|
||||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
}}
|
||||||
placeholder="Workspace name"
|
></div>
|
||||||
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
|
{redoHistory
|
||||||
type="button"
|
.slice()
|
||||||
className="secondary"
|
.reverse()
|
||||||
onClick={onUndo}
|
.map((entry, index) => (
|
||||||
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
|
<div
|
||||||
style={{
|
key={entry.id}
|
||||||
marginTop: '0.5rem',
|
style={{
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
color: '#92400e',
|
color: '#9ca3af',
|
||||||
}}
|
borderLeft: '3px solid #d1d5db',
|
||||||
|
paddingLeft: '0.45rem',
|
||||||
|
paddingTop: '0.2rem',
|
||||||
|
paddingBottom: '0.2rem',
|
||||||
|
opacity: 0.75,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Unsaved workspace changes.
|
<strong>
|
||||||
|
Redo {index + 1}. {entry.label}
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: '#9ca3af' }}>
|
||||||
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
{workspaceMessage && (
|
</details>
|
||||||
<div
|
)}
|
||||||
style={{
|
</div>
|
||||||
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;
|
export default WorkspacePanel;
|
||||||
159
src/hooks/usePdfGeneratedOutputs.ts
Normal file
159
src/hooks/usePdfGeneratedOutputs.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/merge/mergeQueueHelpers.test.ts
Normal file
82
src/merge/mergeQueueHelpers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MergeQueueItem } from './mergeTypes';
|
||||||
|
import {
|
||||||
|
canMergeQueue,
|
||||||
|
clampMergeInsertAt,
|
||||||
|
createMergedPdfName,
|
||||||
|
getReadyMergeQueuePdfs,
|
||||||
|
moveMergeQueueItem,
|
||||||
|
} from './mergeQueueHelpers';
|
||||||
|
|
||||||
|
function makeItem(
|
||||||
|
id: string,
|
||||||
|
status: MergeQueueItem['status']
|
||||||
|
): MergeQueueItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
file: new File(['x'], `${id}.pdf`, { type: 'application/pdf' }),
|
||||||
|
name: `${id}.pdf`,
|
||||||
|
size: 1,
|
||||||
|
pageCount: status === 'ready' ? 1 : null,
|
||||||
|
pdf:
|
||||||
|
status === 'ready'
|
||||||
|
? {
|
||||||
|
id: `pdf-${id}`,
|
||||||
|
name: `${id}.pdf`,
|
||||||
|
// The helper tests never dereference the PDFDocument.
|
||||||
|
doc: {} as never,
|
||||||
|
pageCount: 1,
|
||||||
|
arrayBuffer: new ArrayBuffer(0),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('merge queue helpers', () => {
|
||||||
|
it('moves queued items up and down without mutating the original array', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem('a', 'ready'),
|
||||||
|
makeItem('b', 'ready'),
|
||||||
|
makeItem('c', 'ready'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(moveMergeQueueItem(items, 'b', 'up').map((item) => item.id)).toEqual(
|
||||||
|
['b', 'a', 'c']
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
moveMergeQueueItem(items, 'b', 'down').map((item) => item.id)
|
||||||
|
).toEqual(['a', 'c', 'b']);
|
||||||
|
expect(items.map((item) => item.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only allows merging when every queued item is ready', () => {
|
||||||
|
const readyItems = [makeItem('a', 'ready'), makeItem('b', 'ready')];
|
||||||
|
const mixedItems = [makeItem('a', 'ready'), makeItem('b', 'loading')];
|
||||||
|
|
||||||
|
expect(canMergeQueue(readyItems)).toBe(true);
|
||||||
|
expect(getReadyMergeQueuePdfs(readyItems)).toHaveLength(2);
|
||||||
|
expect(canMergeQueue(mixedItems)).toBe(false);
|
||||||
|
expect(canMergeQueue([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps one-based merge positions to zero-based insert slots', () => {
|
||||||
|
expect(clampMergeInsertAt('1', 10)).toBe(0);
|
||||||
|
expect(clampMergeInsertAt('5', 10)).toBe(4);
|
||||||
|
expect(clampMergeInsertAt('99', 10)).toBe(10);
|
||||||
|
expect(clampMergeInsertAt('-10', 10)).toBe(0);
|
||||||
|
expect(clampMergeInsertAt('not-a-number', 10)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates readable merged PDF filenames', () => {
|
||||||
|
expect(createMergedPdfName('base.pdf', ['a.pdf', 'b.pdf'], 'append')).toBe(
|
||||||
|
'base_plus_2_pdfs.pdf'
|
||||||
|
);
|
||||||
|
expect(createMergedPdfName('base.pdf', ['a.pdf'], 'overwrite')).toBe(
|
||||||
|
'a_merged.pdf'
|
||||||
|
);
|
||||||
|
expect(createMergedPdfName(null, ['a.pdf', 'b.pdf'], 'overwrite')).toBe(
|
||||||
|
'merged_2_pdfs.pdf'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/merge/mergeQueueHelpers.ts
Normal file
86
src/merge/mergeQueueHelpers.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { PdfFile } from '../pdf/pdfTypes';
|
||||||
|
import type { MergeMode, MergeQueueItem } from './mergeTypes';
|
||||||
|
|
||||||
|
export function createMergeQueueItemId(): string {
|
||||||
|
return `merge_${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveMergeQueueItem(
|
||||||
|
items: MergeQueueItem[],
|
||||||
|
itemId: string,
|
||||||
|
direction: 'up' | 'down'
|
||||||
|
): MergeQueueItem[] {
|
||||||
|
const index = items.findIndex((item) => item.id === itemId);
|
||||||
|
if (index < 0) return items;
|
||||||
|
|
||||||
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= items.length) return items;
|
||||||
|
|
||||||
|
const next = [...items];
|
||||||
|
const [item] = next.splice(index, 1);
|
||||||
|
next.splice(targetIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadyMergeQueuePdfs(items: MergeQueueItem[]): PdfFile[] {
|
||||||
|
return items
|
||||||
|
.filter((item) => item.status === 'ready' && item.pdf)
|
||||||
|
.map((item) => item.pdf as PdfFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMergeQueue(items: MergeQueueItem[]): boolean {
|
||||||
|
return (
|
||||||
|
items.length > 0 &&
|
||||||
|
items.every((item) => item.status === 'ready' && item.pdf !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMergeQueueErrors(items: MergeQueueItem[]): boolean {
|
||||||
|
return items.some((item) => item.status === 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMergeQueueLoading(items: MergeQueueItem[]): boolean {
|
||||||
|
return items.some((item) => item.status === 'loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampMergeInsertAt(value: string, pageCount: number): number {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed)) return pageCount;
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed - 1, 0), pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultMergeInsertPosition(pageCount: number): string {
|
||||||
|
return String(pageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMergedPdfName(
|
||||||
|
currentPdfName: string | null,
|
||||||
|
incomingPdfNames: string[],
|
||||||
|
mode: MergeMode
|
||||||
|
): string {
|
||||||
|
const incomingBaseNames = incomingPdfNames.map(stripPdfExtension);
|
||||||
|
|
||||||
|
if (incomingBaseNames.length === 0) {
|
||||||
|
return currentPdfName ?? 'merged.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
incomingBaseNames.length === 1 &&
|
||||||
|
(!currentPdfName || mode === 'overwrite')
|
||||||
|
) {
|
||||||
|
return `${incomingBaseNames[0]}_merged.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPdfName && mode !== 'overwrite') {
|
||||||
|
const currentBaseName = stripPdfExtension(currentPdfName);
|
||||||
|
return `${currentBaseName}_plus_${incomingBaseNames.length}_pdfs.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `merged_${incomingBaseNames.length}_pdfs.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPdfExtension(filename: string): string {
|
||||||
|
const base = filename.replace(/\.pdf$/i, '').trim();
|
||||||
|
return base || 'document';
|
||||||
|
}
|
||||||
16
src/merge/mergeTypes.ts
Normal file
16
src/merge/mergeTypes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PdfFile } from '../pdf/pdfTypes';
|
||||||
|
|
||||||
|
export type MergeMode = 'overwrite' | 'append' | 'insertAt';
|
||||||
|
|
||||||
|
export type MergeQueueItemStatus = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface MergeQueueItem {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
pageCount: number | null;
|
||||||
|
pdf: PdfFile | null;
|
||||||
|
status: MergeQueueItemStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
102
src/merge/useMergeQueue.ts
Normal file
102
src/merge/useMergeQueue.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { loadPdfFromFile } from '../pdf/pdfService';
|
||||||
|
import {
|
||||||
|
canMergeQueue,
|
||||||
|
createMergeQueueItemId,
|
||||||
|
getReadyMergeQueuePdfs,
|
||||||
|
hasMergeQueueErrors,
|
||||||
|
isMergeQueueLoading,
|
||||||
|
moveMergeQueueItem,
|
||||||
|
} from './mergeQueueHelpers';
|
||||||
|
import type { MergeQueueItem } from './mergeTypes';
|
||||||
|
|
||||||
|
export function useMergeQueue() {
|
||||||
|
const [items, setItems] = useState<MergeQueueItem[]>([]);
|
||||||
|
|
||||||
|
const addFiles = useCallback((files: File[]) => {
|
||||||
|
const pdfFiles = files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.type === 'application/pdf' ||
|
||||||
|
file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pdfFiles.length === 0) return;
|
||||||
|
|
||||||
|
const queuedItems: MergeQueueItem[] = pdfFiles.map((file) => ({
|
||||||
|
id: createMergeQueueItemId(),
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
pageCount: null,
|
||||||
|
pdf: null,
|
||||||
|
status: 'loading',
|
||||||
|
}));
|
||||||
|
|
||||||
|
setItems((current) => [...current, ...queuedItems]);
|
||||||
|
|
||||||
|
queuedItems.forEach((item) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const loadedPdf = await loadPdfFromFile(item.file);
|
||||||
|
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((currentItem) =>
|
||||||
|
currentItem.id === item.id
|
||||||
|
? {
|
||||||
|
...currentItem,
|
||||||
|
pdf: loadedPdf,
|
||||||
|
pageCount: loadedPdf.pageCount,
|
||||||
|
status: 'ready',
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: currentItem
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((currentItem) =>
|
||||||
|
currentItem.id === item.id
|
||||||
|
? {
|
||||||
|
...currentItem,
|
||||||
|
status: 'error',
|
||||||
|
error: 'Could not load this PDF.',
|
||||||
|
}
|
||||||
|
: currentItem
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeItem = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== itemId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveItemUp = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => moveMergeQueueItem(current, itemId, 'up'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveItemDown = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => moveMergeQueueItem(current, itemId, 'down'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearQueue = useCallback(() => {
|
||||||
|
setItems([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
moveItemUp,
|
||||||
|
moveItemDown,
|
||||||
|
clearQueue,
|
||||||
|
readyPdfs: getReadyMergeQueuePdfs(items),
|
||||||
|
canMerge: canMergeQueue(items),
|
||||||
|
hasErrors: hasMergeQueueErrors(items),
|
||||||
|
isLoading: isMergeQueueLoading(items),
|
||||||
|
};
|
||||||
|
}
|
||||||
57
src/pdf/pdfMergeService.test.ts
Normal file
57
src/pdf/pdfMergeService.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { PdfFile } from './pdfTypes';
|
||||||
|
import { mergePdfFilesAtPosition } from './pdfService';
|
||||||
|
|
||||||
|
async function makePdf(name: string, pageCount: number): Promise<PdfFile> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
for (let i = 0; i < pageCount; i += 1) {
|
||||||
|
doc.addPage([100, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await doc.save();
|
||||||
|
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(arrayBuffer).set(bytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
doc,
|
||||||
|
pageCount,
|
||||||
|
arrayBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergePdfFilesAtPosition', () => {
|
||||||
|
it('merges a queue without a current base PDF', async () => {
|
||||||
|
const first = await makePdf('first.pdf', 1);
|
||||||
|
const second = await makePdf('second.pdf', 2);
|
||||||
|
|
||||||
|
const merged = await mergePdfFilesAtPosition({
|
||||||
|
basePdf: null,
|
||||||
|
incomingPdfs: [first, second],
|
||||||
|
insertAt: 0,
|
||||||
|
name: 'merged.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.name).toBe('merged.pdf');
|
||||||
|
expect(merged.pageCount).toBe(3);
|
||||||
|
expect(merged.doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts queued PDFs into a current base PDF at the requested slot', async () => {
|
||||||
|
const base = await makePdf('base.pdf', 3);
|
||||||
|
const incoming = await makePdf('incoming.pdf', 2);
|
||||||
|
|
||||||
|
const merged = await mergePdfFilesAtPosition({
|
||||||
|
basePdf: base,
|
||||||
|
incomingPdfs: [incoming],
|
||||||
|
insertAt: 1,
|
||||||
|
name: 'base_plus_incoming.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.name).toBe('base_plus_incoming.pdf');
|
||||||
|
expect(merged.pageCount).toBe(5);
|
||||||
|
expect(merged.doc.getPageCount()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,16 @@ function createId() {
|
|||||||
return Math.random().toString(36).slice(2);
|
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> {
|
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const doc = await PDFDocument.load(arrayBuffer);
|
const doc = await PDFDocument.load(arrayBuffer);
|
||||||
@@ -23,8 +33,8 @@ export async function mergePdfFiles(
|
|||||||
newPdf: PdfFile,
|
newPdf: PdfFile,
|
||||||
insertAt: number
|
insertAt: number
|
||||||
): Promise<PdfFile> {
|
): Promise<PdfFile> {
|
||||||
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
|
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||||
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
|
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
|
||||||
|
|
||||||
const mergedDoc = await PDFDocument.create();
|
const mergedDoc = await PDFDocument.create();
|
||||||
|
|
||||||
@@ -53,8 +63,7 @@ export async function mergePdfFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await mergedDoc.save();
|
const bytes = await mergedDoc.save();
|
||||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||||
new Uint8Array(buffer).set(bytes);
|
|
||||||
|
|
||||||
const baseName = basePdf.name.replace(/\.pdf$/i, '');
|
const baseName = basePdf.name.replace(/\.pdf$/i, '');
|
||||||
const newName = newPdf.name.replace(/\.pdf$/i, '');
|
const newName = newPdf.name.replace(/\.pdf$/i, '');
|
||||||
@@ -68,6 +77,76 @@ export async function mergePdfFiles(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MergePdfFilesAtPositionOptions {
|
||||||
|
basePdf: PdfFile | null;
|
||||||
|
incomingPdfs: PdfFile[];
|
||||||
|
insertAt: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergePdfFilesAtPosition({
|
||||||
|
basePdf,
|
||||||
|
incomingPdfs,
|
||||||
|
insertAt,
|
||||||
|
name,
|
||||||
|
}: MergePdfFilesAtPositionOptions): Promise<PdfFile> {
|
||||||
|
if (!basePdf && incomingPdfs.length === 0) {
|
||||||
|
throw new Error('At least one PDF is required for merging');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDoc = await PDFDocument.create();
|
||||||
|
|
||||||
|
const addAllPages = async (sourcePdf: PdfFile) => {
|
||||||
|
const sourceDoc =
|
||||||
|
sourcePdf.doc ?? (await PDFDocument.load(sourcePdf.arrayBuffer));
|
||||||
|
const pageCount = sourceDoc.getPageCount();
|
||||||
|
const pages = await mergedDoc.copyPages(
|
||||||
|
sourceDoc,
|
||||||
|
Array.from({ length: pageCount }, (_, i) => i)
|
||||||
|
);
|
||||||
|
pages.forEach((page) => mergedDoc.addPage(page));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!basePdf) {
|
||||||
|
for (const incomingPdf of incomingPdfs) {
|
||||||
|
await addAllPages(incomingPdf);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const baseDoc =
|
||||||
|
basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||||
|
const basePageCount = baseDoc.getPageCount();
|
||||||
|
const clampedInsertAt = Math.min(Math.max(insertAt, 0), basePageCount);
|
||||||
|
|
||||||
|
const basePages = await mergedDoc.copyPages(
|
||||||
|
baseDoc,
|
||||||
|
Array.from({ length: basePageCount }, (_, i) => i)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < clampedInsertAt; i += 1) {
|
||||||
|
mergedDoc.addPage(basePages[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const incomingPdf of incomingPdfs) {
|
||||||
|
await addAllPages(incomingPdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = clampedInsertAt; i < basePages.length; i += 1) {
|
||||||
|
mergedDoc.addPage(basePages[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await mergedDoc.save();
|
||||||
|
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
name,
|
||||||
|
arrayBuffer: buffer,
|
||||||
|
pageCount: mergedDoc.getPageCount(),
|
||||||
|
doc: mergedDoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function splitIntoSinglePages(
|
export async function splitIntoSinglePages(
|
||||||
pdf: PdfFile
|
pdf: PdfFile
|
||||||
): Promise<SplitResult[]> {
|
): Promise<SplitResult[]> {
|
||||||
@@ -99,7 +178,7 @@ export async function splitIntoSinglePages(
|
|||||||
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
const blob = pdfBytesToBlob(bytes);
|
||||||
|
|
||||||
const base = name.replace(/\.pdf$/i, '');
|
const base = name.replace(/\.pdf$/i, '');
|
||||||
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
||||||
@@ -114,10 +193,7 @@ export async function splitIntoSinglePages(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractRange(
|
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
|
||||||
pdf: PdfFile,
|
|
||||||
range: Range
|
|
||||||
): Promise<Blob> {
|
|
||||||
const { doc } = pdf;
|
const { doc } = pdf;
|
||||||
const pageCount = doc.getPageCount();
|
const pageCount = doc.getPageCount();
|
||||||
|
|
||||||
@@ -136,7 +212,7 @@ export async function extractRange(
|
|||||||
copiedPages.forEach((p) => newDoc.addPage(p));
|
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||||
@@ -150,7 +226,7 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPages(
|
export async function exportPages(
|
||||||
@@ -188,7 +264,7 @@ export async function exportPages(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportReordered(
|
export async function exportReordered(
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ interface ThumbnailGenerationOptions {
|
|||||||
onThumbnail?: (update: ThumbnailUpdate) => void;
|
onThumbnail?: (update: ThumbnailUpdate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
||||||
*/
|
*/
|
||||||
@@ -133,7 +132,11 @@ async function generateThumbnailsInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderPageThumbnail(
|
async function renderPageThumbnail(
|
||||||
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
|
page: Awaited<
|
||||||
|
ReturnType<
|
||||||
|
Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']
|
||||||
|
>
|
||||||
|
>,
|
||||||
originalIndex: number,
|
originalIndex: number,
|
||||||
rotations: RotationsMap,
|
rotations: RotationsMap,
|
||||||
maxHeight: number,
|
maxHeight: number,
|
||||||
@@ -154,6 +157,7 @@ async function renderPageThumbnail(
|
|||||||
baseCanvas.height = scaledViewport.height;
|
baseCanvas.height = scaledViewport.height;
|
||||||
|
|
||||||
const renderTask = page.render({
|
const renderTask = page.render({
|
||||||
|
canvas: baseCanvas,
|
||||||
canvasContext: baseCtx,
|
canvasContext: baseCtx,
|
||||||
viewport: scaledViewport,
|
viewport: scaledViewport,
|
||||||
});
|
});
|
||||||
|
|||||||
86
src/pdf/pdfZipService.test.ts
Normal file
86
src/pdf/pdfZipService.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import { createSplitResultsZip, createSplitZipFilename } from './pdfZipService';
|
||||||
|
import type { SplitResult } from './pdfTypes';
|
||||||
|
|
||||||
|
async function unzipBlob(blob: Blob): Promise<Record<string, Uint8Array>> {
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
return unzipSync(new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pdfZipService', () => {
|
||||||
|
it('creates a ZIP archive from split PDF blobs', async () => {
|
||||||
|
const results: SplitResult[] = [
|
||||||
|
{
|
||||||
|
pageIndex: 0,
|
||||||
|
filename: 'document_page_001.pdf',
|
||||||
|
blob: new Blob([new Uint8Array([1, 2, 3])], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageIndex: 1,
|
||||||
|
filename: 'document_page_002.pdf',
|
||||||
|
blob: new Blob([new Uint8Array([4, 5, 6])], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const zipBlob = await createSplitResultsZip(results);
|
||||||
|
const entries = await unzipBlob(zipBlob);
|
||||||
|
|
||||||
|
expect(zipBlob.type).toBe('application/zip');
|
||||||
|
expect(Object.keys(entries)).toEqual([
|
||||||
|
'document_page_001.pdf',
|
||||||
|
'document_page_002.pdf',
|
||||||
|
]);
|
||||||
|
expect(Array.from(entries['document_page_001.pdf'])).toEqual([1, 2, 3]);
|
||||||
|
expect(Array.from(entries['document_page_002.pdf'])).toEqual([4, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes and deduplicates ZIP entry names', async () => {
|
||||||
|
const results: SplitResult[] = [
|
||||||
|
{
|
||||||
|
pageIndex: 0,
|
||||||
|
filename: '../page.pdf',
|
||||||
|
blob: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageIndex: 1,
|
||||||
|
filename: '../page.pdf',
|
||||||
|
blob: new Blob([new Uint8Array([2])], { type: 'application/pdf' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageIndex: 2,
|
||||||
|
filename: '',
|
||||||
|
blob: new Blob([new Uint8Array([3])], { type: 'application/pdf' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const zipBlob = await createSplitResultsZip(results);
|
||||||
|
const entries = await unzipBlob(zipBlob);
|
||||||
|
|
||||||
|
expect(Object.keys(entries)).toEqual([
|
||||||
|
'.._page.pdf',
|
||||||
|
'.._page_2.pdf',
|
||||||
|
'page_003.pdf',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a readable ZIP filename from the source PDF name', () => {
|
||||||
|
expect(createSplitZipFilename('contract.pdf')).toBe(
|
||||||
|
'contract_split_pages.zip'
|
||||||
|
);
|
||||||
|
expect(createSplitZipFilename('contract.final.PDF')).toBe(
|
||||||
|
'contract.final_split_pages.zip'
|
||||||
|
);
|
||||||
|
expect(createSplitZipFilename('')).toBe('document_split_pages.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty split results', async () => {
|
||||||
|
await expect(createSplitResultsZip([])).rejects.toThrow(
|
||||||
|
'Cannot create a ZIP archive without split results.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/pdf/pdfZipService.ts
Normal file
78
src/pdf/pdfZipService.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import type { SplitResult } from './pdfTypes';
|
||||||
|
|
||||||
|
function bytesToBlob(bytes: Uint8Array, type: string): Blob {
|
||||||
|
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(buffer).set(bytes);
|
||||||
|
return new Blob([buffer], { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeControlCharacters(value: string): string {
|
||||||
|
return Array.from(value)
|
||||||
|
.filter((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code > 31 && code !== 127;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeZipEntryName(filename: string, fallback: string): string {
|
||||||
|
const cleaned = removeControlCharacters(filename)
|
||||||
|
.replace(/[\\/]+/g, '_')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return cleaned.length > 0 ? cleaned : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueZipEntryName(filename: string, usedNames: Set<string>): string {
|
||||||
|
if (!usedNames.has(filename)) {
|
||||||
|
usedNames.add(filename);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotIndex = filename.lastIndexOf('.');
|
||||||
|
const hasExtension = dotIndex > 0;
|
||||||
|
const base = hasExtension ? filename.slice(0, dotIndex) : filename;
|
||||||
|
const extension = hasExtension ? filename.slice(dotIndex) : '';
|
||||||
|
|
||||||
|
let counter = 2;
|
||||||
|
let candidate = `${base}_${counter}${extension}`;
|
||||||
|
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
counter += 1;
|
||||||
|
candidate = `${base}_${counter}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSplitZipFilename(pdfName: string): string {
|
||||||
|
const baseName = pdfName.replace(/\.pdf$/i, '').trim() || 'document';
|
||||||
|
return `${baseName}_split_pages.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSplitResultsZip(
|
||||||
|
results: SplitResult[]
|
||||||
|
): Promise<Blob> {
|
||||||
|
if (results.length === 0) {
|
||||||
|
throw new Error('Cannot create a ZIP archive without split results.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const fallback = `page_${String(result.pageIndex + 1).padStart(3, '0')}.pdf`;
|
||||||
|
const entryName = uniqueZipEntryName(
|
||||||
|
safeZipEntryName(result.filename, fallback),
|
||||||
|
usedNames
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrayBuffer = await result.blob.arrayBuffer();
|
||||||
|
entries[entryName] = new Uint8Array(arrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zippedBytes = zipSync(entries, { level: 6 });
|
||||||
|
return bytesToBlob(zippedBytes, 'application/zip');
|
||||||
|
}
|
||||||
212
src/pdf/usePdfThumbnails.ts
Normal file
212
src/pdf/usePdfThumbnails.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
@@ -394,3 +398,94 @@ button.secondary {
|
|||||||
align-items: flex-start;
|
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 +1 @@
|
|||||||
export const APP_VERSION = '0.2.0';
|
export const APP_VERSION = '0.3.2';
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
208
src/workspace/useWorkspaceState.test.tsx
Normal file
208
src/workspace/useWorkspaceState.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
src/workspace/useWorkspaceState.ts
Normal file
250
src/workspace/useWorkspaceState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/workspace/workspaceCommands.test.ts
Normal file
105
src/workspace/workspaceCommands.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -190,7 +190,7 @@ export async function deleteWorkspaceFromIndexedDb(
|
|||||||
const remainingWorkspaces = await listWorkspaces();
|
const remainingWorkspaces = await listWorkspaces();
|
||||||
|
|
||||||
const pdfStillUsed = remainingWorkspaces.some(
|
const pdfStillUsed = remainingWorkspaces.some(
|
||||||
(summary) => summary.pdfId === workspace.pdfId
|
(summary) => summary.pdfId === workspace.pdfId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pdfStillUsed) {
|
if (!pdfStillUsed) {
|
||||||
|
|||||||
39
src/workspace/workspaceSelection.test.ts
Normal file
39
src/workspace/workspaceSelection.test.ts
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/workspace/workspaceSelection.ts
Normal file
31
src/workspace/workspaceSelection.ts
Normal 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`;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react-swc';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user