Compare commits
29 Commits
bdbb6c0a1c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b34572a9 | |||
| b76886e3ec | |||
| 92dd3bcab3 | |||
| f22714c37f | |||
| 5cfbd48282 | |||
| 8425d44658 | |||
| d40a288a4e | |||
| bff9e059d8 | |||
| 5f017d3b22 | |||
| 6386326473 | |||
| 54d4dc9545 | |||
| 499db16fce | |||
| 0b975558d0 | |||
| c639864319 | |||
| 969ede47dd | |||
| 3a0a90bd4a | |||
| 8929080dc7 | |||
| 618a8fc86e | |||
| 07ef17fcaa | |||
| 91abda1744 | |||
| f0b0f678f1 | |||
| 4b0046a943 | |||
| c27efaec90 | |||
| 13097b73fc | |||
| 1b602943cb | |||
| ab757291b7 | |||
| cf9a0dd0b7 | |||
| a5dc70aabf | |||
| 07f4361573 |
288
.gitignore
vendored
288
.gitignore
vendored
@@ -1,143 +1,147 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
todo.txt
|
||||
chatgpt_continuation.md5
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
todo.txt
|
||||
# Generated release artifacts
|
||||
release/
|
||||
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
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
|
||||
}
|
||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -4,6 +4,132 @@ All notable changes to `pdf-tools` are documented here.
|
||||
|
||||
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
|
||||
|
||||
## 0.4.4 — LocalToolBox organization migration
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved canonical repository, Toolbox schema, corresponding-source, and package-registry URLs from the personal `zemion` namespace to the `lotobo` LocalToolBox organization.
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.3` from the organization-owned registry.
|
||||
- Bumped the app/package version to `0.4.4`.
|
||||
|
||||
## 0.4.3 — Aligned Toolbox content width
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the PDF workspace's maximum content width from `900px` to `90rem` so it aligns with the shared Toolbox shell bar and central catalogue.
|
||||
- Bumped the app/package version to `0.4.3`.
|
||||
|
||||
## 0.4.2 — Corrected Toolbox header orientation
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.2`.
|
||||
- Moved the Toolbox icon and `add·ideas Toolbox` label to the fixed far-left block, kept the app title and one tagline centered, and moved the fixed control cluster to the far right in the reverse order: Help, the PDF-specific Gitea source link, Apps, and Personalize.
|
||||
- Bumped the app/package version to `0.4.2`.
|
||||
|
||||
## 0.4.1 — Stable Toolbox header layout
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.1`.
|
||||
- Adopted the stable shared top bar: the fixed left cluster contains Personalize, Apps, the PDF-specific Gitea source link, and Help; the app title and one tagline remain centered; and the fixed far-right block contains the Toolbox icon and `add·ideas Toolbox` label.
|
||||
|
||||
## 0.4.0 — Shared Toolbox header and themes
|
||||
|
||||
### Added
|
||||
|
||||
- Added the standard Toolbox header controls for in-app help, the app-specific Gitea source link, appearance personalization, and application switching.
|
||||
- Added explicit light, dark, and system-following themes for the PDF workspace, saved-workspace list, merge queue, dialogs, status messages, and controls.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded the Toolbox contract, React shell, and validation testkit to `0.2.0`.
|
||||
- The header now uses the manifest's repository metadata for the source link instead of carrying a release-specific source action.
|
||||
- Replaced application color literals with semantic PDF workspace variables while intentionally keeping rendered PDF pages on a white paper surface.
|
||||
- Scoped application button and card styles to the shell's main content so they cannot override shared header controls.
|
||||
- Bumped the app/package version to `0.4.0`.
|
||||
|
||||
## 0.3.4 — PDF.js compatibility and Toolbox integration
|
||||
|
||||
### Added
|
||||
|
||||
- Added a generated Toolbox App Contract manifest and shared Toolbox Shell integration with standalone fallback.
|
||||
- Added deterministic, checksummed release ZIP packaging for artifact-based toolbox assembly.
|
||||
|
||||
### Changed
|
||||
|
||||
- Centralized PDF.js setup in a shared module so thumbnail rendering and page preview use the same legacy PDF.js build and worker configuration.
|
||||
- Added a local PDF.js worker wrapper that installs a `Uint8Array.prototype.toHex` compatibility shim before PDF.js worker code runs.
|
||||
- Added the same `toHex` compatibility shim in the main app bundle for browsers that do not provide it natively.
|
||||
- Made Vite asset URLs relative so the app can be hosted at arbitrary nested paths.
|
||||
- Corrected the documented licence identifier to `AGPL-3.0-only`.
|
||||
- Bumped the app/package version to `0.3.4`.
|
||||
|
||||
## 0.3.2 — Multi-file merge queue release
|
||||
|
||||
### Added
|
||||
|
||||
- Added a multi-file merge queue for selecting, loading, reviewing, reordering, removing, and merging several incoming PDFs.
|
||||
- Added queue merge modes for replacing the current document, appending after the current workspace, or inserting at a chosen one-based page position.
|
||||
- Added merge queue helper tests for queue ordering, readiness checks, insert-position clamping, and merged filename generation.
|
||||
- Added PDF merge service tests for queue-only and base-plus-incoming merge results.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the file picker to accept multiple PDFs. A single file with no active workspace still opens directly; otherwise selected files are added to the merge queue.
|
||||
- Replaced the old single-file merge card with a queue-based merge panel.
|
||||
- Merging now creates a new unsaved workspace from the materialized merge result, preserving the current workspace state before append/insert merges.
|
||||
- Bumped the app/package version to `0.3.2`.
|
||||
|
||||
## 0.3.1 — Split ZIP export release
|
||||
|
||||
### Added
|
||||
|
||||
- Added browser-side ZIP packaging for split results via `src/pdf/pdfZipService.ts`.
|
||||
- Added a “Download all as ZIP” link next to the individual single-page split downloads.
|
||||
- Added tests for split-result ZIP creation, ZIP entry name sanitization/deduplication, ZIP filename generation, and empty-result handling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended generated-output handling so split downloads can include both individual page files and one ZIP archive, with object URL cleanup handled by `usePdfGeneratedOutputs`.
|
||||
- Bumped the app/package version to `0.3.1`.
|
||||
|
||||
## 0.3.0 — Selection workspace and maintenance release
|
||||
|
||||
### Added
|
||||
|
||||
- Added “Open selection as workspace” to create a new unsaved active workspace from the selected pages in current visual order.
|
||||
- Added selection-workspace helper tests for visual-order selection and derived naming.
|
||||
- Added TypeScript type-check, ESLint, Prettier, and aggregate `check` scripts.
|
||||
- Added ESLint flat config with TypeScript, React Hooks, React Refresh, browser, and Node config support.
|
||||
- Added Prettier configuration and ignore file.
|
||||
- Added Vite client type declarations for worker URL imports.
|
||||
- Added Vitest-based test scripts for one-off and watch-mode test runs.
|
||||
- Added pure tests for workspace command cloning, snapshot command stability, and serializable command record round-tripping.
|
||||
- Added hook-level tests for workspace load/replace behavior, command execution, undo, redo, history, redo clearing, dirty-state updates, and content-change callbacks.
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped the app/package version to `0.3.0`.
|
||||
- Switched to `@vitejs/plugin-react`.
|
||||
- Marked the package as an ES module package to remove the Vite CJS Node API deprecation warning during local tooling runs.
|
||||
- Ran Prettier across the project after adding the formatting configuration.
|
||||
- Split the former monolithic `ReorderPanel` into focused page-workspace components: `PageGrid`, `PageCard`, `PageSelectionToolbar`, `DropIndicator`, and `CopyPagesDialog`.
|
||||
- Kept drag/drop move, Ctrl/⌘ copy-drag, selection, rotation, deletion, preview opening, and copy-by-position behavior wired through the existing `ReorderPanel` API.
|
||||
- Extracted generated PDF download URL creation and cleanup for split, extract, and export results into `src/hooks/usePdfGeneratedOutputs.ts`.
|
||||
- Updated `ActionsPanel` to render prepared download objects instead of creating object URLs during render.
|
||||
- Extracted workspace page, selection, dirty-state, message, undo/redo history, command creation, command execution, and reset/load helpers from `App.tsx` into `src/workspace/useWorkspaceState.ts`.
|
||||
- Extracted thumbnail state, caching, invalidation, progressive rendering, rotation-aware rendering, copied-page thumbnail reuse, and thumbnail error reporting into `src/pdf/usePdfThumbnails.ts`.
|
||||
- Kept PDF loading, IndexedDB persistence, dialogs, preview, merge, export, and split orchestration in `App.tsx` for now.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renamed Prettier config files to `.prettierrc.json` and `.prettierignore` so Prettier picks them up automatically.
|
||||
- Fixed existing `tsc --noEmit` failures for Vite worker URL imports and `Uint8Array`/`BlobPart` PDF byte handling.
|
||||
- Removed a duplicate copy-dialog validation error assignment in `ReorderPanel`.
|
||||
- Rotated thumbnails from loaded/saved workspaces are now regenerated from the actual current page rotation instead of relying only on rotation changes after load.
|
||||
- Copied/duplicated pages now receive thumbnails through the shared thumbnail hook/cache path instead of ad-hoc copy handling in `App.tsx`.
|
||||
|
||||
## 0.2.0 — Browser-only PDF workspace baseline
|
||||
|
||||
### Added
|
||||
@@ -18,4 +144,4 @@ The project follows a pragmatic versioning scheme while the app is still below `
|
||||
|
||||
### Notes
|
||||
|
||||
This release is the baseline for the next refactoring phase. The goal of the upcoming internal changes is to preserve behavior while extracting workspace state, thumbnail handling, generated object URLs, UI subcomponents, tests, and linting into clearer modules.
|
||||
This release is the baseline for the next refactoring phase. The goal of the upcoming internal changes is to preserve behavior while extracting workspace state, thumbnail handling, generated object URLs, UI subcomponents, tests, and linting into clearer modules.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
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 development release: **v0.4.4 — LocalToolBox organization migration**. See [`CHANGELOG.md`](CHANGELOG.md) for milestone history.
|
||||
|
||||
Active work is tracked in Gitea issues. Durable project context is mirrored into the Gitea wiki from repository docs, including [`ROADMAP.md`](ROADMAP.md).
|
||||
|
||||
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
|
||||
|
||||
@@ -14,7 +16,7 @@ Many everyday PDF tasks are not full document-authoring tasks. They are page-wor
|
||||
|
||||
- remove pages before sending a document;
|
||||
- rotate scanned pages;
|
||||
- split a PDF into single-page files;
|
||||
- split a PDF into single-page files and download them individually or as one ZIP archive;
|
||||
- merge another PDF into the current document;
|
||||
- extract a subset of pages;
|
||||
- reorder pages visually;
|
||||
@@ -40,6 +42,8 @@ This makes the project especially useful for self-hosted environments, public-se
|
||||
- **Thumbnail cache by page and rotation**: Rotated thumbnails are cached and only changed thumbnails need to be regenerated.
|
||||
- **Stable page references**: Duplicated pages and reordered pages are tracked as workspace page references rather than only by original page number.
|
||||
- **Merge choices**: Loading another PDF can replace the current document, append pages, or insert pages at a chosen position.
|
||||
- **PDF.js compatibility setup**: PDF.js is configured through one shared module and a local worker wrapper so the app can polyfill `Uint8Array.prototype.toHex` before PDF.js runs in older browsers.
|
||||
- **Toolbox appearance controls**: The shared header provides light, dark, and system-following themes and keeps the app switcher, help, and source controls in the same place as the Toolbox portal.
|
||||
- **In-app help**: The app includes a Help/Tutorial dialog with keyboard shortcuts and workflow explanations.
|
||||
|
||||
## Current features
|
||||
@@ -70,28 +74,30 @@ This makes the project especially useful for self-hosted environments, public-se
|
||||
|
||||
- Export the current reordered/rotated/duplicated/deleted workspace as a new PDF.
|
||||
- Extract selected pages into a new PDF.
|
||||
- Open selected pages as a new active workspace for continued editing.
|
||||
- Split the source PDF into single-page PDFs.
|
||||
- Download all split results as one ZIP archive.
|
||||
- Merge another PDF by replacing, appending, or inserting it into the current workspace.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `F1` / `?` | Open in-app help and tutorial |
|
||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||
| `Esc` | Clear the current selection or close an open dialog |
|
||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||
| `←` / `→` in preview | Move to previous / next page |
|
||||
| `Esc` in preview | Close preview |
|
||||
| Shortcut | Action |
|
||||
| -------------------------- | --------------------------------------------------- |
|
||||
| `F1` / `?` | Open in-app help and tutorial |
|
||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||
| `Esc` | Clear the current selection or close an open dialog |
|
||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||
| `←` / `→` in preview | Move to previous / next page |
|
||||
| `Esc` in preview | Close preview |
|
||||
|
||||
Keyboard shortcuts are ignored while typing in form fields.
|
||||
|
||||
## In-app documentation concept
|
||||
|
||||
The app includes a Help/Tutorial dialog reachable from the header via **Help ?**, `F1`, or `?`.
|
||||
The app includes a Help/Tutorial dialog reachable from the header's **?** control, `F1`, or `?`.
|
||||
|
||||
Recommended structure for in-app documentation:
|
||||
|
||||
@@ -115,25 +121,60 @@ browser <-- HTTPS --> static web server / reverse proxy --> built app assets
|
||||
|
||||
There is no application server to operate. Administrators only need to host the built files from `dist/`.
|
||||
|
||||
### Build from source
|
||||
The production build is relocatable and can also be assembled into an
|
||||
[Add Ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal) deployment.
|
||||
`toolbox-app.json` describes this app through the framework-independent
|
||||
[Toolbox App Contract](https://git.add-ideas.de/lotobo/toolbox-sdk). The same
|
||||
artifact remains fully usable on its own.
|
||||
|
||||
The shared first-party shell always provides help, source, and appearance
|
||||
controls. When launched with a same-origin catalogue URL, for example
|
||||
`?toolbox=/toolbox.catalog.json`, it also connects the application switcher to
|
||||
the current Toolbox catalogue. Missing, invalid, and cross-origin catalogues
|
||||
are ignored so the app falls back to standalone mode.
|
||||
|
||||
### Build and test from source
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run check` runs the main project quality gate:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The production build is written to `dist/`.
|
||||
|
||||
To preview the production build locally:
|
||||
Create a versioned, checksummed release artifact with:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
For development:
|
||||
This writes `release/pdf-tools-<version>.zip` and its `.sha256` file. The ZIP
|
||||
contains the static app, its toolbox manifest and changelog, plus the application
|
||||
source-code location, license, and the full license/notice texts found for
|
||||
bundled runtime dependencies under `LICENSES/`. Existing artifacts for the same
|
||||
version are never replaced implicitly; after intentionally reviewing a rebuild, use
|
||||
`npm run release:artifact -- --force`.
|
||||
|
||||
Useful individual development commands:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run dev # start the Vite development server
|
||||
npm run preview # preview the production build locally
|
||||
npm run test # run tests once
|
||||
npm run test:watch # run tests in watch mode
|
||||
npm run typecheck # run TypeScript without emitting files
|
||||
npm run lint # run ESLint
|
||||
npm run format # format the project with Prettier
|
||||
npm run format:check # verify Prettier formatting
|
||||
```
|
||||
|
||||
### Static hosting
|
||||
@@ -207,7 +248,7 @@ The app expects modern browser APIs, including:
|
||||
- Canvas;
|
||||
- modern JavaScript modules.
|
||||
|
||||
Use current versions of Chromium, Firefox, Safari, or Edge.
|
||||
The app includes a small `Uint8Array.prototype.toHex` compatibility shim for PDF.js fingerprint handling in browsers that do not provide that typed-array helper natively. Use current versions of Chromium, Firefox, Safari, or Edge where possible.
|
||||
|
||||
## Versioning and release baseline
|
||||
|
||||
@@ -216,17 +257,19 @@ The application version shown in the header is defined in `src/version.ts`. The
|
||||
The current development baseline is:
|
||||
|
||||
```text
|
||||
v0.2.0 — Browser-only PDF workspace baseline
|
||||
v0.4.4 — LocalToolBox organization migration
|
||||
```
|
||||
|
||||
This branch preserves the browser-only workspace baseline and PDF.js compatibility setup while using Toolbox SDK `0.2.3` from the `lotobo` package registry. The displayed app/package version is `0.4.4`; canonical source, schema, and release links use the LocalToolBox organization. The shared header keeps the Toolbox icon and `add·ideas Toolbox` label in a fixed far-left block, the app title and one tagline centered, and Help, the PDF source link, Apps, and Personalize in a fixed far-right cluster. The PDF workspace shares the shell bar's `90rem` maximum width. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting remain separated enough to support additional feature work without turning `App.tsx` back into a monolith.
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
src/
|
||||
App.tsx Main application state and orchestration
|
||||
App.tsx Main application orchestration and UI wiring
|
||||
components/
|
||||
ActionDialog.tsx Reusable confirmation/action dialog
|
||||
ActionsPanel.tsx Export, extract, and split actions
|
||||
ActionsPanel.tsx Export, extract, split, and ZIP download actions
|
||||
FileLoader.tsx PDF file loading
|
||||
HelpDialog.tsx In-app tutorial and shortcut reference
|
||||
Layout.tsx Application shell/header
|
||||
@@ -234,8 +277,12 @@ src/
|
||||
ReorderPanel.tsx Page grid, selection, drag/drop, copy/delete/rotate
|
||||
WorkspacePanel.tsx Workspace save/load/reset and undo/redo history
|
||||
pdf/
|
||||
pdfJs.ts Shared pdf.js setup and worker configuration
|
||||
pdfjsWorker.ts Local pdf.js worker wrapper with compatibility shim
|
||||
uint8ArrayToHexPolyfill.ts Compatibility shim for PDF.js fingerprint handling
|
||||
pdfService.ts pdf-lib operations: load, merge, split, export
|
||||
pdfThumbnailService.ts pdf.js thumbnail rendering
|
||||
pdfZipService.ts Browser-side ZIP packaging for split results
|
||||
pdfTypes.ts PDF-related types
|
||||
workspace/
|
||||
workspaceCommands.ts Command model for undo/redo
|
||||
@@ -245,68 +292,10 @@ src/
|
||||
version.ts App version displayed in the header
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
## Roadmap and backlog
|
||||
|
||||
### Milestone 1: Fast preview and thumbnails
|
||||
|
||||
- [x] Remove unused legacy page list view.
|
||||
- [x] Bound thumbnail generation by width and height.
|
||||
- [x] Display thumbnails progressively.
|
||||
- [x] Add preview page flipping.
|
||||
- [x] Attach preview controls to the modal container.
|
||||
- [x] Add first keyboard shortcuts.
|
||||
- [x] Cache thumbnails by page and rotation.
|
||||
- [x] Regenerate only changed rotated thumbnails.
|
||||
- [x] Show software version number.
|
||||
|
||||
### Milestone 2: Real page workspace
|
||||
|
||||
- [x] Introduce stable page references instead of only original page indices.
|
||||
- [x] Support duplicate selected pages.
|
||||
- [x] Save and reload the last state from browser storage.
|
||||
- [x] Support named workspaces.
|
||||
- [x] Store PDF binaries directly in IndexedDB.
|
||||
- [x] Reset workspace.
|
||||
- [x] Add command history as a foundation for undo/redo.
|
||||
- [x] Add undo/redo.
|
||||
- [x] Display undo/redo history with redo entries visually separated.
|
||||
- [ ] Extract selection as a new active workspace.
|
||||
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
|
||||
- [ ] Add grid/list view toggle.
|
||||
|
||||
### Milestone 3: Better merge and mobile handling
|
||||
|
||||
- [ ] Add a full multi-file merge queue.
|
||||
- [ ] Support drag-and-drop of PDFs into the page grid at the hovered position.
|
||||
- [ ] Add custom long-press drag on mobile.
|
||||
- [ ] Consolidate frequently used actions into a toolbar.
|
||||
|
||||
### Milestone 4: Structural PDF editing
|
||||
|
||||
- [ ] Metadata editing.
|
||||
- [ ] Crop pages.
|
||||
- [ ] Add tools directly in the preview overlay.
|
||||
- [ ] Read/fill/flatten forms.
|
||||
- [ ] Read bookmarks, then evaluate bookmark editing.
|
||||
- [ ] Read annotations, then evaluate annotation writing.
|
||||
|
||||
### Milestone 5: Export and power tools
|
||||
|
||||
- [ ] Basic text extraction.
|
||||
- [ ] ZIP export for split results.
|
||||
- [ ] Optimize/compress MVP.
|
||||
- [ ] Carefully scoped encrypted PDF handling.
|
||||
|
||||
## Non-goals for now
|
||||
|
||||
- Server-side PDF processing.
|
||||
- Collaborative editing.
|
||||
- User accounts.
|
||||
- OCR.
|
||||
- Full content-stream editing.
|
||||
- Digital signature creation/validation workflows.
|
||||
- DMS replacement functionality.
|
||||
The roadmap and non-goals now live in [`ROADMAP.md`](ROADMAP.md). Open roadmap items are imported into Gitea issues so priorities, status, and implementation notes stay out of the README.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0. See `LICENSE`.
|
||||
AGPL-3.0-only. See `LICENSE`.
|
||||
63
ROADMAP.md
Normal file
63
ROADMAP.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# pdf-tools Roadmap
|
||||
|
||||
Active roadmap items are tracked as Gitea issues. This file is kept as the source for issue import and wiki sync; completed items remain here as release context.
|
||||
|
||||
## Milestone 1: Fast preview and thumbnails
|
||||
|
||||
- [x] Remove unused legacy page list view.
|
||||
- [x] Bound thumbnail generation by width and height.
|
||||
- [x] Display thumbnails progressively.
|
||||
- [x] Add preview page flipping.
|
||||
- [x] Attach preview controls to the modal container.
|
||||
- [x] Add first keyboard shortcuts.
|
||||
- [x] Cache thumbnails by page and rotation.
|
||||
- [x] Regenerate only changed rotated thumbnails.
|
||||
- [x] Show software version number.
|
||||
|
||||
## Milestone 2: Real page workspace
|
||||
|
||||
- [x] Introduce stable page references instead of only original page indices.
|
||||
- [x] Support duplicate selected pages.
|
||||
- [x] Save and reload the last state from browser storage.
|
||||
- [x] Support named workspaces.
|
||||
- [x] Store PDF binaries directly in IndexedDB.
|
||||
- [x] Reset workspace.
|
||||
- [x] Add command history as a foundation for undo/redo.
|
||||
- [x] Add undo/redo.
|
||||
- [x] Display undo/redo history with redo entries visually separated.
|
||||
- [x] Extract selection as a new active workspace.
|
||||
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
|
||||
- [ ] Add grid/list view toggle.
|
||||
|
||||
## Milestone 3: Better merge and mobile handling
|
||||
|
||||
- [x] Add a full multi-file merge queue.
|
||||
- [ ] Support drag-and-drop of PDFs into the page grid at the hovered position.
|
||||
- [ ] Add custom long-press drag on mobile.
|
||||
- [ ] Consolidate frequently used actions into a toolbar.
|
||||
|
||||
## Milestone 4: Structural PDF editing
|
||||
|
||||
- [ ] Metadata editing.
|
||||
- [ ] Crop pages.
|
||||
- [ ] Add tools directly in the preview overlay.
|
||||
- [ ] Read/fill/flatten forms.
|
||||
- [ ] Read bookmarks, then evaluate bookmark editing.
|
||||
- [ ] Read annotations, then evaluate annotation writing.
|
||||
|
||||
## Milestone 5: Export and power tools
|
||||
|
||||
- [ ] Basic text extraction.
|
||||
- [x] ZIP export for split results.
|
||||
- [ ] Optimize/compress MVP.
|
||||
- [ ] Carefully scoped encrypted PDF handling.
|
||||
|
||||
## Non-goals for now
|
||||
|
||||
- Server-side PDF processing.
|
||||
- Collaborative editing.
|
||||
- User accounts.
|
||||
- OCR.
|
||||
- Full content-stream editing.
|
||||
- Digital signature creation/validation workflows.
|
||||
- DMS replacement functionality.
|
||||
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', 'scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.es2022,
|
||||
},
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
);
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Self-hosted PDF Workbench</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="PDFTools" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
24
licenses/core-js-3.49.0-LICENSE.txt
Normal file
24
licenses/core-js-3.49.0-LICENSE.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
core-js 3.49.0 — MIT License
|
||||
Source: https://github.com/zloirock/core-js/tree/v3.49.0
|
||||
|
||||
Copyright (c) 2013–2025 Denis Pushkarev (zloirock.ru)
|
||||
|
||||
Copyright (c) 2025–2026 CoreJS Company (core-js.io)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
4471
package-lock.json
generated
4471
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
@@ -1,25 +1,64 @@
|
||||
{
|
||||
"name": "pdf-tools",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "npm run manifest:generate",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run manifest:generate",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"no lint configured\""
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run --environment jsdom",
|
||||
"test:toolbox": "toolbox-check dist",
|
||||
"test:watch": "vitest --environment jsdom",
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
"release:artifact": "npm run check && node scripts/package-release.mjs",
|
||||
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run test && npm run build && npm run test:toolbox"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "^0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.3",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.6.82",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
"@add-ideas/toolbox-testkit": "^0.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
|
||||
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/pdf-tools.git"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"bugs": {
|
||||
"url": "https://git.add-ideas.de/lotobo/pdf-tools/issues"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="pdftools favicon" width="64" height="64"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><defs>
|
||||
<linearGradient id="pdf-bg" x1="10" y1="4" x2="54" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff4b3f"></stop>
|
||||
<stop offset="1" stop-color="#c91424"></stop>
|
||||
</linearGradient>
|
||||
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="1.25" flood-color="#7a0b12" flood-opacity="0.28"></feDropShadow>
|
||||
</filter>
|
||||
</defs><rect width="64" height="64" rx="14" fill="url(#pdf-bg)"></rect><g filter="url(#soft-shadow)">
|
||||
<path d="M19 11h22l9 9v31a4 4 0 0 1-4 4H19a4 4 0 0 1-4-4V15a4 4 0 0 1 4-4z" fill="#fff"></path>
|
||||
<path d="M41 11v9h9z" fill="#ffd9d6"></path>
|
||||
<path d="M23 24h19" stroke="#d41627" stroke-width="4" stroke-linecap="round"></path>
|
||||
<path d="M23 34h14" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.82"></path>
|
||||
<path d="M23 44h19" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.64"></path>
|
||||
</g><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
21
public/site.webmanifest
Normal file
21
public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "PDFTools",
|
||||
"short_name": "PDFTools",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
32
public/toolbox-app.json
Normal file
32
public/toolbox-app.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"name": "PDF Workbench",
|
||||
"description": "Page-level PDF operations performed locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "pdf"],
|
||||
"tags": ["merge", "split", "rotate", "reorder"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": true,
|
||||
"crossOriginIsolated": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
},
|
||||
"version": "0.4.4"
|
||||
}
|
||||
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
64
scripts/generate-toolbox-manifest.mjs
Normal file
64
scripts/generate-toolbox-manifest.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import { format } from 'prettier';
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const definitionPath = path.join(
|
||||
projectRoot,
|
||||
'src',
|
||||
'toolboxApp.definition.json'
|
||||
);
|
||||
const versionSourcePath = path.join(projectRoot, 'src', 'version.ts');
|
||||
const manifestPath = path.join(projectRoot, 'public', 'toolbox-app.json');
|
||||
|
||||
const [packageJson, definition, versionSource] = await Promise.all([
|
||||
readJson(packageJsonPath),
|
||||
readJson(definitionPath),
|
||||
readFile(versionSourcePath, 'utf8'),
|
||||
]);
|
||||
|
||||
const sourceVersion = versionSource.match(
|
||||
/APP_VERSION\s*=\s*['"]([^'"]+)['"]/
|
||||
)?.[1];
|
||||
|
||||
if (!sourceVersion) {
|
||||
throw new Error('Could not read APP_VERSION from src/version.ts.');
|
||||
}
|
||||
|
||||
if (sourceVersion !== packageJson.version) {
|
||||
throw new Error(
|
||||
`Version mismatch: package.json is ${packageJson.version}, while src/version.ts is ${sourceVersion}.`
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
...definition,
|
||||
version: packageJson.version,
|
||||
};
|
||||
parseToolboxApp(manifest);
|
||||
const output = await format(JSON.stringify(manifest), { parser: 'json' });
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
|
||||
if (existing !== output) {
|
||||
throw new Error(
|
||||
'public/toolbox-app.json is out of date. Run npm run manifest:generate.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await writeFile(manifestPath, output);
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
}
|
||||
100
scripts/package-release.mjs
Normal file
100
scripts/package-release.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
collectDirectoryFiles,
|
||||
createDeterministicZip,
|
||||
createThirdPartyLicenseBundle,
|
||||
} from './release-archive.mjs';
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(projectRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const releaseDirectory = path.join(projectRoot, 'release');
|
||||
const archiveName = `pdf-tools-${packageJson.version}.zip`;
|
||||
const archivePath = path.join(releaseDirectory, archiveName);
|
||||
const checksumPath = path.join(
|
||||
releaseDirectory,
|
||||
`pdf-tools-${packageJson.version}.sha256`
|
||||
);
|
||||
const force = process.argv.slice(2).includes('--force');
|
||||
const unknownArguments = process.argv
|
||||
.slice(2)
|
||||
.filter((argument) => argument !== '--force');
|
||||
if (unknownArguments.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
|
||||
}
|
||||
|
||||
await mkdir(releaseDirectory, { recursive: true });
|
||||
if (!force) {
|
||||
await assertDoesNotExist(archivePath);
|
||||
await assertDoesNotExist(checksumPath);
|
||||
}
|
||||
|
||||
const files = await collectDirectoryFiles(path.join(projectRoot, 'dist'));
|
||||
|
||||
files['CHANGELOG.md'] = await readFile(path.join(projectRoot, 'CHANGELOG.md'));
|
||||
files['SOURCE.md'] = Buffer.from(
|
||||
[
|
||||
'# Corresponding source',
|
||||
'',
|
||||
`The source code corresponding to PDF Tools ${packageJson.version} is available at:`,
|
||||
'',
|
||||
`https://git.add-ideas.de/lotobo/pdf-tools/src/tag/v${packageJson.version}`,
|
||||
'',
|
||||
'Third-party components remain available under the separate terms reproduced in LICENSES/.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
files['LICENSES/pdf-tools-LICENSE.txt'] = await readFile(
|
||||
path.join(projectRoot, 'LICENSE')
|
||||
);
|
||||
files['LICENSES/core-js-3.49.0-LICENSE.txt'] = await readFile(
|
||||
path.join(projectRoot, 'licenses', 'core-js-3.49.0-LICENSE.txt')
|
||||
);
|
||||
files['LICENSES/THIRD-PARTY-LICENSES.txt'] =
|
||||
await createThirdPartyLicenseBundle(projectRoot, [
|
||||
'@add-ideas/toolbox-contract',
|
||||
'@add-ideas/toolbox-shell-react',
|
||||
'@pdf-lib/standard-fonts',
|
||||
'@pdf-lib/upng',
|
||||
'fflate',
|
||||
'pako',
|
||||
'pdf-lib',
|
||||
'pdfjs-dist',
|
||||
'react',
|
||||
'react-dom',
|
||||
'scheduler',
|
||||
'tslib',
|
||||
]);
|
||||
|
||||
const archive = createDeterministicZip(files);
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
|
||||
const writeFlag = force ? 'w' : 'wx';
|
||||
await writeFile(archivePath, archive, { flag: writeFlag });
|
||||
await writeFile(checksumPath, `${checksum} ${archiveName}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: writeFlag,
|
||||
});
|
||||
|
||||
console.log(`Created release/${archiveName}`);
|
||||
console.log(`SHA-256 ${checksum}`);
|
||||
|
||||
async function assertDoesNotExist(outputPath) {
|
||||
try {
|
||||
await access(outputPath);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`${path.relative(projectRoot, outputPath)} already exists; pass --force to replace this exact version.`
|
||||
);
|
||||
}
|
||||
12
scripts/release-archive.d.ts
vendored
Normal file
12
scripts/release-archive.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
export function compareCodePoints(left: string, right: string): number;
|
||||
export function createDeterministicZip(
|
||||
files: Record<string, Uint8Array>
|
||||
): Uint8Array;
|
||||
export function collectDirectoryFiles(
|
||||
directory: string,
|
||||
prefix?: string
|
||||
): Promise<Record<string, Buffer>>;
|
||||
export function createThirdPartyLicenseBundle(
|
||||
projectRoot: string,
|
||||
packageNames: string[]
|
||||
): Promise<Buffer>;
|
||||
123
scripts/release-archive.mjs
Normal file
123
scripts/release-archive.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
|
||||
|
||||
export function compareCodePoints(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function createDeterministicZip(files) {
|
||||
const orderedFiles = Object.fromEntries(
|
||||
Object.entries(files).sort(([left], [right]) =>
|
||||
compareCodePoints(left, right)
|
||||
)
|
||||
);
|
||||
return zipSync(orderedFiles, {
|
||||
level: 9,
|
||||
// ZIP stores a wall-clock DOS timestamp. Constructing the epoch in local
|
||||
// time keeps its encoded bytes stable in every process timezone.
|
||||
mtime: new Date(1980, 0, 1, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectDirectoryFiles(directory, prefix = '') {
|
||||
const result = {};
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const relativePath = path.posix.join(prefix, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
Object.assign(
|
||||
result,
|
||||
await collectDirectoryFiles(absolutePath, relativePath)
|
||||
);
|
||||
} else if (entry.isFile()) {
|
||||
result[relativePath] = await readFile(absolutePath);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported filesystem entry in release input: ${relativePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createThirdPartyLicenseBundle(projectRoot, packageNames) {
|
||||
const sections = [];
|
||||
|
||||
for (const packageName of [...new Set(packageNames)].sort(
|
||||
compareCodePoints
|
||||
)) {
|
||||
const packageDirectory = path.join(
|
||||
projectRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/')
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, 'package.json'), 'utf8')
|
||||
);
|
||||
const licenseFiles = await collectLicenseFiles(packageDirectory);
|
||||
if (licenseFiles.length === 0) {
|
||||
throw new Error(`No license text found for ${packageName}.`);
|
||||
}
|
||||
|
||||
const contents = [];
|
||||
for (const licenseFile of licenseFiles) {
|
||||
const text = await readFile(
|
||||
path.join(packageDirectory, ...licenseFile.split('/')),
|
||||
'utf8'
|
||||
);
|
||||
contents.push(
|
||||
`--- ${licenseFile} ---\n${text.replaceAll('\r\n', '\n').trim()}\n`
|
||||
);
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
`================================================================================`,
|
||||
`${packageJson.name ?? packageName}@${packageJson.version ?? 'unknown'}`,
|
||||
`Declared license: ${String(packageJson.license ?? 'unspecified')}`,
|
||||
`================================================================================`,
|
||||
...contents,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
[
|
||||
'Third-party software license texts bundled with PDF Tools',
|
||||
'Generated deterministically from the installed production packages.',
|
||||
'',
|
||||
...sections,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function collectLicenseFiles(directory, prefix = '') {
|
||||
const matches = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const relativePath = path.posix.join(prefix, entry.name);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
matches.push(...(await collectLicenseFiles(absolutePath, relativePath)));
|
||||
} else if (entry.isFile() && LICENSE_FILE.test(entry.name)) {
|
||||
matches.push(relativePath);
|
||||
} else if (!entry.isFile()) {
|
||||
throw new Error(
|
||||
`Unsupported filesystem entry in license input: ${relativePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
1117
src/App.tsx
1117
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -21,18 +21,18 @@ const backgroundByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
string
|
||||
> = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#e5e7eb',
|
||||
danger: '#dc2626',
|
||||
primary: 'var(--pdf-primary)',
|
||||
secondary: 'var(--pdf-control)',
|
||||
danger: 'var(--pdf-danger-text)',
|
||||
};
|
||||
|
||||
const colorByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
string
|
||||
> = {
|
||||
primary: 'white',
|
||||
secondary: '#111827',
|
||||
danger: 'white',
|
||||
primary: 'var(--pdf-on-primary)',
|
||||
secondary: 'var(--pdf-control-text)',
|
||||
danger: 'var(--pdf-on-primary)',
|
||||
};
|
||||
|
||||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
@@ -75,7 +75,7 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
background: 'var(--pdf-overlay)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -86,9 +86,10 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
background: 'white',
|
||||
background: 'var(--pdf-surface-raised)',
|
||||
color: 'var(--pdf-text)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
boxShadow: 'var(--pdf-dialog-shadow)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -121,8 +122,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
@@ -139,7 +140,7 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
color: 'var(--pdf-muted)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
@@ -173,9 +174,11 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: action.disabled
|
||||
? '#e5e7eb'
|
||||
? 'var(--pdf-control)'
|
||||
: backgroundByVariant[variant],
|
||||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
||||
color: action.disabled
|
||||
? 'var(--pdf-muted-soft)'
|
||||
: colorByVariant[variant],
|
||||
cursor: action.disabled ? 'default' : 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
@@ -190,4 +193,4 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionDialog;
|
||||
export default ActionDialog;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { SplitResult } from '../pdf/pdfTypes';
|
||||
import type {
|
||||
PdfDownload,
|
||||
SplitPdfDownload,
|
||||
} from '../hooks/usePdfGeneratedOutputs';
|
||||
|
||||
interface ActionsPanelProps {
|
||||
hasPdf: boolean;
|
||||
@@ -9,13 +12,13 @@ interface ActionsPanelProps {
|
||||
|
||||
onSplit: () => void;
|
||||
onExtractSelected: () => void;
|
||||
onOpenSelectionAsWorkspace: () => void;
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitResults: SplitResult[];
|
||||
subsetDownloadUrl: string | null;
|
||||
subsetFilename: string | null;
|
||||
exportDownloadUrl: string | null;
|
||||
exportFilename: string | null;
|
||||
splitDownloads: SplitPdfDownload[];
|
||||
splitZipDownload: PdfDownload | null;
|
||||
subsetDownload: PdfDownload | null;
|
||||
exportDownload: PdfDownload | null;
|
||||
}
|
||||
|
||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
@@ -24,12 +27,12 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
selectedCount,
|
||||
onSplit,
|
||||
onExtractSelected,
|
||||
onOpenSelectionAsWorkspace,
|
||||
onExportReordered,
|
||||
splitResults,
|
||||
subsetDownloadUrl,
|
||||
subsetFilename,
|
||||
exportDownloadUrl,
|
||||
exportFilename,
|
||||
splitDownloads,
|
||||
splitZipDownload,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
}) => {
|
||||
const disabled = !hasPdf || isBusy;
|
||||
|
||||
@@ -38,10 +41,15 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
onExtractSelected();
|
||||
};
|
||||
|
||||
const handleOpenSelectionAsWorkspaceClick = () => {
|
||||
if (selectedCount === 0) return;
|
||||
onOpenSelectionAsWorkspace();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Tools</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Use these tools on the current in-memory document (reordered, rotated,
|
||||
with deletions). Nothing is uploaded to a server.
|
||||
</p>
|
||||
@@ -73,6 +81,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
📤 Extract selected ({selectedCount})
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled || selectedCount === 0}
|
||||
onClick={handleOpenSelectionAsWorkspaceClick}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? 'Select at least one page'
|
||||
: 'Open selected pages as a new unsaved workspace'
|
||||
}
|
||||
>
|
||||
🧩 Open selection as workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
@@ -83,52 +105,57 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subsetDownloadUrl && subsetFilename && (
|
||||
{subsetDownload && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Subset result:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={subsetDownloadUrl}
|
||||
download={subsetFilename}
|
||||
href={subsetDownload.url}
|
||||
download={subsetDownload.filename}
|
||||
>
|
||||
Download {subsetFilename}
|
||||
Download {subsetDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportDownloadUrl && exportFilename && (
|
||||
{exportDownload && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Exported document:</strong>{' '}
|
||||
<a
|
||||
className="download-link"
|
||||
href={exportDownloadUrl}
|
||||
download={exportFilename}
|
||||
href={exportDownload.url}
|
||||
download={exportDownload.filename}
|
||||
>
|
||||
Download {exportFilename}
|
||||
Download {exportDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitResults.length > 0 && (
|
||||
{splitDownloads.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<strong>Single-page PDFs:</strong>
|
||||
<div>
|
||||
{splitResults.map((r) => {
|
||||
const url = URL.createObjectURL(r.blob);
|
||||
return (
|
||||
<a
|
||||
key={r.pageIndex}
|
||||
className="download-link"
|
||||
href={url}
|
||||
download={r.filename}
|
||||
onClick={() => {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}}
|
||||
>
|
||||
{r.filename}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{splitZipDownload && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
<a
|
||||
className="download-link"
|
||||
href={splitZipDownload.url}
|
||||
download={splitZipDownload.filename}
|
||||
>
|
||||
Download all as ZIP ({splitDownloads.length} files)
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
{splitDownloads.map((download) => (
|
||||
<a
|
||||
key={download.id}
|
||||
className="download-link"
|
||||
href={download.url}
|
||||
download={download.filename}
|
||||
>
|
||||
{download.filename}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { PdfFile } from '../pdf/pdfTypes';
|
||||
|
||||
interface FileLoaderProps {
|
||||
pdf: PdfFile | null;
|
||||
onFileLoaded: (file: File) => void;
|
||||
onFilesLoaded: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFilesLoaded }) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileLoaded(file);
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
onFilesLoaded(files);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -18,11 +18,25 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>1. Load PDF</h2>
|
||||
<p>Select a PDF file. Processing happens entirely in your browser.</p>
|
||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
||||
<p>
|
||||
Select one PDF to open it directly, or select several PDFs to place them
|
||||
in the merge queue. Processing happens entirely in your browser.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{pdf && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--pdf-muted)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Loaded:</strong> {pdf.name}
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,34 @@ interface HelpDialogProps {
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
|
||||
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' },
|
||||
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' },
|
||||
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
|
||||
{
|
||||
keys: 'Ctrl/⌘ + A',
|
||||
description: 'Select all pages in the current workspace',
|
||||
},
|
||||
{
|
||||
keys: 'Delete / Backspace',
|
||||
description: 'Delete the selected pages after confirmation',
|
||||
},
|
||||
{
|
||||
keys: 'Esc',
|
||||
description: 'Clear the page selection or close an open dialog',
|
||||
},
|
||||
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
|
||||
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' },
|
||||
{
|
||||
keys: 'Ctrl/⌘ + Shift + Z',
|
||||
description: 'Redo the next workspace command',
|
||||
},
|
||||
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
|
||||
{ keys: '← / → in preview', description: 'Move to the previous or next page in the preview overlay' },
|
||||
{
|
||||
keys: '← / → in preview',
|
||||
description: 'Move to the previous or next page in the preview overlay',
|
||||
},
|
||||
];
|
||||
|
||||
const tutorialSteps = [
|
||||
{
|
||||
title: '1. Open a PDF or load a workspace',
|
||||
body: 'Start by selecting a local PDF file. 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',
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
@@ -79,8 +102,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||
<p>
|
||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||
rearrange, split, merge, rotate, duplicate, and export PDFs without
|
||||
uploading documents to a server.
|
||||
rearrange, split, queue-merge, rotate, duplicate, and export PDFs
|
||||
without uploading documents to a server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,8 +142,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
))}
|
||||
</div>
|
||||
<p className="help-note">
|
||||
Shortcuts are ignored while typing in text fields or other editable
|
||||
controls.
|
||||
Shortcuts are ignored while typing in text fields or other
|
||||
editable controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
<div className="app-header-content">
|
||||
<div className="app-header-title">
|
||||
<span className="app-logo">📄</span>
|
||||
<div>
|
||||
<h1>PDF Workbench</h1>
|
||||
<small>All in your browser</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={onOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1 or ?)"
|
||||
>
|
||||
Help <span aria-hidden="true">?</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
235
src/components/MergeQueuePanel.tsx
Normal file
235
src/components/MergeQueuePanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import type { MergeQueueItem, MergeMode } from '../merge/mergeTypes';
|
||||
|
||||
interface MergeQueuePanelProps {
|
||||
items: MergeQueueItem[];
|
||||
hasCurrentPdf: boolean;
|
||||
currentPdfName: string | null;
|
||||
currentPageCount: number;
|
||||
mergeMode: MergeMode;
|
||||
mergeInsertAt: string;
|
||||
isBusy: boolean;
|
||||
canMerge: boolean;
|
||||
isLoading: boolean;
|
||||
hasErrors: boolean;
|
||||
onMergeModeChange: (mode: MergeMode) => void;
|
||||
onMergeInsertAtChange: (value: string) => void;
|
||||
onMoveUp: (itemId: string) => void;
|
||||
onMoveDown: (itemId: string) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const MergeQueuePanel: React.FC<MergeQueuePanelProps> = ({
|
||||
items,
|
||||
hasCurrentPdf,
|
||||
currentPdfName,
|
||||
currentPageCount,
|
||||
mergeMode,
|
||||
mergeInsertAt,
|
||||
isBusy,
|
||||
canMerge,
|
||||
isLoading,
|
||||
hasErrors,
|
||||
onMergeModeChange,
|
||||
onMergeInsertAtChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const queuedPageCount = items.reduce(
|
||||
(sum, item) => sum + (item.pageCount ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card merge-queue-card"
|
||||
style={{
|
||||
border: '1px solid var(--pdf-info-border)',
|
||||
background: 'var(--pdf-info-surface)',
|
||||
}}
|
||||
>
|
||||
<h2>Merge PDF queue</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
Add several PDFs, reorder the queue, then merge them into a new unsaved
|
||||
workspace. Processing still happens entirely in your browser.
|
||||
</p>
|
||||
|
||||
{hasCurrentPdf ? (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
Current workspace:{' '}
|
||||
<strong>{currentPdfName ?? 'Untitled document'}</strong> with{' '}
|
||||
{currentPageCount} {currentPageCount === 1 ? 'page' : 'pages'}.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
No current workspace is open. The queue will become a new workspace.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="merge-queue-list" aria-label="PDF merge queue">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className="merge-queue-item">
|
||||
<div className="merge-queue-order">#{index + 1}</div>
|
||||
<div className="merge-queue-details">
|
||||
<strong>{item.name}</strong>
|
||||
<span>
|
||||
{formatFileSize(item.size)} ·{' '}
|
||||
{item.status === 'ready' && item.pageCount != null
|
||||
? `${item.pageCount} ${item.pageCount === 1 ? 'page' : 'pages'}`
|
||||
: item.status === 'loading'
|
||||
? 'Loading…'
|
||||
: (item.error ?? 'Error')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="merge-queue-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onMoveUp(item.id)}
|
||||
disabled={isBusy || index === 0}
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onMoveDown(item.id)}
|
||||
disabled={isBusy || index === items.length - 1}
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onRemove(item.id)}
|
||||
disabled={isBusy}
|
||||
title="Remove from queue"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-info-text)' }}>
|
||||
Queue total:{' '}
|
||||
<strong>
|
||||
{items.length} {items.length === 1 ? 'PDF' : 'PDFs'}
|
||||
</strong>
|
||||
{queuedPageCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
· {queuedPageCount} {queuedPageCount === 1 ? 'page' : 'pages'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="merge-mode-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="overwrite"
|
||||
checked={mergeMode === 'overwrite'}
|
||||
onChange={() => onMergeModeChange('overwrite')}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<span>
|
||||
{hasCurrentPdf ? 'Replace current document' : 'Create from queue'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="append"
|
||||
checked={mergeMode === 'append'}
|
||||
onChange={() => onMergeModeChange('append')}
|
||||
disabled={isBusy || !hasCurrentPdf}
|
||||
/>
|
||||
<span>Append queue after current workspace</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="mergeMode"
|
||||
value="insertAt"
|
||||
checked={mergeMode === 'insertAt'}
|
||||
onChange={() => onMergeModeChange('insertAt')}
|
||||
disabled={isBusy || !hasCurrentPdf}
|
||||
/>
|
||||
<span>
|
||||
Insert queue starting at position{' '}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={currentPageCount + 1}
|
||||
value={mergeInsertAt}
|
||||
onChange={(e) => onMergeInsertAtChange(e.target.value)}
|
||||
disabled={isBusy || !hasCurrentPdf || mergeMode !== 'insertAt'}
|
||||
style={{
|
||||
width: '4rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
/>{' '}
|
||||
<span style={{ color: 'var(--pdf-muted-soft)' }}>
|
||||
(1 = before first page, {currentPageCount + 1} = after last page)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mergeMode === 'overwrite' && hasCurrentPdf && (
|
||||
<p className="merge-warning">
|
||||
Replace mode discards the current in-memory workspace after the merge.
|
||||
Save it first if you want to keep the current state separately.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<p className="merge-warning">
|
||||
One or more queued files could not be loaded. Remove failed items
|
||||
before merging.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="button-row" style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isBusy || isLoading || !canMerge || hasErrors}
|
||||
>
|
||||
{isBusy ? 'Working…' : isLoading ? 'Loading PDFs…' : 'Merge queue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MergeQueuePanel;
|
||||
@@ -1,19 +1,14 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
|
||||
// pdf.js worker setup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
import { pdfjsLib } from '../pdf/pdfJs';
|
||||
|
||||
interface PagePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
pdf: PdfFile | null;
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
totalPages: number;
|
||||
|
||||
canGoPrevious: boolean;
|
||||
@@ -134,6 +129,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
baseCanvas.height = scaledViewport.height;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvas: baseCanvas,
|
||||
canvasContext: baseCtx,
|
||||
viewport: scaledViewport,
|
||||
});
|
||||
@@ -323,4 +319,4 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PagePreviewModal;
|
||||
export default PagePreviewModal;
|
||||
|
||||
225
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
225
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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: 'var(--pdf-overlay)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={onConfirm}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
background: 'var(--pdf-surface-raised)',
|
||||
color: 'var(--pdf-text)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: 'var(--pdf-dialog-shadow)',
|
||||
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: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
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: 'var(--pdf-muted)',
|
||||
}}
|
||||
>
|
||||
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 var(--pdf-border-strong)',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--pdf-muted-soft)',
|
||||
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: 'var(--pdf-danger-surface)',
|
||||
border: '1px solid var(--pdf-danger-border)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
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: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: 'var(--pdf-success-text)',
|
||||
color: 'var(--pdf-on-primary)',
|
||||
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;
|
||||
217
src/components/PageWorkspace/PageCard.tsx
Normal file
217
src/components/PageWorkspace/PageCard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
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
|
||||
? 'var(--pdf-success-soft)'
|
||||
: 'var(--pdf-info-surface)'
|
||||
: selected
|
||||
? 'var(--pdf-info-surface)'
|
||||
: 'var(--pdf-surface-soft)';
|
||||
|
||||
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 var(--pdf-border)',
|
||||
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 var(--pdf-border-strong)',
|
||||
background: selected
|
||||
? 'var(--pdf-primary)'
|
||||
: 'color-mix(in srgb, var(--pdf-surface) 92%, transparent)',
|
||||
color: selected ? 'var(--pdf-on-primary)' : 'transparent',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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 var(--pdf-paper-border)',
|
||||
background: 'var(--pdf-paper)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed var(--pdf-paper-border)',
|
||||
background: 'var(--pdf-paper-placeholder)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: '0.8rem' }}>
|
||||
Page {page.sourcePageIndex + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
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: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRotateCounterclockwise();
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: 'var(--pdf-control)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
}}
|
||||
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;
|
||||
114
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
114
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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: 'var(--pdf-success-soft)',
|
||||
color: 'var(--pdf-success-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Copy selected pages to another position"
|
||||
>
|
||||
Copy selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: 'var(--pdf-success-border)',
|
||||
color: 'var(--pdf-control-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={!hasSelection}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: 'var(--pdf-control)',
|
||||
color: hasSelection
|
||||
? 'var(--pdf-control-text)'
|
||||
: 'var(--pdf-muted-soft)',
|
||||
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 CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
|
||||
import PageGrid from './PageWorkspace/PageGrid';
|
||||
import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
|
||||
|
||||
interface ReorderPanelProps {
|
||||
pages: PageRef[];
|
||||
@@ -53,8 +56,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
|
||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
||||
|
||||
const cleanupDragGhost = () => {
|
||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||
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);
|
||||
};
|
||||
|
||||
const resetDragState = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(visualIndex);
|
||||
setDropIndex(visualIndex);
|
||||
@@ -122,10 +130,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (pageId: string) => () => {
|
||||
const handleDeleteClick = (pageId: string) => {
|
||||
onDelete(pageId);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleRotateClickClockwise = (pageId: string) => () => {
|
||||
onRotateClockwise(pageId);
|
||||
};
|
||||
|
||||
const handleRotateClickCounterclockwise = (pageId: string) => () => {
|
||||
onRotateCounterclockwise(pageId);
|
||||
};
|
||||
|
||||
const handleCardClick = (pageId: string) => () => {
|
||||
onOpenPreview(pageId);
|
||||
};
|
||||
|
||||
const handleCheckboxClick =
|
||||
(pageId: string, visualIndex: number) =>
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // don't trigger preview
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
|
||||
const handleCopySelectedClick = () => {
|
||||
if (selectedPageIds.length === 0) return;
|
||||
|
||||
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
|
||||
setCopyTargetPosition(String(pages.length + 1));
|
||||
setCopyDialogError(null);
|
||||
setCopyDialogOpen(true);
|
||||
};
|
||||
@@ -245,6 +238,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyTargetPositionChange = (value: string) => {
|
||||
setCopyTargetPosition(value);
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
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) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -293,554 +274,68 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const showLeftLine = (visualIndex: number) =>
|
||||
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||
|
||||
const showRightLine = (visualIndex: number) =>
|
||||
dropIndex !== null &&
|
||||
dropIndex === visualIndex + 1 &&
|
||||
draggingIndex !== null;
|
||||
|
||||
const showEndLine = () =>
|
||||
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
|
||||
|
||||
// For highlighting the whole selection while dragging it
|
||||
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
||||
const draggingSelectionActive =
|
||||
draggingPage != null &&
|
||||
selectedPageIds.length > 0 &&
|
||||
selectedPageIds.includes(draggingPage.id);
|
||||
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
||||
const dropIndicatorColor = isCopyDragging
|
||||
? 'var(--pdf-success-text)'
|
||||
: 'var(--pdf-primary)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Pages</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Tap/click a page to preview it. Use the checkbox to select pages
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves the
|
||||
whole selection. Hold Ctrl/⌘ while dropping to copy instead of move.
|
||||
Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears
|
||||
selection.
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
||||
Esc clears selection.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Selected: <strong>{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>
|
||||
<PageSelectionToolbar
|
||||
selectedCount={selectedPageIds.length}
|
||||
onCopySelected={handleCopySelectedClick}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
<PageGrid
|
||||
pages={pages}
|
||||
thumbnails={thumbnails}
|
||||
selectedPageIds={selectedPageIds}
|
||||
isBusy={isBusy}
|
||||
draggingIndex={draggingIndex}
|
||||
dropIndex={dropIndex}
|
||||
draggingSelectionActive={draggingSelectionActive}
|
||||
isCopyDragging={isCopyDragging}
|
||||
dropIndicatorColor={dropIndicatorColor}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onCardDragOver={handleCardDragOver}
|
||||
onEndSlotDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{pages.map((page, visualIndex) => {
|
||||
const thumb = thumbnails[page.id];
|
||||
const rotation = page.rotation;
|
||||
const selected = isSelected(page.id);
|
||||
|
||||
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>
|
||||
onOpenPreview={onOpenPreview}
|
||||
onToggleSelect={handleCheckboxClick}
|
||||
onRotateClockwise={onRotateClockwise}
|
||||
onRotateCounterclockwise={onRotateCounterclockwise}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{copyDialogOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="copy-pages-dialog-title"
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleCopyDialogCancel();
|
||||
}
|
||||
}}
|
||||
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>
|
||||
<CopyPagesDialog
|
||||
selectedCount={selectedPageIds.length}
|
||||
pageCount={pages.length}
|
||||
targetPosition={copyTargetPosition}
|
||||
error={copyDialogError}
|
||||
onTargetPositionChange={handleCopyTargetPositionChange}
|
||||
onCancel={handleCopyDialogCancel}
|
||||
onConfirm={handleCopyDialogConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,325 +3,335 @@ import type { WorkspaceSummary } from '../workspace/workspaceTypes';
|
||||
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
||||
|
||||
interface WorkspacePanelProps {
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}
|
||||
|
||||
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--pdf-muted-soft)' }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
|
||||
<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 var(--pdf-border-strong)',
|
||||
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: 'var(--pdf-warning-text)',
|
||||
}}
|
||||
>
|
||||
Unsaved workspace changes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--pdf-success-text)',
|
||||
}}
|
||||
>
|
||||
{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 var(--pdf-border)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
background: active
|
||||
? 'var(--pdf-info-surface)'
|
||||
: 'var(--pdf-surface-soft)',
|
||||
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: 'var(--pdf-primary)' }}>
|
||||
{' '}
|
||||
· active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--pdf-muted-soft)',
|
||||
}}
|
||||
>
|
||||
{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: 'var(--pdf-danger-soft)',
|
||||
color: 'var(--pdf-danger-text)',
|
||||
}}
|
||||
>
|
||||
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: 'var(--pdf-muted)',
|
||||
borderLeft: '3px solid var(--pdf-primary)',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: 'var(--pdf-muted-soft)' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--pdf-success-surface)',
|
||||
color: 'var(--pdf-success-text)',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
alignSelf: 'flex-start',
|
||||
border: '2px solid var(--pdf-success-text)',
|
||||
width: '100%',
|
||||
}}
|
||||
></div>
|
||||
|
||||
<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 && (
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: '#92400e',
|
||||
}}
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--pdf-muted-faint)',
|
||||
borderLeft: '3px solid var(--pdf-border-strong)',
|
||||
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: 'var(--pdf-muted-faint)' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#166534',
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.4rem',
|
||||
marginTop: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
const active = workspace.id === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
background: active ? '#eff6ff' : '#f9fafb',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: '#2563eb' }}> · active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
|
||||
{workspace.pdfName} · source pages:{' '}
|
||||
{workspace.sourcePageCount} · workspace pages:{' '}
|
||||
{workspace.workspacePageCount} · undo:{' '}
|
||||
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
|
||||
{new Date(workspace.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.35rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onLoadWorkspace(workspace.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(history.length > 0 || redoHistory.length > 0) && (
|
||||
<details style={{ marginTop: '0.75rem' }} open>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||
</summary>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#374151',
|
||||
borderLeft: '3px solid #2563eb',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
borderRadius: '999px',
|
||||
background: '#ecfdf5',
|
||||
color: '#166534',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
alignSelf: 'flex-start',
|
||||
border: '2px solid #166534',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#9ca3af',
|
||||
borderLeft: '3px solid #d1d5db',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Redo {index + 1}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#9ca3af' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
||||
82
src/merge/mergeQueueHelpers.test.ts
Normal file
82
src/merge/mergeQueueHelpers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MergeQueueItem } from './mergeTypes';
|
||||
import {
|
||||
canMergeQueue,
|
||||
clampMergeInsertAt,
|
||||
createMergedPdfName,
|
||||
getReadyMergeQueuePdfs,
|
||||
moveMergeQueueItem,
|
||||
} from './mergeQueueHelpers';
|
||||
|
||||
function makeItem(
|
||||
id: string,
|
||||
status: MergeQueueItem['status']
|
||||
): MergeQueueItem {
|
||||
return {
|
||||
id,
|
||||
file: new File(['x'], `${id}.pdf`, { type: 'application/pdf' }),
|
||||
name: `${id}.pdf`,
|
||||
size: 1,
|
||||
pageCount: status === 'ready' ? 1 : null,
|
||||
pdf:
|
||||
status === 'ready'
|
||||
? {
|
||||
id: `pdf-${id}`,
|
||||
name: `${id}.pdf`,
|
||||
// The helper tests never dereference the PDFDocument.
|
||||
doc: {} as never,
|
||||
pageCount: 1,
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
}
|
||||
: null,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe('merge queue helpers', () => {
|
||||
it('moves queued items up and down without mutating the original array', () => {
|
||||
const items = [
|
||||
makeItem('a', 'ready'),
|
||||
makeItem('b', 'ready'),
|
||||
makeItem('c', 'ready'),
|
||||
];
|
||||
|
||||
expect(moveMergeQueueItem(items, 'b', 'up').map((item) => item.id)).toEqual(
|
||||
['b', 'a', 'c']
|
||||
);
|
||||
expect(
|
||||
moveMergeQueueItem(items, 'b', 'down').map((item) => item.id)
|
||||
).toEqual(['a', 'c', 'b']);
|
||||
expect(items.map((item) => item.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('only allows merging when every queued item is ready', () => {
|
||||
const readyItems = [makeItem('a', 'ready'), makeItem('b', 'ready')];
|
||||
const mixedItems = [makeItem('a', 'ready'), makeItem('b', 'loading')];
|
||||
|
||||
expect(canMergeQueue(readyItems)).toBe(true);
|
||||
expect(getReadyMergeQueuePdfs(readyItems)).toHaveLength(2);
|
||||
expect(canMergeQueue(mixedItems)).toBe(false);
|
||||
expect(canMergeQueue([])).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps one-based merge positions to zero-based insert slots', () => {
|
||||
expect(clampMergeInsertAt('1', 10)).toBe(0);
|
||||
expect(clampMergeInsertAt('5', 10)).toBe(4);
|
||||
expect(clampMergeInsertAt('99', 10)).toBe(10);
|
||||
expect(clampMergeInsertAt('-10', 10)).toBe(0);
|
||||
expect(clampMergeInsertAt('not-a-number', 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('creates readable merged PDF filenames', () => {
|
||||
expect(createMergedPdfName('base.pdf', ['a.pdf', 'b.pdf'], 'append')).toBe(
|
||||
'base_plus_2_pdfs.pdf'
|
||||
);
|
||||
expect(createMergedPdfName('base.pdf', ['a.pdf'], 'overwrite')).toBe(
|
||||
'a_merged.pdf'
|
||||
);
|
||||
expect(createMergedPdfName(null, ['a.pdf', 'b.pdf'], 'overwrite')).toBe(
|
||||
'merged_2_pdfs.pdf'
|
||||
);
|
||||
});
|
||||
});
|
||||
86
src/merge/mergeQueueHelpers.ts
Normal file
86
src/merge/mergeQueueHelpers.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import type { MergeMode, MergeQueueItem } from './mergeTypes';
|
||||
|
||||
export function createMergeQueueItemId(): string {
|
||||
return `merge_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function moveMergeQueueItem(
|
||||
items: MergeQueueItem[],
|
||||
itemId: string,
|
||||
direction: 'up' | 'down'
|
||||
): MergeQueueItem[] {
|
||||
const index = items.findIndex((item) => item.id === itemId);
|
||||
if (index < 0) return items;
|
||||
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= items.length) return items;
|
||||
|
||||
const next = [...items];
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(targetIndex, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getReadyMergeQueuePdfs(items: MergeQueueItem[]): PdfFile[] {
|
||||
return items
|
||||
.filter((item) => item.status === 'ready' && item.pdf)
|
||||
.map((item) => item.pdf as PdfFile);
|
||||
}
|
||||
|
||||
export function canMergeQueue(items: MergeQueueItem[]): boolean {
|
||||
return (
|
||||
items.length > 0 &&
|
||||
items.every((item) => item.status === 'ready' && item.pdf !== null)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMergeQueueErrors(items: MergeQueueItem[]): boolean {
|
||||
return items.some((item) => item.status === 'error');
|
||||
}
|
||||
|
||||
export function isMergeQueueLoading(items: MergeQueueItem[]): boolean {
|
||||
return items.some((item) => item.status === 'loading');
|
||||
}
|
||||
|
||||
export function clampMergeInsertAt(value: string, pageCount: number): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return pageCount;
|
||||
|
||||
return Math.min(Math.max(parsed - 1, 0), pageCount);
|
||||
}
|
||||
|
||||
export function defaultMergeInsertPosition(pageCount: number): string {
|
||||
return String(pageCount + 1);
|
||||
}
|
||||
|
||||
export function createMergedPdfName(
|
||||
currentPdfName: string | null,
|
||||
incomingPdfNames: string[],
|
||||
mode: MergeMode
|
||||
): string {
|
||||
const incomingBaseNames = incomingPdfNames.map(stripPdfExtension);
|
||||
|
||||
if (incomingBaseNames.length === 0) {
|
||||
return currentPdfName ?? 'merged.pdf';
|
||||
}
|
||||
|
||||
if (
|
||||
incomingBaseNames.length === 1 &&
|
||||
(!currentPdfName || mode === 'overwrite')
|
||||
) {
|
||||
return `${incomingBaseNames[0]}_merged.pdf`;
|
||||
}
|
||||
|
||||
if (currentPdfName && mode !== 'overwrite') {
|
||||
const currentBaseName = stripPdfExtension(currentPdfName);
|
||||
return `${currentBaseName}_plus_${incomingBaseNames.length}_pdfs.pdf`;
|
||||
}
|
||||
|
||||
return `merged_${incomingBaseNames.length}_pdfs.pdf`;
|
||||
}
|
||||
|
||||
function stripPdfExtension(filename: string): string {
|
||||
const base = filename.replace(/\.pdf$/i, '').trim();
|
||||
return base || 'document';
|
||||
}
|
||||
16
src/merge/mergeTypes.ts
Normal file
16
src/merge/mergeTypes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
|
||||
export type MergeMode = 'overwrite' | 'append' | 'insertAt';
|
||||
|
||||
export type MergeQueueItemStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface MergeQueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
name: string;
|
||||
size: number;
|
||||
pageCount: number | null;
|
||||
pdf: PdfFile | null;
|
||||
status: MergeQueueItemStatus;
|
||||
error?: string;
|
||||
}
|
||||
102
src/merge/useMergeQueue.ts
Normal file
102
src/merge/useMergeQueue.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { loadPdfFromFile } from '../pdf/pdfService';
|
||||
import {
|
||||
canMergeQueue,
|
||||
createMergeQueueItemId,
|
||||
getReadyMergeQueuePdfs,
|
||||
hasMergeQueueErrors,
|
||||
isMergeQueueLoading,
|
||||
moveMergeQueueItem,
|
||||
} from './mergeQueueHelpers';
|
||||
import type { MergeQueueItem } from './mergeTypes';
|
||||
|
||||
export function useMergeQueue() {
|
||||
const [items, setItems] = useState<MergeQueueItem[]>([]);
|
||||
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
const pdfFiles = files.filter(
|
||||
(file) =>
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
|
||||
if (pdfFiles.length === 0) return;
|
||||
|
||||
const queuedItems: MergeQueueItem[] = pdfFiles.map((file) => ({
|
||||
id: createMergeQueueItemId(),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
pageCount: null,
|
||||
pdf: null,
|
||||
status: 'loading',
|
||||
}));
|
||||
|
||||
setItems((current) => [...current, ...queuedItems]);
|
||||
|
||||
queuedItems.forEach((item) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const loadedPdf = await loadPdfFromFile(item.file);
|
||||
|
||||
setItems((current) =>
|
||||
current.map((currentItem) =>
|
||||
currentItem.id === item.id
|
||||
? {
|
||||
...currentItem,
|
||||
pdf: loadedPdf,
|
||||
pageCount: loadedPdf.pageCount,
|
||||
status: 'ready',
|
||||
error: undefined,
|
||||
}
|
||||
: currentItem
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
setItems((current) =>
|
||||
current.map((currentItem) =>
|
||||
currentItem.id === item.id
|
||||
? {
|
||||
...currentItem,
|
||||
status: 'error',
|
||||
error: 'Could not load this PDF.',
|
||||
}
|
||||
: currentItem
|
||||
)
|
||||
);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((itemId: string) => {
|
||||
setItems((current) => current.filter((item) => item.id !== itemId));
|
||||
}, []);
|
||||
|
||||
const moveItemUp = useCallback((itemId: string) => {
|
||||
setItems((current) => moveMergeQueueItem(current, itemId, 'up'));
|
||||
}, []);
|
||||
|
||||
const moveItemDown = useCallback((itemId: string) => {
|
||||
setItems((current) => moveMergeQueueItem(current, itemId, 'down'));
|
||||
}, []);
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
setItems([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
addFiles,
|
||||
removeItem,
|
||||
moveItemUp,
|
||||
moveItemDown,
|
||||
clearQueue,
|
||||
readyPdfs: getReadyMergeQueuePdfs(items),
|
||||
canMerge: canMergeQueue(items),
|
||||
hasErrors: hasMergeQueueErrors(items),
|
||||
isLoading: isMergeQueueLoading(items),
|
||||
};
|
||||
}
|
||||
9
src/pdf/pdfJs.ts
Normal file
9
src/pdf/pdfJs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import './uint8ArrayToHexPolyfill';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pdfjsWorker from './pdfjsWorker?worker&url';
|
||||
|
||||
// pdf.js worker setup for Vite.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
export { pdfjsLib };
|
||||
57
src/pdf/pdfMergeService.test.ts
Normal file
57
src/pdf/pdfMergeService.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PdfFile } from './pdfTypes';
|
||||
import { mergePdfFilesAtPosition } from './pdfService';
|
||||
|
||||
async function makePdf(name: string, pageCount: number): Promise<PdfFile> {
|
||||
const doc = await PDFDocument.create();
|
||||
for (let i = 0; i < pageCount; i += 1) {
|
||||
doc.addPage([100, 100]);
|
||||
}
|
||||
|
||||
const bytes = await doc.save();
|
||||
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(arrayBuffer).set(bytes);
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
doc,
|
||||
pageCount,
|
||||
arrayBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergePdfFilesAtPosition', () => {
|
||||
it('merges a queue without a current base PDF', async () => {
|
||||
const first = await makePdf('first.pdf', 1);
|
||||
const second = await makePdf('second.pdf', 2);
|
||||
|
||||
const merged = await mergePdfFilesAtPosition({
|
||||
basePdf: null,
|
||||
incomingPdfs: [first, second],
|
||||
insertAt: 0,
|
||||
name: 'merged.pdf',
|
||||
});
|
||||
|
||||
expect(merged.name).toBe('merged.pdf');
|
||||
expect(merged.pageCount).toBe(3);
|
||||
expect(merged.doc.getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('inserts queued PDFs into a current base PDF at the requested slot', async () => {
|
||||
const base = await makePdf('base.pdf', 3);
|
||||
const incoming = await makePdf('incoming.pdf', 2);
|
||||
|
||||
const merged = await mergePdfFilesAtPosition({
|
||||
basePdf: base,
|
||||
incomingPdfs: [incoming],
|
||||
insertAt: 1,
|
||||
name: 'base_plus_incoming.pdf',
|
||||
});
|
||||
|
||||
expect(merged.name).toBe('base_plus_incoming.pdf');
|
||||
expect(merged.pageCount).toBe(5);
|
||||
expect(merged.doc.getPageCount()).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,16 @@ function createId() {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function pdfBytesToBlob(bytes: Uint8Array): Blob {
|
||||
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const doc = await PDFDocument.load(arrayBuffer);
|
||||
@@ -23,8 +33,8 @@ export async function mergePdfFiles(
|
||||
newPdf: PdfFile,
|
||||
insertAt: number
|
||||
): Promise<PdfFile> {
|
||||
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
|
||||
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
|
||||
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
|
||||
|
||||
const mergedDoc = await PDFDocument.create();
|
||||
|
||||
@@ -53,8 +63,7 @@ export async function mergePdfFiles(
|
||||
}
|
||||
|
||||
const bytes = await mergedDoc.save();
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||
|
||||
const baseName = basePdf.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(
|
||||
pdf: PdfFile
|
||||
): Promise<SplitResult[]> {
|
||||
@@ -99,7 +178,7 @@ export async function splitIntoSinglePages(
|
||||
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const blob = pdfBytesToBlob(bytes);
|
||||
|
||||
const base = name.replace(/\.pdf$/i, '');
|
||||
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
||||
@@ -114,10 +193,7 @@ export async function splitIntoSinglePages(
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function extractRange(
|
||||
pdf: PdfFile,
|
||||
range: Range
|
||||
): Promise<Blob> {
|
||||
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
|
||||
const { doc } = pdf;
|
||||
const pageCount = doc.getPageCount();
|
||||
|
||||
@@ -136,7 +212,7 @@ export async function extractRange(
|
||||
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||
@@ -150,7 +226,7 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||
}
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function exportPages(
|
||||
@@ -188,7 +264,7 @@ export async function exportPages(
|
||||
});
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function exportReordered(
|
||||
@@ -204,4 +280,4 @@ export async function exportReordered(
|
||||
rotation: rotations?.[sourcePageIndex] ?? 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
|
||||
// pdf.js worker setup for Vite
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
import { pdfjsLib } from './pdfJs';
|
||||
|
||||
type RotationsMap = Record<number, number>; // key: 0-based page index, value: degrees
|
||||
|
||||
@@ -37,7 +32,6 @@ interface ThumbnailGenerationOptions {
|
||||
onThumbnail?: (update: ThumbnailUpdate) => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
||||
*/
|
||||
@@ -112,7 +106,7 @@ async function generateThumbnailsInternal(
|
||||
while (!signal?.aborted) {
|
||||
const pageNum = pageNums[nextPageIndex];
|
||||
nextPageIndex += 1;
|
||||
|
||||
|
||||
if (pageNum == null) return;
|
||||
|
||||
await renderOne(pageNum);
|
||||
@@ -133,7 +127,11 @@ async function generateThumbnailsInternal(
|
||||
}
|
||||
|
||||
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,
|
||||
rotations: RotationsMap,
|
||||
maxHeight: number,
|
||||
@@ -154,6 +152,7 @@ async function renderPageThumbnail(
|
||||
baseCanvas.height = scaledViewport.height;
|
||||
|
||||
const renderTask = page.render({
|
||||
canvas: baseCanvas,
|
||||
canvasContext: baseCtx,
|
||||
viewport: scaledViewport,
|
||||
});
|
||||
@@ -205,4 +204,4 @@ async function renderPageThumbnail(
|
||||
rotatedCtx.restore();
|
||||
|
||||
return rotatedCanvas.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
|
||||
86
src/pdf/pdfZipService.test.ts
Normal file
86
src/pdf/pdfZipService.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { unzipSync } from 'fflate';
|
||||
import { createSplitResultsZip, createSplitZipFilename } from './pdfZipService';
|
||||
import type { SplitResult } from './pdfTypes';
|
||||
|
||||
async function unzipBlob(blob: Blob): Promise<Record<string, Uint8Array>> {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
return unzipSync(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
|
||||
describe('pdfZipService', () => {
|
||||
it('creates a ZIP archive from split PDF blobs', async () => {
|
||||
const results: SplitResult[] = [
|
||||
{
|
||||
pageIndex: 0,
|
||||
filename: 'document_page_001.pdf',
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
},
|
||||
{
|
||||
pageIndex: 1,
|
||||
filename: 'document_page_002.pdf',
|
||||
blob: new Blob([new Uint8Array([4, 5, 6])], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const zipBlob = await createSplitResultsZip(results);
|
||||
const entries = await unzipBlob(zipBlob);
|
||||
|
||||
expect(zipBlob.type).toBe('application/zip');
|
||||
expect(Object.keys(entries)).toEqual([
|
||||
'document_page_001.pdf',
|
||||
'document_page_002.pdf',
|
||||
]);
|
||||
expect(Array.from(entries['document_page_001.pdf'])).toEqual([1, 2, 3]);
|
||||
expect(Array.from(entries['document_page_002.pdf'])).toEqual([4, 5, 6]);
|
||||
});
|
||||
|
||||
it('sanitizes and deduplicates ZIP entry names', async () => {
|
||||
const results: SplitResult[] = [
|
||||
{
|
||||
pageIndex: 0,
|
||||
filename: '../page.pdf',
|
||||
blob: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
|
||||
},
|
||||
{
|
||||
pageIndex: 1,
|
||||
filename: '../page.pdf',
|
||||
blob: new Blob([new Uint8Array([2])], { type: 'application/pdf' }),
|
||||
},
|
||||
{
|
||||
pageIndex: 2,
|
||||
filename: '',
|
||||
blob: new Blob([new Uint8Array([3])], { type: 'application/pdf' }),
|
||||
},
|
||||
];
|
||||
|
||||
const zipBlob = await createSplitResultsZip(results);
|
||||
const entries = await unzipBlob(zipBlob);
|
||||
|
||||
expect(Object.keys(entries)).toEqual([
|
||||
'.._page.pdf',
|
||||
'.._page_2.pdf',
|
||||
'page_003.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a readable ZIP filename from the source PDF name', () => {
|
||||
expect(createSplitZipFilename('contract.pdf')).toBe(
|
||||
'contract_split_pages.zip'
|
||||
);
|
||||
expect(createSplitZipFilename('contract.final.PDF')).toBe(
|
||||
'contract.final_split_pages.zip'
|
||||
);
|
||||
expect(createSplitZipFilename('')).toBe('document_split_pages.zip');
|
||||
});
|
||||
|
||||
it('rejects empty split results', async () => {
|
||||
await expect(createSplitResultsZip([])).rejects.toThrow(
|
||||
'Cannot create a ZIP archive without split results.'
|
||||
);
|
||||
});
|
||||
});
|
||||
78
src/pdf/pdfZipService.ts
Normal file
78
src/pdf/pdfZipService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { zipSync } from 'fflate';
|
||||
import type { SplitResult } from './pdfTypes';
|
||||
|
||||
function bytesToBlob(bytes: Uint8Array, type: string): Blob {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return new Blob([buffer], { type });
|
||||
}
|
||||
|
||||
function removeControlCharacters(value: string): string {
|
||||
return Array.from(value)
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function safeZipEntryName(filename: string, fallback: string): string {
|
||||
const cleaned = removeControlCharacters(filename)
|
||||
.replace(/[\\/]+/g, '_')
|
||||
.trim();
|
||||
|
||||
return cleaned.length > 0 ? cleaned : fallback;
|
||||
}
|
||||
|
||||
function uniqueZipEntryName(filename: string, usedNames: Set<string>): string {
|
||||
if (!usedNames.has(filename)) {
|
||||
usedNames.add(filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
const hasExtension = dotIndex > 0;
|
||||
const base = hasExtension ? filename.slice(0, dotIndex) : filename;
|
||||
const extension = hasExtension ? filename.slice(dotIndex) : '';
|
||||
|
||||
let counter = 2;
|
||||
let candidate = `${base}_${counter}${extension}`;
|
||||
|
||||
while (usedNames.has(candidate)) {
|
||||
counter += 1;
|
||||
candidate = `${base}_${counter}${extension}`;
|
||||
}
|
||||
|
||||
usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function createSplitZipFilename(pdfName: string): string {
|
||||
const baseName = pdfName.replace(/\.pdf$/i, '').trim() || 'document';
|
||||
return `${baseName}_split_pages.zip`;
|
||||
}
|
||||
|
||||
export async function createSplitResultsZip(
|
||||
results: SplitResult[]
|
||||
): Promise<Blob> {
|
||||
if (results.length === 0) {
|
||||
throw new Error('Cannot create a ZIP archive without split results.');
|
||||
}
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const result of results) {
|
||||
const fallback = `page_${String(result.pageIndex + 1).padStart(3, '0')}.pdf`;
|
||||
const entryName = uniqueZipEntryName(
|
||||
safeZipEntryName(result.filename, fallback),
|
||||
usedNames
|
||||
);
|
||||
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
entries[entryName] = new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
const zippedBytes = zipSync(entries, { level: 6 });
|
||||
return bytesToBlob(zippedBytes, 'application/zip');
|
||||
}
|
||||
2
src/pdf/pdfjsWorker.ts
Normal file
2
src/pdf/pdfjsWorker.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './uint8ArrayToHexPolyfill';
|
||||
import 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||
22
src/pdf/uint8ArrayToHexPolyfill.ts
Normal file
22
src/pdf/uint8ArrayToHexPolyfill.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
type Uint8ArrayWithToHex = Uint8Array & {
|
||||
toHex?: () => string;
|
||||
};
|
||||
|
||||
const uint8ArrayPrototype = Uint8Array.prototype as Uint8ArrayWithToHex;
|
||||
|
||||
if (typeof uint8ArrayPrototype.toHex !== 'function') {
|
||||
Object.defineProperty(Uint8Array.prototype, 'toHex', {
|
||||
value: function toHex(this: Uint8Array): string {
|
||||
const hex = new Array<string>(this.length);
|
||||
|
||||
for (let index = 0; index < this.length; index += 1) {
|
||||
const value = this[index].toString(16);
|
||||
hex[index] = value.length === 1 ? `0${value}` : value;
|
||||
}
|
||||
|
||||
return hex.join('');
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
442
src/styles.css
442
src/styles.css
@@ -6,91 +6,36 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
background-color: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 260px;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-sidebar h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-sidebar small {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-nav button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-nav button.active {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.app-nav button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card {
|
||||
border: 1px solid var(--pdf-border);
|
||||
background: var(--pdf-surface);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||||
box-shadow: var(--pdf-card-shadow);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.primary {
|
||||
background: var(--pdf-primary);
|
||||
color: var(--pdf-on-primary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
@@ -98,14 +43,14 @@ button.primary {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
.pdf-toolbox-shell .toolbox-shell__main button.secondary {
|
||||
background: var(--pdf-control);
|
||||
color: var(--pdf-control-text);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
@@ -113,121 +58,175 @@ button.secondary {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
.pdf-toolbox-shell {
|
||||
--pdf-surface: var(--toolbox-surface);
|
||||
--pdf-surface-soft: #f9fafb;
|
||||
--pdf-surface-raised: #ffffff;
|
||||
--pdf-text: var(--toolbox-text);
|
||||
--pdf-muted: #4b5563;
|
||||
--pdf-muted-soft: #6b7280;
|
||||
--pdf-muted-faint: #9ca3af;
|
||||
--pdf-border: #e5e7eb;
|
||||
--pdf-border-strong: #d1d5db;
|
||||
--pdf-primary: #2563eb;
|
||||
--pdf-on-primary: #ffffff;
|
||||
--pdf-control: #e5e7eb;
|
||||
--pdf-control-text: #111827;
|
||||
--pdf-info-surface: #eff6ff;
|
||||
--pdf-info-border: #bfdbfe;
|
||||
--pdf-info-text: #1e3a8a;
|
||||
--pdf-success-surface: #ecfdf5;
|
||||
--pdf-success-soft: #dcfce7;
|
||||
--pdf-success-border: #86efac;
|
||||
--pdf-success-text: #166534;
|
||||
--pdf-warning-surface: #fff7ed;
|
||||
--pdf-warning-border: #fed7aa;
|
||||
--pdf-warning-text: #9a3412;
|
||||
--pdf-danger-surface: #fef2f2;
|
||||
--pdf-danger-soft: #fee2e2;
|
||||
--pdf-danger-border: #fecaca;
|
||||
--pdf-danger-text: #b91c1c;
|
||||
--pdf-overlay: rgba(15, 23, 42, 0.62);
|
||||
--pdf-dialog-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
|
||||
--pdf-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||||
--pdf-paper: #ffffff;
|
||||
--pdf-paper-placeholder: #f3f4f6;
|
||||
--pdf-paper-border: #d1d5db;
|
||||
--pdf-paper-text: #111827;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 0.6rem 1rem;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
|
||||
.pdf-toolbox-shell[data-toolbox-theme='dark'] {
|
||||
--pdf-surface-soft: #20283a;
|
||||
--pdf-surface-raised: #1b2334;
|
||||
--pdf-muted: #b6bfd2;
|
||||
--pdf-muted-soft: #a9b3ca;
|
||||
--pdf-muted-faint: #8792aa;
|
||||
--pdf-border: #303a50;
|
||||
--pdf-border-strong: #465169;
|
||||
--pdf-primary: #8798f5;
|
||||
--pdf-on-primary: #101728;
|
||||
--pdf-control: #293247;
|
||||
--pdf-control-text: #edf1fb;
|
||||
--pdf-info-surface: #182946;
|
||||
--pdf-info-border: #345a8c;
|
||||
--pdf-info-text: #c6dcff;
|
||||
--pdf-success-surface: #12382e;
|
||||
--pdf-success-soft: #174436;
|
||||
--pdf-success-border: #33765d;
|
||||
--pdf-success-text: #a7e8cf;
|
||||
--pdf-warning-surface: #402814;
|
||||
--pdf-warning-border: #80522d;
|
||||
--pdf-warning-text: #ffd2a6;
|
||||
--pdf-danger-surface: #421f29;
|
||||
--pdf-danger-soft: #4f2530;
|
||||
--pdf-danger-border: #81394b;
|
||||
--pdf-danger-text: #ffc0cc;
|
||||
--pdf-overlay: rgba(2, 6, 16, 0.76);
|
||||
--pdf-dialog-shadow: 0 24px 52px rgba(0, 0, 0, 0.5);
|
||||
--pdf-card-shadow: 0 2px 7px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
.app-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pdf-toolbox-shell[data-toolbox-theme='system'] {
|
||||
--pdf-surface-soft: #20283a;
|
||||
--pdf-surface-raised: #1b2334;
|
||||
--pdf-muted: #b6bfd2;
|
||||
--pdf-muted-soft: #a9b3ca;
|
||||
--pdf-muted-faint: #8792aa;
|
||||
--pdf-border: #303a50;
|
||||
--pdf-border-strong: #465169;
|
||||
--pdf-primary: #8798f5;
|
||||
--pdf-on-primary: #101728;
|
||||
--pdf-control: #293247;
|
||||
--pdf-control-text: #edf1fb;
|
||||
--pdf-info-surface: #182946;
|
||||
--pdf-info-border: #345a8c;
|
||||
--pdf-info-text: #c6dcff;
|
||||
--pdf-success-surface: #12382e;
|
||||
--pdf-success-soft: #174436;
|
||||
--pdf-success-border: #33765d;
|
||||
--pdf-success-text: #a7e8cf;
|
||||
--pdf-warning-surface: #402814;
|
||||
--pdf-warning-border: #80522d;
|
||||
--pdf-warning-text: #ffd2a6;
|
||||
--pdf-danger-surface: #421f29;
|
||||
--pdf-danger-soft: #4f2530;
|
||||
--pdf-danger-border: #81394b;
|
||||
--pdf-danger-text: #ffc0cc;
|
||||
--pdf-overlay: rgba(2, 6, 16, 0.76);
|
||||
--pdf-dialog-shadow: 0 24px 52px rgba(0, 0, 0, 0.5);
|
||||
--pdf-card-shadow: 0 2px 7px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-header small {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
.pdf-toolbox-shell .toolbox-shell__main {
|
||||
padding: 0.75rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
max-width: 90rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Make cards full-width on mobile */
|
||||
.card {
|
||||
.pdf-toolbox-shell .toolbox-shell__main input,
|
||||
.pdf-toolbox-shell .toolbox-shell__main select,
|
||||
.pdf-toolbox-shell .toolbox-shell__main textarea {
|
||||
border-color: var(--pdf-border-strong);
|
||||
background: var(--pdf-surface);
|
||||
color: var(--pdf-text);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main input::placeholder,
|
||||
.pdf-toolbox-shell .toolbox-shell__main textarea::placeholder {
|
||||
color: var(--pdf-muted-soft);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main a {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main a:hover {
|
||||
color: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main button:disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main button:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main a:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main input:focus-visible,
|
||||
.pdf-toolbox-shell .toolbox-shell__main select:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 72%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .download-link {
|
||||
display: inline-block;
|
||||
margin: 0.15rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card hr {
|
||||
.pdf-toolbox-shell .toolbox-shell__main .card hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-help-button {
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-help-button:hover,
|
||||
.app-help-button:focus-visible {
|
||||
background: #374151;
|
||||
outline: none;
|
||||
border-top: 1px solid var(--pdf-border);
|
||||
}
|
||||
|
||||
.help-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
background: var(--pdf-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -238,9 +237,9 @@ button.secondary {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
background: var(--pdf-surface-raised);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
|
||||
box-shadow: var(--pdf-dialog-shadow);
|
||||
}
|
||||
|
||||
.help-dialog-header {
|
||||
@@ -252,8 +251,8 @@ button.secondary {
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface-raised);
|
||||
border-bottom: 1px solid var(--pdf-border);
|
||||
}
|
||||
|
||||
.help-dialog-header h2 {
|
||||
@@ -263,7 +262,7 @@ button.secondary {
|
||||
|
||||
.help-dialog-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -274,8 +273,8 @@ button.secondary {
|
||||
border-radius: 999px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
background: var(--pdf-control);
|
||||
color: var(--pdf-control-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@@ -289,10 +288,10 @@ button.secondary {
|
||||
}
|
||||
|
||||
.help-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--pdf-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
background: #f9fafb;
|
||||
background: var(--pdf-surface-soft);
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
@@ -308,8 +307,8 @@ button.secondary {
|
||||
|
||||
.help-step {
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface);
|
||||
border: 1px solid var(--pdf-border);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -321,7 +320,7 @@ button.secondary {
|
||||
.help-step p,
|
||||
.help-note {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -337,18 +336,18 @@ button.secondary {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--pdf-border-strong);
|
||||
background: var(--pdf-surface);
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #111827;
|
||||
box-shadow: inset 0 -1px 0 #d1d5db;
|
||||
color: var(--pdf-text);
|
||||
box-shadow: inset 0 -1px 0 var(--pdf-border-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-grid span {
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -365,8 +364,8 @@ button.secondary {
|
||||
}
|
||||
|
||||
.help-concepts dl > div {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: var(--pdf-surface);
|
||||
border: 1px solid var(--pdf-border);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
@@ -379,7 +378,7 @@ button.secondary {
|
||||
|
||||
.help-concepts dd {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -389,8 +388,95 @@ button.secondary {
|
||||
.help-concepts dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
align-items: flex-start;
|
||||
.merge-queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.merge-queue-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--pdf-info-border);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--pdf-surface) 86%, transparent);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.merge-queue-order {
|
||||
border-radius: 999px;
|
||||
background: var(--pdf-info-surface);
|
||||
color: var(--pdf-info-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.merge-queue-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.merge-queue-details strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.merge-queue-details span {
|
||||
color: var(--pdf-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.merge-queue-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.merge-queue-actions button.secondary {
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.merge-mode-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.merge-mode-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.merge-warning {
|
||||
margin: 0.75rem 0 0;
|
||||
border: 1px solid var(--pdf-warning-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--pdf-warning-surface);
|
||||
color: var(--pdf-warning-text);
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.merge-queue-item {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.merge-queue-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/toolboxApp.definition.json
Normal file
29
src/toolboxApp.definition.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"name": "PDF Workbench",
|
||||
"description": "Page-level PDF operations performed locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "pdf"],
|
||||
"tags": ["merge", "split", "rotate", "reorder"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": true,
|
||||
"crossOriginIsolated": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
}
|
||||
17
src/toolboxApp.test.ts
Normal file
17
src/toolboxApp.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import generatedManifest from '../public/toolbox-app.json';
|
||||
import { toolboxApp } from './toolboxApp';
|
||||
|
||||
describe('toolbox app definition', () => {
|
||||
it('matches the generated public manifest', () => {
|
||||
expect(toolboxApp).toEqual(parseToolboxApp(generatedManifest));
|
||||
});
|
||||
|
||||
it('uses repository metadata for the shared source control', () => {
|
||||
expect(toolboxApp.source?.repository).toBe(
|
||||
'https://git.add-ideas.de/lotobo/pdf-tools'
|
||||
);
|
||||
expect(toolboxApp.actions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
9
src/toolboxApp.ts
Normal file
9
src/toolboxApp.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import definition from './toolboxApp.definition.json';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
export const toolboxApp = parseToolboxApp({
|
||||
schemaVersion: 1,
|
||||
...definition,
|
||||
version: APP_VERSION,
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.2.0';
|
||||
export const APP_VERSION = '0.4.4';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -81,4 +81,4 @@ export function toWorkspaceCommandRecord(
|
||||
details: command.payload.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function deleteWorkspaceFromIndexedDb(
|
||||
const remainingWorkspaces = await listWorkspaces();
|
||||
|
||||
const pdfStillUsed = remainingWorkspaces.some(
|
||||
(summary) => summary.pdfId === workspace.pdfId
|
||||
(summary) => summary.pdfId === workspace.pdfId
|
||||
);
|
||||
|
||||
if (!pdfStillUsed) {
|
||||
@@ -207,4 +207,4 @@ export async function deleteWorkspaceFromIndexedDb(
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -37,4 +37,4 @@ export interface WorkspaceSummary {
|
||||
export interface LoadedWorkspace {
|
||||
workspace: StoredWorkspace;
|
||||
pdfArrayBuffer: ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
13
tests/layoutStyles.test.ts
Normal file
13
tests/layoutStyles.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Toolbox layout styles', () => {
|
||||
it('keeps the PDF workspace aligned to the 90rem shell width', async () => {
|
||||
const stylesheet = await readFile('src/styles.css', 'utf8');
|
||||
const mainRule = stylesheet.match(
|
||||
/\.pdf-toolbox-shell \.toolbox-shell__main\s*\{(?<body>[^}]*)\}/u
|
||||
);
|
||||
|
||||
expect(mainRule?.groups?.body).toMatch(/\bmax-width:\s*90rem\s*;/u);
|
||||
});
|
||||
});
|
||||
96
tests/releaseArchive.test.ts
Normal file
96
tests/releaseArchive.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareCodePoints,
|
||||
collectDirectoryFiles,
|
||||
createThirdPartyLicenseBundle,
|
||||
} from '../scripts/release-archive.mjs';
|
||||
|
||||
describe('release archive helpers', () => {
|
||||
it('sorts archive paths by code point', () => {
|
||||
expect(['z', 'a', 'ä'].sort(compareCodePoints)).toEqual(['a', 'z', 'ä']);
|
||||
});
|
||||
|
||||
it('creates identical ZIP bytes in different process timezones', () => {
|
||||
const hashes = ['UTC', 'Europe/Berlin', 'America/New_York'].map(
|
||||
(timezone) => {
|
||||
const script = `
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createDeterministicZip } from './scripts/release-archive.mjs';
|
||||
const archive = createDeterministicZip({
|
||||
'z.txt': Buffer.from('last'),
|
||||
'a.txt': Buffer.from('first'),
|
||||
});
|
||||
process.stdout.write(createHash('sha256').update(archive).digest('hex'));
|
||||
`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '--eval', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TZ: timezone },
|
||||
}
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return result.stdout;
|
||||
}
|
||||
);
|
||||
expect(new Set(hashes)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collects installed runtime license and notice texts', async () => {
|
||||
const bundle = await createThirdPartyLicenseBundle(process.cwd(), [
|
||||
'react',
|
||||
'pdfjs-dist',
|
||||
'@add-ideas/toolbox-contract',
|
||||
]);
|
||||
const text = bundle.toString('utf8');
|
||||
expect(text).toContain('react@');
|
||||
expect(text).toContain('pdfjs-dist@');
|
||||
expect(text).toContain('@add-ideas/toolbox-contract@');
|
||||
expect(createHash('sha256').update(bundle).digest('hex')).toMatch(
|
||||
/^[a-f0-9]{64}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects symbolic links in release input', async () => {
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'pdf-tools-release-')
|
||||
);
|
||||
try {
|
||||
const outsideFile = path.join(temporaryDirectory, 'outside.txt');
|
||||
const inputDirectory = path.join(temporaryDirectory, 'input');
|
||||
await writeFile(outsideFile, 'private');
|
||||
await mkdir(inputDirectory);
|
||||
await symlink(outsideFile, path.join(inputDirectory, 'linked.txt'));
|
||||
|
||||
await expect(collectDirectoryFiles(inputDirectory)).rejects.toThrow(
|
||||
'Unsupported filesystem entry in release input: linked.txt'
|
||||
);
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the embedded core-js license used by the PDF.js legacy build', async () => {
|
||||
const license = await readFile(
|
||||
'licenses/core-js-3.49.0-LICENSE.txt',
|
||||
'utf8'
|
||||
);
|
||||
expect(license).toContain('core-js 3.49.0');
|
||||
expect(license).toContain('CoreJS Company');
|
||||
expect(license).toContain('Permission is hereby granted');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
||||
allowedHosts: ['pdftools.add-ideas.de'],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user