Compare commits
7 Commits
c27efaec90
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| c639864319 | |||
| 969ede47dd | |||
| 3a0a90bd4a | |||
| 8929080dc7 | |||
| 618a8fc86e | |||
| 07ef17fcaa | |||
| 4b0046a943 |
287
.gitignore
vendored
287
.gitignore
vendored
@@ -1,143 +1,144 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
lib-cov
|
lib-cov
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
# nyc test coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
.grunt
|
.grunt
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
# Bower dependency directory (https://bower.io/)
|
||||||
bower_components
|
bower_components
|
||||||
|
|
||||||
# node-waf configuration
|
# node-waf configuration
|
||||||
.lock-wscript
|
.lock-wscript
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
web_modules/
|
web_modules/
|
||||||
|
|
||||||
# TypeScript cache
|
# TypeScript cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Optional npm cache directory
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# Optional stylelint cache
|
# Optional stylelint cache
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
# Output of 'npm pack'
|
# Output of 'npm pack'
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
# Yarn Integrity file
|
# Yarn Integrity file
|
||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
.parcel-cache
|
.parcel-cache
|
||||||
|
|
||||||
# Next.js build output
|
# Next.js build output
|
||||||
.next
|
.next
|
||||||
out
|
out
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
.output
|
.output
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
# 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
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
# public
|
# public
|
||||||
|
|
||||||
# vuepress build output
|
# vuepress build output
|
||||||
.vuepress/dist
|
.vuepress/dist
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
# vuepress v2.x temp and cache directory
|
||||||
.temp
|
.temp
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
# Sveltekit cache directory
|
# Sveltekit cache directory
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
|
||||||
# vitepress build output
|
# vitepress build output
|
||||||
**/.vitepress/dist
|
**/.vitepress/dist
|
||||||
|
|
||||||
# vitepress cache directory
|
# vitepress cache directory
|
||||||
**/.vitepress/cache
|
**/.vitepress/cache
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
# Docusaurus cache and generated files
|
||||||
.docusaurus
|
.docusaurus
|
||||||
|
|
||||||
# Serverless directories
|
# Serverless directories
|
||||||
.serverless/
|
.serverless/
|
||||||
|
|
||||||
# FuseBox cache
|
# FuseBox cache
|
||||||
.fusebox/
|
.fusebox/
|
||||||
|
|
||||||
# DynamoDB Local files
|
# DynamoDB Local files
|
||||||
.dynamodb/
|
.dynamodb/
|
||||||
|
|
||||||
# Firebase cache directory
|
# Firebase cache directory
|
||||||
.firebase/
|
.firebase/
|
||||||
|
|
||||||
# TernJS port file
|
# TernJS port file
|
||||||
.tern-port
|
.tern-port
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
.vscode-test
|
.vscode-test
|
||||||
|
|
||||||
# yarn v3
|
# yarn v3
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
# Vite files
|
# Vite files
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
todo.txt
|
todo.txt
|
||||||
|
chatgpt_continuation.md5
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -4,6 +4,35 @@ All notable changes to `pdf-tools` are documented here.
|
|||||||
|
|
||||||
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
|
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
|
||||||
|
|
||||||
|
## 0.3.2 — Multi-file merge queue release
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a multi-file merge queue for selecting, loading, reviewing, reordering, removing, and merging several incoming PDFs.
|
||||||
|
- Added queue merge modes for replacing the current document, appending after the current workspace, or inserting at a chosen one-based page position.
|
||||||
|
- Added merge queue helper tests for queue ordering, readiness checks, insert-position clamping, and merged filename generation.
|
||||||
|
- Added PDF merge service tests for queue-only and base-plus-incoming merge results.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the file picker to accept multiple PDFs. A single file with no active workspace still opens directly; otherwise selected files are added to the merge queue.
|
||||||
|
- Replaced the old single-file merge card with a queue-based merge panel.
|
||||||
|
- Merging now creates a new unsaved workspace from the materialized merge result, preserving the current workspace state before append/insert merges.
|
||||||
|
- Bumped the app/package version to `0.3.2`.
|
||||||
|
|
||||||
|
## 0.3.1 — Split ZIP export release
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added browser-side ZIP packaging for split results via `src/pdf/pdfZipService.ts`.
|
||||||
|
- Added a “Download all as ZIP” link next to the individual single-page split downloads.
|
||||||
|
- Added tests for split-result ZIP creation, ZIP entry name sanitization/deduplication, ZIP filename generation, and empty-result handling.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended generated-output handling so split downloads can include both individual page files and one ZIP archive, with object URL cleanup handled by `usePdfGeneratedOutputs`.
|
||||||
|
- Bumped the app/package version to `0.3.1`.
|
||||||
|
|
||||||
## 0.3.0 — Selection workspace and maintenance release
|
## 0.3.0 — Selection workspace and maintenance release
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Current hosted version: <https://pdftools.add-ideas.de>
|
Current hosted version: <https://pdftools.add-ideas.de>
|
||||||
|
|
||||||
Current release: **v0.3.0 — Selection workspace and maintenance release**. See [`CHANGELOG.md`](CHANGELOG.md) for release notes and milestone history.
|
Current release: **v0.3.1 — Split ZIP export release**. See [`CHANGELOG.md`](CHANGELOG.md) for release notes and milestone history.
|
||||||
|
|
||||||
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
|
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Many everyday PDF tasks are not full document-authoring tasks. They are page-wor
|
|||||||
|
|
||||||
- remove pages before sending a document;
|
- remove pages before sending a document;
|
||||||
- rotate scanned pages;
|
- rotate scanned pages;
|
||||||
- split a PDF into single-page files;
|
- split a PDF into single-page files and download them individually or as one ZIP archive;
|
||||||
- merge another PDF into the current document;
|
- merge another PDF into the current document;
|
||||||
- extract a subset of pages;
|
- extract a subset of pages;
|
||||||
- reorder pages visually;
|
- reorder pages visually;
|
||||||
@@ -72,6 +72,7 @@ This makes the project especially useful for self-hosted environments, public-se
|
|||||||
- Extract selected pages into a new PDF.
|
- Extract selected pages into a new PDF.
|
||||||
- Open selected pages as a new active workspace for continued editing.
|
- Open selected pages as a new active workspace for continued editing.
|
||||||
- Split the source PDF into single-page PDFs.
|
- Split the source PDF into single-page PDFs.
|
||||||
|
- Download all split results as one ZIP archive.
|
||||||
- Merge another PDF by replacing, appending, or inserting it into the current workspace.
|
- Merge another PDF by replacing, appending, or inserting it into the current workspace.
|
||||||
|
|
||||||
### Keyboard shortcuts
|
### Keyboard shortcuts
|
||||||
@@ -227,10 +228,10 @@ The application version shown in the header is defined in `src/version.ts`. The
|
|||||||
The current development baseline is:
|
The current development baseline is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v0.3.0 — Selection workspace and maintenance release
|
v0.3.1 — Split ZIP export release
|
||||||
```
|
```
|
||||||
|
|
||||||
This release preserves the browser-only workspace baseline and adds the first post-refactor feature: opening selected pages as a new active workspace. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are separated enough to support additional feature work without turning `App.tsx` back into a monolith.
|
This release preserves the browser-only workspace baseline and adds split-result ZIP downloads on top of the selection-workspace feature. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are separated enough to support additional feature work without turning `App.tsx` back into a monolith.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
@@ -239,7 +240,7 @@ src/
|
|||||||
App.tsx Main application orchestration and UI wiring
|
App.tsx Main application orchestration and UI wiring
|
||||||
components/
|
components/
|
||||||
ActionDialog.tsx Reusable confirmation/action dialog
|
ActionDialog.tsx Reusable confirmation/action dialog
|
||||||
ActionsPanel.tsx Export, extract, and split actions
|
ActionsPanel.tsx Export, extract, split, and ZIP download actions
|
||||||
FileLoader.tsx PDF file loading
|
FileLoader.tsx PDF file loading
|
||||||
HelpDialog.tsx In-app tutorial and shortcut reference
|
HelpDialog.tsx In-app tutorial and shortcut reference
|
||||||
Layout.tsx Application shell/header
|
Layout.tsx Application shell/header
|
||||||
@@ -249,6 +250,7 @@ src/
|
|||||||
pdf/
|
pdf/
|
||||||
pdfService.ts pdf-lib operations: load, merge, split, export
|
pdfService.ts pdf-lib operations: load, merge, split, export
|
||||||
pdfThumbnailService.ts pdf.js thumbnail rendering
|
pdfThumbnailService.ts pdf.js thumbnail rendering
|
||||||
|
pdfZipService.ts Browser-side ZIP packaging for split results
|
||||||
pdfTypes.ts PDF-related types
|
pdfTypes.ts PDF-related types
|
||||||
workspace/
|
workspace/
|
||||||
workspaceCommands.ts Command model for undo/redo
|
workspaceCommands.ts Command model for undo/redo
|
||||||
@@ -306,7 +308,7 @@ src/
|
|||||||
### Milestone 5: Export and power tools
|
### Milestone 5: Export and power tools
|
||||||
|
|
||||||
- [ ] Basic text extraction.
|
- [ ] Basic text extraction.
|
||||||
- [ ] ZIP export for split results.
|
- [x] ZIP export for split results.
|
||||||
- [ ] Optimize/compress MVP.
|
- [ ] Optimize/compress MVP.
|
||||||
- [ ] Carefully scoped encrypted PDF handling.
|
- [ ] Carefully scoped encrypted PDF handling.
|
||||||
|
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Self-hosted PDF Workbench</title>
|
<title>Self-hosted PDF Workbench</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PDFTools" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "pdf-tools",
|
"name": "pdf-tools",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pdf-tools",
|
"name": "pdf-tools",
|
||||||
"version": "0.2.2",
|
"version": "0.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -116,7 +117,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -447,7 +447,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -496,7 +495,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -1469,7 +1467,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
@@ -1516,7 +1515,6 @@
|
|||||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
@@ -1527,7 +1525,6 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1538,7 +1535,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -1588,7 +1584,6 @@
|
|||||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.3",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.3",
|
||||||
@@ -1912,7 +1907,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1953,6 +1947,7 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -1963,6 +1958,7 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -1976,6 +1972,7 @@
|
|||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -2056,7 +2053,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -2197,6 +2193,7 @@
|
|||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -2216,7 +2213,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.357",
|
"version": "1.5.357",
|
||||||
@@ -2274,7 +2272,6 @@
|
|||||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -2526,6 +2523,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -2728,7 +2731,6 @@
|
|||||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^5.1.11",
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
@@ -3128,6 +3130,7 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -3349,7 +3352,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3418,6 +3420,7 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -3442,7 +3445,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3452,7 +3454,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -3465,7 +3466,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
@@ -3736,7 +3738,6 @@
|
|||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3833,7 +3834,6 @@
|
|||||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -4157,7 +4157,6 @@
|
|||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pdf-tools",
|
"name": "pdf-tools",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon-96x96.png
Normal file
BIN
public/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="pdftools favicon" width="64" height="64"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><defs>
|
||||||
|
<linearGradient id="pdf-bg" x1="10" y1="4" x2="54" y2="60" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#ff4b3f"></stop>
|
||||||
|
<stop offset="1" stop-color="#c91424"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="1.25" flood-color="#7a0b12" flood-opacity="0.28"></feDropShadow>
|
||||||
|
</filter>
|
||||||
|
</defs><rect width="64" height="64" rx="14" fill="url(#pdf-bg)"></rect><g filter="url(#soft-shadow)">
|
||||||
|
<path d="M19 11h22l9 9v31a4 4 0 0 1-4 4H19a4 4 0 0 1-4-4V15a4 4 0 0 1 4-4z" fill="#fff"></path>
|
||||||
|
<path d="M41 11v9h9z" fill="#ffd9d6"></path>
|
||||||
|
<path d="M23 24h19" stroke="#d41627" stroke-width="4" stroke-linecap="round"></path>
|
||||||
|
<path d="M23 34h14" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.82"></path>
|
||||||
|
<path d="M23 44h19" stroke="#d41627" stroke-width="4" stroke-linecap="round" opacity="0.64"></path>
|
||||||
|
</g><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
21
public/site.webmanifest
Normal file
21
public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "PDFTools",
|
||||||
|
"short_name": "PDFTools",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
public/web-app-manifest-192x192.png
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
BIN
public/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
285
src/App.tsx
285
src/App.tsx
@@ -9,6 +9,7 @@ import ActionDialog, {
|
|||||||
type ActionDialogAction,
|
type ActionDialogAction,
|
||||||
} from './components/ActionDialog';
|
} from './components/ActionDialog';
|
||||||
import HelpDialog from './components/HelpDialog';
|
import HelpDialog from './components/HelpDialog';
|
||||||
|
import MergeQueuePanel from './components/MergeQueuePanel';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import type {
|
import type {
|
||||||
StoredWorkspace,
|
StoredWorkspace,
|
||||||
@@ -32,17 +33,28 @@ import {
|
|||||||
import type { PageRef, PdfFile } from './pdf/pdfTypes';
|
import type { PageRef, PdfFile } from './pdf/pdfTypes';
|
||||||
import {
|
import {
|
||||||
loadPdfFromFile,
|
loadPdfFromFile,
|
||||||
mergePdfFiles,
|
mergePdfFilesAtPosition,
|
||||||
splitIntoSinglePages,
|
splitIntoSinglePages,
|
||||||
exportPages,
|
exportPages,
|
||||||
} from './pdf/pdfService';
|
} from './pdf/pdfService';
|
||||||
import { usePdfThumbnails } from './pdf/usePdfThumbnails';
|
import { usePdfThumbnails } from './pdf/usePdfThumbnails';
|
||||||
import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
|
import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
|
||||||
|
import {
|
||||||
|
createSplitResultsZip,
|
||||||
|
createSplitZipFilename,
|
||||||
|
} from './pdf/pdfZipService';
|
||||||
import {
|
import {
|
||||||
createSelectionPdfName,
|
createSelectionPdfName,
|
||||||
createSelectionWorkspaceName,
|
createSelectionWorkspaceName,
|
||||||
getSelectedPagesInVisualOrder,
|
getSelectedPagesInVisualOrder,
|
||||||
} from './workspace/workspaceSelection';
|
} from './workspace/workspaceSelection';
|
||||||
|
import { useMergeQueue } from './merge/useMergeQueue';
|
||||||
|
import {
|
||||||
|
clampMergeInsertAt,
|
||||||
|
createMergedPdfName,
|
||||||
|
defaultMergeInsertPosition,
|
||||||
|
} from './merge/mergeQueueHelpers';
|
||||||
|
import type { MergeMode } from './merge/mergeTypes';
|
||||||
|
|
||||||
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
@@ -76,15 +88,26 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const [previewPageId, setPreviewPageId] = useState<string | null>(null);
|
const [previewPageId, setPreviewPageId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [mergeQueueOpen, setMergeQueueOpen] = useState(false);
|
||||||
const [showMergeOptions, setShowMergeOptions] = useState(false);
|
const [mergeMode, setMergeMode] = useState<MergeMode>('append');
|
||||||
const [mergeMode, setMergeMode] = useState<
|
|
||||||
'overwrite' | 'append' | 'insertAt'
|
|
||||||
>('append');
|
|
||||||
const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
|
const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: mergeQueueItems,
|
||||||
|
addFiles: addFilesToMergeQueue,
|
||||||
|
removeItem: removeMergeQueueItem,
|
||||||
|
moveItemUp: moveMergeQueueItemUp,
|
||||||
|
moveItemDown: moveMergeQueueItemDown,
|
||||||
|
clearQueue: clearMergeQueue,
|
||||||
|
readyPdfs: readyMergePdfs,
|
||||||
|
canMerge: canMergeQueue,
|
||||||
|
hasErrors: mergeQueueHasErrors,
|
||||||
|
isLoading: mergeQueueIsLoading,
|
||||||
|
} = useMergeQueue();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
splitDownloads,
|
splitDownloads,
|
||||||
|
splitZipDownload,
|
||||||
subsetDownload,
|
subsetDownload,
|
||||||
exportDownload,
|
exportDownload,
|
||||||
replaceSplitResults,
|
replaceSplitResults,
|
||||||
@@ -417,89 +440,111 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileLoaded = (file: File) => {
|
const queueFilesForMerge = (files: File[]) => {
|
||||||
if (!pdf || pages.length === 0) {
|
if (files.length === 0) return;
|
||||||
void loadFileAsNew(file);
|
|
||||||
} else {
|
const queueWasEmpty = mergeQueueItems.length === 0;
|
||||||
setPendingFile(file);
|
|
||||||
setShowMergeOptions(true);
|
addFilesToMergeQueue(files);
|
||||||
setMergeMode('append');
|
setMergeQueueOpen(true);
|
||||||
setMergeInsertAt(String(pages.length + 1));
|
|
||||||
|
if (queueWasEmpty) {
|
||||||
|
if (!pdf || pages.length === 0) {
|
||||||
|
setMergeMode('overwrite');
|
||||||
|
setMergeInsertAt('1');
|
||||||
|
} else {
|
||||||
|
setMergeMode('append');
|
||||||
|
setMergeInsertAt(defaultMergeInsertPosition(pages.length));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFilesLoaded = (files: File[]) => {
|
||||||
|
const pdfFiles = files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.type === 'application/pdf' ||
|
||||||
|
file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pdfFiles.length === 0) return;
|
||||||
|
|
||||||
|
if (!pdf && pages.length === 0 && pdfFiles.length === 1) {
|
||||||
|
void loadFileAsNew(pdfFiles[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueFilesForMerge(pdfFiles);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMergeCancel = () => {
|
const handleMergeCancel = () => {
|
||||||
setPendingFile(null);
|
clearMergeQueue();
|
||||||
setShowMergeOptions(false);
|
setMergeQueueOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMergeConfirm = async () => {
|
const handleMergeConfirm = async () => {
|
||||||
if (!pendingFile) return;
|
if (!canMergeQueue || readyMergePdfs.length === 0) return;
|
||||||
|
|
||||||
if (!pdf || mergeMode === 'overwrite') {
|
|
||||||
await loadFileAsNew(pendingFile);
|
|
||||||
setPendingFile(null);
|
|
||||||
setShowMergeOptions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) Materialize the current in-memory workspace (page refs + rotations)
|
let basePdf: PdfFile | null = null;
|
||||||
const currentBlob = await exportPages(pdf, pages);
|
let insertAt = 0;
|
||||||
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
|
||||||
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
|
||||||
const currentPdf: PdfFile = {
|
|
||||||
id: pdf.id,
|
|
||||||
name: pdf.name,
|
|
||||||
doc: currentDoc,
|
|
||||||
arrayBuffer: currentArrayBuffer,
|
|
||||||
pageCount: pages.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2) Load the new PDF
|
if (pdf && pages.length > 0 && mergeMode !== 'overwrite') {
|
||||||
const newPdf = await loadPdfFromFile(pendingFile);
|
const currentBlob = await exportPages(pdf, pages);
|
||||||
|
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
||||||
|
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
||||||
|
|
||||||
// 3) Determine insert position (0-based)
|
basePdf = {
|
||||||
let insertAt = pages.length; // default: append at end
|
id: pdf.id,
|
||||||
if (mergeMode === 'insertAt') {
|
name: pdf.name,
|
||||||
const parsed = parseInt(mergeInsertAt, 10);
|
doc: currentDoc,
|
||||||
if (Number.isFinite(parsed)) {
|
arrayBuffer: currentArrayBuffer,
|
||||||
insertAt = Math.min(Math.max(parsed - 1, 0), pages.length);
|
pageCount: pages.length,
|
||||||
}
|
};
|
||||||
} else if (mergeMode === 'append') {
|
|
||||||
insertAt = pages.length;
|
insertAt =
|
||||||
|
mergeMode === 'insertAt'
|
||||||
|
? clampMergeInsertAt(mergeInsertAt, pages.length)
|
||||||
|
: pages.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Merge
|
const mergedPdf = await mergePdfFilesAtPosition({
|
||||||
const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt);
|
basePdf,
|
||||||
const mergedPages = createInitialPageRefs(mergedPdf.pageCount);
|
incomingPdfs: readyMergePdfs,
|
||||||
|
insertAt,
|
||||||
|
name: createMergedPdfName(
|
||||||
|
pdf?.name ?? null,
|
||||||
|
readyMergePdfs.map((item) => item.name),
|
||||||
|
mergeMode
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
// 5) Reset state to the merged document
|
|
||||||
setPdf(mergedPdf);
|
setPdf(mergedPdf);
|
||||||
replaceWorkspaceState({
|
replaceWorkspaceState({
|
||||||
pages: mergedPages,
|
pages: createInitialPageRefs(mergedPdf.pageCount),
|
||||||
selectedPageIds: [],
|
selectedPageIds: [],
|
||||||
lastSelectedVisualIndex: null,
|
lastSelectedVisualIndex: null,
|
||||||
history: [],
|
history: [],
|
||||||
redoHistory: [],
|
redoHistory: [],
|
||||||
dirty: true,
|
dirty: true,
|
||||||
message: null,
|
message: `Merged ${readyMergePdfs.length} queued ${
|
||||||
|
readyMergePdfs.length === 1 ? 'PDF' : 'PDFs'
|
||||||
|
} into a new unsaved workspace.`,
|
||||||
});
|
});
|
||||||
clearGeneratedOutputs();
|
clearGeneratedOutputs();
|
||||||
clearThumbnailCache();
|
clearThumbnailCache();
|
||||||
setPreviewPageId(null);
|
setPreviewPageId(null);
|
||||||
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
|
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
|
||||||
setActiveWorkspaceId(null);
|
setActiveWorkspaceId(null);
|
||||||
|
clearMergeQueue();
|
||||||
|
setMergeQueueOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Failed to merge PDF (see console).');
|
setError('Failed to merge PDF queue (see console).');
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
setPendingFile(null);
|
|
||||||
setShowMergeOptions(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -916,7 +961,11 @@ const App: React.FC = () => {
|
|||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const result = await splitIntoSinglePages(pdf);
|
const result = await splitIntoSinglePages(pdf);
|
||||||
replaceSplitResults(result);
|
const zipBlob = await createSplitResultsZip(result);
|
||||||
|
replaceSplitResults(result, {
|
||||||
|
blob: zipBlob,
|
||||||
|
filename: createSplitZipFilename(pdf.name),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Error while splitting PDF (see console).');
|
setError('Error while splitting PDF (see console).');
|
||||||
@@ -1091,7 +1140,29 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
||||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
<FileLoader pdf={pdf} onFilesLoaded={handleFilesLoaded} />
|
||||||
|
|
||||||
|
{mergeQueueOpen && mergeQueueItems.length > 0 && (
|
||||||
|
<MergeQueuePanel
|
||||||
|
items={mergeQueueItems}
|
||||||
|
hasCurrentPdf={hasPdf && pages.length > 0}
|
||||||
|
currentPdfName={pdf?.name ?? null}
|
||||||
|
currentPageCount={pages.length}
|
||||||
|
mergeMode={mergeMode}
|
||||||
|
mergeInsertAt={mergeInsertAt}
|
||||||
|
isBusy={isBusy}
|
||||||
|
canMerge={canMergeQueue}
|
||||||
|
isLoading={mergeQueueIsLoading}
|
||||||
|
hasErrors={mergeQueueHasErrors}
|
||||||
|
onMergeModeChange={setMergeMode}
|
||||||
|
onMergeInsertAtChange={setMergeInsertAt}
|
||||||
|
onMoveUp={moveMergeQueueItemUp}
|
||||||
|
onMoveDown={moveMergeQueueItemDown}
|
||||||
|
onRemove={removeMergeQueueItem}
|
||||||
|
onCancel={handleMergeCancel}
|
||||||
|
onConfirm={handleMergeConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<WorkspacePanel
|
<WorkspacePanel
|
||||||
hasPdf={hasPdf}
|
hasPdf={hasPdf}
|
||||||
@@ -1116,105 +1187,6 @@ const App: React.FC = () => {
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMergeOptions && pendingFile && pdf && pages.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="card"
|
|
||||||
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
|
|
||||||
>
|
|
||||||
<h2>Open file: merge or replace?</h2>
|
|
||||||
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
|
||||||
You already have <strong>{pdf.name}</strong> with {pages.length}{' '}
|
|
||||||
pages open. What should happen with{' '}
|
|
||||||
<strong>{pendingFile.name}</strong>?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.5rem',
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mergeMode"
|
|
||||||
value="overwrite"
|
|
||||||
checked={mergeMode === 'overwrite'}
|
|
||||||
onChange={() => setMergeMode('overwrite')}
|
|
||||||
/>
|
|
||||||
<span>Replace current document</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mergeMode"
|
|
||||||
value="append"
|
|
||||||
checked={mergeMode === 'append'}
|
|
||||||
onChange={() => setMergeMode('append')}
|
|
||||||
/>
|
|
||||||
<span>Merge and append pages at the end</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="mergeMode"
|
|
||||||
value="insertAt"
|
|
||||||
checked={mergeMode === 'insertAt'}
|
|
||||||
onChange={() => setMergeMode('insertAt')}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Merge and insert starting at position{' '}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={pages.length + 1}
|
|
||||||
value={mergeInsertAt}
|
|
||||||
onChange={(e) => setMergeInsertAt(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '4rem',
|
|
||||||
padding: '0.15rem 0.3rem',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
/>{' '}
|
|
||||||
<span style={{ color: '#6b7280' }}>
|
|
||||||
(1 = before first page, {pages.length + 1} = after last page)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-row" style={{ marginTop: '0.75rem' }}>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={handleMergeCancel}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="primary"
|
|
||||||
type="button"
|
|
||||||
onClick={handleMergeConfirm}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
{isBusy ? 'Working…' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ReorderPanel
|
<ReorderPanel
|
||||||
pages={pages}
|
pages={pages}
|
||||||
thumbnails={reorderThumbnails}
|
thumbnails={reorderThumbnails}
|
||||||
@@ -1242,6 +1214,7 @@ const App: React.FC = () => {
|
|||||||
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
|
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
|
||||||
onExportReordered={handleExportReordered}
|
onExportReordered={handleExportReordered}
|
||||||
splitDownloads={splitDownloads}
|
splitDownloads={splitDownloads}
|
||||||
|
splitZipDownload={splitZipDownload}
|
||||||
subsetDownload={subsetDownload}
|
subsetDownload={subsetDownload}
|
||||||
exportDownload={exportDownload}
|
exportDownload={exportDownload}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface ActionsPanelProps {
|
|||||||
onExportReordered: () => void;
|
onExportReordered: () => void;
|
||||||
|
|
||||||
splitDownloads: SplitPdfDownload[];
|
splitDownloads: SplitPdfDownload[];
|
||||||
|
splitZipDownload: PdfDownload | null;
|
||||||
subsetDownload: PdfDownload | null;
|
subsetDownload: PdfDownload | null;
|
||||||
exportDownload: PdfDownload | null;
|
exportDownload: PdfDownload | null;
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
onOpenSelectionAsWorkspace,
|
onOpenSelectionAsWorkspace,
|
||||||
onExportReordered,
|
onExportReordered,
|
||||||
splitDownloads,
|
splitDownloads,
|
||||||
|
splitZipDownload,
|
||||||
subsetDownload,
|
subsetDownload,
|
||||||
exportDownload,
|
exportDownload,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -132,7 +134,18 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
{splitDownloads.length > 0 && (
|
{splitDownloads.length > 0 && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
<strong>Single-page PDFs:</strong>
|
<strong>Single-page PDFs:</strong>
|
||||||
<div>
|
{splitZipDownload && (
|
||||||
|
<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) => (
|
{splitDownloads.map((download) => (
|
||||||
<a
|
<a
|
||||||
key={download.id}
|
key={download.id}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { PdfFile } from '../pdf/pdfTypes';
|
|||||||
|
|
||||||
interface FileLoaderProps {
|
interface FileLoaderProps {
|
||||||
pdf: PdfFile | null;
|
pdf: PdfFile | null;
|
||||||
onFileLoaded: (file: File) => void;
|
onFilesLoaded: (files: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFilesLoaded }) => {
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const files = Array.from(e.target.files ?? []);
|
||||||
if (file) {
|
if (files.length > 0) {
|
||||||
onFileLoaded(file);
|
onFilesLoaded(files);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -18,8 +18,16 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>1. Load PDF</h2>
|
<h2>1. Load PDF</h2>
|
||||||
<p>Select a PDF file. Processing happens entirely in your browser.</p>
|
<p>
|
||||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
Select one PDF to open it directly, or select several PDFs to place them
|
||||||
|
in the merge queue. Processing happens entirely in your browser.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{pdf && (
|
{pdf && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const shortcuts = [
|
|||||||
const tutorialSteps = [
|
const tutorialSteps = [
|
||||||
{
|
{
|
||||||
title: '1. Open a PDF or load a workspace',
|
title: '1. Open a PDF or load a workspace',
|
||||||
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.',
|
body: 'Start by selecting a local PDF file. Select several PDFs to open the merge queue, or restore a saved workspace from browser storage.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '2. Arrange pages visually',
|
title: '2. Arrange pages visually',
|
||||||
@@ -49,11 +49,15 @@ const tutorialSteps = [
|
|||||||
body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
|
body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '5. Save your workspace or export a PDF',
|
title: '5. Split and download results',
|
||||||
|
body: 'Splitting creates individual one-page PDF downloads and a ZIP archive that contains all generated page files. For merging, review the incoming PDF queue, reorder it if needed, then replace, append, or insert the queued PDFs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '6. Save your workspace or export a PDF',
|
||||||
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '6. Use history deliberately',
|
title: '7. Use history deliberately',
|
||||||
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -98,7 +102,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||||
<p>
|
<p>
|
||||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||||
rearrange, split, merge, rotate, duplicate, and export PDFs
|
rearrange, split, queue-merge, rotate, duplicate, and export PDFs
|
||||||
without uploading documents to a server.
|
without uploading documents to a server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
232
src/components/MergeQueuePanel.tsx
Normal file
232
src/components/MergeQueuePanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { MergeQueueItem, MergeMode } from '../merge/mergeTypes';
|
||||||
|
|
||||||
|
interface MergeQueuePanelProps {
|
||||||
|
items: MergeQueueItem[];
|
||||||
|
hasCurrentPdf: boolean;
|
||||||
|
currentPdfName: string | null;
|
||||||
|
currentPageCount: number;
|
||||||
|
mergeMode: MergeMode;
|
||||||
|
mergeInsertAt: string;
|
||||||
|
isBusy: boolean;
|
||||||
|
canMerge: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasErrors: boolean;
|
||||||
|
onMergeModeChange: (mode: MergeMode) => void;
|
||||||
|
onMergeInsertAtChange: (value: string) => void;
|
||||||
|
onMoveUp: (itemId: string) => void;
|
||||||
|
onMoveDown: (itemId: string) => void;
|
||||||
|
onRemove: (itemId: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MergeQueuePanel: React.FC<MergeQueuePanelProps> = ({
|
||||||
|
items,
|
||||||
|
hasCurrentPdf,
|
||||||
|
currentPdfName,
|
||||||
|
currentPageCount,
|
||||||
|
mergeMode,
|
||||||
|
mergeInsertAt,
|
||||||
|
isBusy,
|
||||||
|
canMerge,
|
||||||
|
isLoading,
|
||||||
|
hasErrors,
|
||||||
|
onMergeModeChange,
|
||||||
|
onMergeInsertAtChange,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onRemove,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const queuedPageCount = items.reduce(
|
||||||
|
(sum, item) => sum + (item.pageCount ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card merge-queue-card"
|
||||||
|
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
|
||||||
|
>
|
||||||
|
<h2>Merge PDF queue</h2>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
Add several PDFs, reorder the queue, then merge them into a new unsaved
|
||||||
|
workspace. Processing still happens entirely in your browser.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasCurrentPdf ? (
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
Current workspace:{' '}
|
||||||
|
<strong>{currentPdfName ?? 'Untitled document'}</strong> with{' '}
|
||||||
|
{currentPageCount} {currentPageCount === 1 ? 'page' : 'pages'}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
No current workspace is open. The queue will become a new workspace.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="merge-queue-list" aria-label="PDF merge queue">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.id} className="merge-queue-item">
|
||||||
|
<div className="merge-queue-order">#{index + 1}</div>
|
||||||
|
<div className="merge-queue-details">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>
|
||||||
|
{formatFileSize(item.size)} ·{' '}
|
||||||
|
{item.status === 'ready' && item.pageCount != null
|
||||||
|
? `${item.pageCount} ${item.pageCount === 1 ? 'page' : 'pages'}`
|
||||||
|
: item.status === 'loading'
|
||||||
|
? 'Loading…'
|
||||||
|
: (item.error ?? 'Error')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="merge-queue-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onMoveUp(item.id)}
|
||||||
|
disabled={isBusy || index === 0}
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onMoveDown(item.id)}
|
||||||
|
disabled={isBusy || index === items.length - 1}
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => onRemove(item.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
Queue total:{' '}
|
||||||
|
<strong>
|
||||||
|
{items.length} {items.length === 1 ? 'PDF' : 'PDFs'}
|
||||||
|
</strong>
|
||||||
|
{queuedPageCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {queuedPageCount} {queuedPageCount === 1 ? 'page' : 'pages'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="merge-mode-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mergeMode"
|
||||||
|
value="overwrite"
|
||||||
|
checked={mergeMode === 'overwrite'}
|
||||||
|
onChange={() => onMergeModeChange('overwrite')}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{hasCurrentPdf ? 'Replace current document' : 'Create from queue'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mergeMode"
|
||||||
|
value="append"
|
||||||
|
checked={mergeMode === 'append'}
|
||||||
|
onChange={() => onMergeModeChange('append')}
|
||||||
|
disabled={isBusy || !hasCurrentPdf}
|
||||||
|
/>
|
||||||
|
<span>Append queue after current workspace</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mergeMode"
|
||||||
|
value="insertAt"
|
||||||
|
checked={mergeMode === 'insertAt'}
|
||||||
|
onChange={() => onMergeModeChange('insertAt')}
|
||||||
|
disabled={isBusy || !hasCurrentPdf}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Insert queue starting at position{' '}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={currentPageCount + 1}
|
||||||
|
value={mergeInsertAt}
|
||||||
|
onChange={(e) => onMergeInsertAtChange(e.target.value)}
|
||||||
|
disabled={isBusy || !hasCurrentPdf || mergeMode !== 'insertAt'}
|
||||||
|
style={{
|
||||||
|
width: '4rem',
|
||||||
|
padding: '0.15rem 0.3rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<span style={{ color: '#6b7280' }}>
|
||||||
|
(1 = before first page, {currentPageCount + 1} = after last page)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mergeMode === 'overwrite' && hasCurrentPdf && (
|
||||||
|
<p className="merge-warning">
|
||||||
|
Replace mode discards the current in-memory workspace after the merge.
|
||||||
|
Save it first if you want to keep the current state separately.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasErrors && (
|
||||||
|
<p className="merge-warning">
|
||||||
|
One or more queued files could not be loaded. Remove failed items
|
||||||
|
before merging.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="button-row" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isBusy || isLoading || !canMerge || hasErrors}
|
||||||
|
>
|
||||||
|
{isBusy ? 'Working…' : isLoading ? 'Loading PDFs…' : 'Merge queue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MergeQueuePanel;
|
||||||
@@ -11,6 +11,11 @@ export interface SplitPdfDownload extends PdfDownload {
|
|||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfBlobResult {
|
||||||
|
blob: Blob;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
function revokeDownload(download: PdfDownload | null): void {
|
function revokeDownload(download: PdfDownload | null): void {
|
||||||
if (download) {
|
if (download) {
|
||||||
URL.revokeObjectURL(download.url);
|
URL.revokeObjectURL(download.url);
|
||||||
@@ -37,30 +42,51 @@ export function usePdfGeneratedOutputs() {
|
|||||||
const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
|
const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [splitZipDownload, setSplitZipDownload] = useState<PdfDownload | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
|
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
|
||||||
const subsetDownloadRef = useRef<PdfDownload | null>(null);
|
const subsetDownloadRef = useRef<PdfDownload | null>(null);
|
||||||
const exportDownloadRef = useRef<PdfDownload | null>(null);
|
const exportDownloadRef = useRef<PdfDownload | null>(null);
|
||||||
|
const splitZipDownloadRef = useRef<PdfDownload | null>(null);
|
||||||
|
|
||||||
const replaceSplitResults = useCallback((results: SplitResult[]) => {
|
const replaceSplitResults = useCallback(
|
||||||
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
|
(results: SplitResult[], zipResult?: PdfBlobResult) => {
|
||||||
...createDownload(
|
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
|
||||||
`split-${result.pageIndex}-${result.filename}`,
|
...createDownload(
|
||||||
result.filename,
|
`split-${result.pageIndex}-${result.filename}`,
|
||||||
result.blob
|
result.filename,
|
||||||
),
|
result.blob
|
||||||
pageIndex: result.pageIndex,
|
),
|
||||||
}));
|
pageIndex: result.pageIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
revokeDownloads(splitDownloadsRef.current);
|
const nextZipDownload = zipResult
|
||||||
splitDownloadsRef.current = nextDownloads;
|
? createDownload('split-zip', zipResult.filename, zipResult.blob)
|
||||||
setSplitDownloads(nextDownloads);
|
: null;
|
||||||
}, []);
|
|
||||||
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
revokeDownload(splitZipDownloadRef.current);
|
||||||
|
|
||||||
|
splitDownloadsRef.current = nextDownloads;
|
||||||
|
splitZipDownloadRef.current = nextZipDownload;
|
||||||
|
|
||||||
|
setSplitDownloads(nextDownloads);
|
||||||
|
setSplitZipDownload(nextZipDownload);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const clearSplitResults = useCallback(() => {
|
const clearSplitResults = useCallback(() => {
|
||||||
revokeDownloads(splitDownloadsRef.current);
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
revokeDownload(splitZipDownloadRef.current);
|
||||||
|
|
||||||
splitDownloadsRef.current = [];
|
splitDownloadsRef.current = [];
|
||||||
|
splitZipDownloadRef.current = null;
|
||||||
|
|
||||||
setSplitDownloads([]);
|
setSplitDownloads([]);
|
||||||
|
setSplitZipDownload(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
|
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
|
||||||
@@ -95,14 +121,17 @@ export function usePdfGeneratedOutputs() {
|
|||||||
revokeDownloads(splitDownloadsRef.current);
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
revokeDownload(subsetDownloadRef.current);
|
revokeDownload(subsetDownloadRef.current);
|
||||||
revokeDownload(exportDownloadRef.current);
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
revokeDownload(splitZipDownloadRef.current);
|
||||||
|
|
||||||
splitDownloadsRef.current = [];
|
splitDownloadsRef.current = [];
|
||||||
subsetDownloadRef.current = null;
|
subsetDownloadRef.current = null;
|
||||||
exportDownloadRef.current = null;
|
exportDownloadRef.current = null;
|
||||||
|
splitZipDownloadRef.current = null;
|
||||||
|
|
||||||
setSplitDownloads([]);
|
setSplitDownloads([]);
|
||||||
setSubsetDownload(null);
|
setSubsetDownload(null);
|
||||||
setExportDownload(null);
|
setExportDownload(null);
|
||||||
|
setSplitZipDownload(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +139,7 @@ export function usePdfGeneratedOutputs() {
|
|||||||
revokeDownloads(splitDownloadsRef.current);
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
revokeDownload(subsetDownloadRef.current);
|
revokeDownload(subsetDownloadRef.current);
|
||||||
revokeDownload(exportDownloadRef.current);
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
revokeDownload(splitZipDownloadRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -117,6 +147,7 @@ export function usePdfGeneratedOutputs() {
|
|||||||
splitDownloads,
|
splitDownloads,
|
||||||
subsetDownload,
|
subsetDownload,
|
||||||
exportDownload,
|
exportDownload,
|
||||||
|
splitZipDownload,
|
||||||
replaceSplitResults,
|
replaceSplitResults,
|
||||||
clearSplitResults,
|
clearSplitResults,
|
||||||
replaceSubsetResult,
|
replaceSubsetResult,
|
||||||
|
|||||||
82
src/merge/mergeQueueHelpers.test.ts
Normal file
82
src/merge/mergeQueueHelpers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MergeQueueItem } from './mergeTypes';
|
||||||
|
import {
|
||||||
|
canMergeQueue,
|
||||||
|
clampMergeInsertAt,
|
||||||
|
createMergedPdfName,
|
||||||
|
getReadyMergeQueuePdfs,
|
||||||
|
moveMergeQueueItem,
|
||||||
|
} from './mergeQueueHelpers';
|
||||||
|
|
||||||
|
function makeItem(
|
||||||
|
id: string,
|
||||||
|
status: MergeQueueItem['status']
|
||||||
|
): MergeQueueItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
file: new File(['x'], `${id}.pdf`, { type: 'application/pdf' }),
|
||||||
|
name: `${id}.pdf`,
|
||||||
|
size: 1,
|
||||||
|
pageCount: status === 'ready' ? 1 : null,
|
||||||
|
pdf:
|
||||||
|
status === 'ready'
|
||||||
|
? {
|
||||||
|
id: `pdf-${id}`,
|
||||||
|
name: `${id}.pdf`,
|
||||||
|
// The helper tests never dereference the PDFDocument.
|
||||||
|
doc: {} as never,
|
||||||
|
pageCount: 1,
|
||||||
|
arrayBuffer: new ArrayBuffer(0),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('merge queue helpers', () => {
|
||||||
|
it('moves queued items up and down without mutating the original array', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem('a', 'ready'),
|
||||||
|
makeItem('b', 'ready'),
|
||||||
|
makeItem('c', 'ready'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(moveMergeQueueItem(items, 'b', 'up').map((item) => item.id)).toEqual(
|
||||||
|
['b', 'a', 'c']
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
moveMergeQueueItem(items, 'b', 'down').map((item) => item.id)
|
||||||
|
).toEqual(['a', 'c', 'b']);
|
||||||
|
expect(items.map((item) => item.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only allows merging when every queued item is ready', () => {
|
||||||
|
const readyItems = [makeItem('a', 'ready'), makeItem('b', 'ready')];
|
||||||
|
const mixedItems = [makeItem('a', 'ready'), makeItem('b', 'loading')];
|
||||||
|
|
||||||
|
expect(canMergeQueue(readyItems)).toBe(true);
|
||||||
|
expect(getReadyMergeQueuePdfs(readyItems)).toHaveLength(2);
|
||||||
|
expect(canMergeQueue(mixedItems)).toBe(false);
|
||||||
|
expect(canMergeQueue([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps one-based merge positions to zero-based insert slots', () => {
|
||||||
|
expect(clampMergeInsertAt('1', 10)).toBe(0);
|
||||||
|
expect(clampMergeInsertAt('5', 10)).toBe(4);
|
||||||
|
expect(clampMergeInsertAt('99', 10)).toBe(10);
|
||||||
|
expect(clampMergeInsertAt('-10', 10)).toBe(0);
|
||||||
|
expect(clampMergeInsertAt('not-a-number', 10)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates readable merged PDF filenames', () => {
|
||||||
|
expect(createMergedPdfName('base.pdf', ['a.pdf', 'b.pdf'], 'append')).toBe(
|
||||||
|
'base_plus_2_pdfs.pdf'
|
||||||
|
);
|
||||||
|
expect(createMergedPdfName('base.pdf', ['a.pdf'], 'overwrite')).toBe(
|
||||||
|
'a_merged.pdf'
|
||||||
|
);
|
||||||
|
expect(createMergedPdfName(null, ['a.pdf', 'b.pdf'], 'overwrite')).toBe(
|
||||||
|
'merged_2_pdfs.pdf'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/merge/mergeQueueHelpers.ts
Normal file
86
src/merge/mergeQueueHelpers.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { PdfFile } from '../pdf/pdfTypes';
|
||||||
|
import type { MergeMode, MergeQueueItem } from './mergeTypes';
|
||||||
|
|
||||||
|
export function createMergeQueueItemId(): string {
|
||||||
|
return `merge_${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveMergeQueueItem(
|
||||||
|
items: MergeQueueItem[],
|
||||||
|
itemId: string,
|
||||||
|
direction: 'up' | 'down'
|
||||||
|
): MergeQueueItem[] {
|
||||||
|
const index = items.findIndex((item) => item.id === itemId);
|
||||||
|
if (index < 0) return items;
|
||||||
|
|
||||||
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= items.length) return items;
|
||||||
|
|
||||||
|
const next = [...items];
|
||||||
|
const [item] = next.splice(index, 1);
|
||||||
|
next.splice(targetIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadyMergeQueuePdfs(items: MergeQueueItem[]): PdfFile[] {
|
||||||
|
return items
|
||||||
|
.filter((item) => item.status === 'ready' && item.pdf)
|
||||||
|
.map((item) => item.pdf as PdfFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMergeQueue(items: MergeQueueItem[]): boolean {
|
||||||
|
return (
|
||||||
|
items.length > 0 &&
|
||||||
|
items.every((item) => item.status === 'ready' && item.pdf !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMergeQueueErrors(items: MergeQueueItem[]): boolean {
|
||||||
|
return items.some((item) => item.status === 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMergeQueueLoading(items: MergeQueueItem[]): boolean {
|
||||||
|
return items.some((item) => item.status === 'loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampMergeInsertAt(value: string, pageCount: number): number {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed)) return pageCount;
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed - 1, 0), pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultMergeInsertPosition(pageCount: number): string {
|
||||||
|
return String(pageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMergedPdfName(
|
||||||
|
currentPdfName: string | null,
|
||||||
|
incomingPdfNames: string[],
|
||||||
|
mode: MergeMode
|
||||||
|
): string {
|
||||||
|
const incomingBaseNames = incomingPdfNames.map(stripPdfExtension);
|
||||||
|
|
||||||
|
if (incomingBaseNames.length === 0) {
|
||||||
|
return currentPdfName ?? 'merged.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
incomingBaseNames.length === 1 &&
|
||||||
|
(!currentPdfName || mode === 'overwrite')
|
||||||
|
) {
|
||||||
|
return `${incomingBaseNames[0]}_merged.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPdfName && mode !== 'overwrite') {
|
||||||
|
const currentBaseName = stripPdfExtension(currentPdfName);
|
||||||
|
return `${currentBaseName}_plus_${incomingBaseNames.length}_pdfs.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `merged_${incomingBaseNames.length}_pdfs.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPdfExtension(filename: string): string {
|
||||||
|
const base = filename.replace(/\.pdf$/i, '').trim();
|
||||||
|
return base || 'document';
|
||||||
|
}
|
||||||
16
src/merge/mergeTypes.ts
Normal file
16
src/merge/mergeTypes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PdfFile } from '../pdf/pdfTypes';
|
||||||
|
|
||||||
|
export type MergeMode = 'overwrite' | 'append' | 'insertAt';
|
||||||
|
|
||||||
|
export type MergeQueueItemStatus = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface MergeQueueItem {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
pageCount: number | null;
|
||||||
|
pdf: PdfFile | null;
|
||||||
|
status: MergeQueueItemStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
102
src/merge/useMergeQueue.ts
Normal file
102
src/merge/useMergeQueue.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { loadPdfFromFile } from '../pdf/pdfService';
|
||||||
|
import {
|
||||||
|
canMergeQueue,
|
||||||
|
createMergeQueueItemId,
|
||||||
|
getReadyMergeQueuePdfs,
|
||||||
|
hasMergeQueueErrors,
|
||||||
|
isMergeQueueLoading,
|
||||||
|
moveMergeQueueItem,
|
||||||
|
} from './mergeQueueHelpers';
|
||||||
|
import type { MergeQueueItem } from './mergeTypes';
|
||||||
|
|
||||||
|
export function useMergeQueue() {
|
||||||
|
const [items, setItems] = useState<MergeQueueItem[]>([]);
|
||||||
|
|
||||||
|
const addFiles = useCallback((files: File[]) => {
|
||||||
|
const pdfFiles = files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.type === 'application/pdf' ||
|
||||||
|
file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pdfFiles.length === 0) return;
|
||||||
|
|
||||||
|
const queuedItems: MergeQueueItem[] = pdfFiles.map((file) => ({
|
||||||
|
id: createMergeQueueItemId(),
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
pageCount: null,
|
||||||
|
pdf: null,
|
||||||
|
status: 'loading',
|
||||||
|
}));
|
||||||
|
|
||||||
|
setItems((current) => [...current, ...queuedItems]);
|
||||||
|
|
||||||
|
queuedItems.forEach((item) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const loadedPdf = await loadPdfFromFile(item.file);
|
||||||
|
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((currentItem) =>
|
||||||
|
currentItem.id === item.id
|
||||||
|
? {
|
||||||
|
...currentItem,
|
||||||
|
pdf: loadedPdf,
|
||||||
|
pageCount: loadedPdf.pageCount,
|
||||||
|
status: 'ready',
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: currentItem
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((currentItem) =>
|
||||||
|
currentItem.id === item.id
|
||||||
|
? {
|
||||||
|
...currentItem,
|
||||||
|
status: 'error',
|
||||||
|
error: 'Could not load this PDF.',
|
||||||
|
}
|
||||||
|
: currentItem
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeItem = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== itemId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveItemUp = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => moveMergeQueueItem(current, itemId, 'up'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveItemDown = useCallback((itemId: string) => {
|
||||||
|
setItems((current) => moveMergeQueueItem(current, itemId, 'down'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearQueue = useCallback(() => {
|
||||||
|
setItems([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
moveItemUp,
|
||||||
|
moveItemDown,
|
||||||
|
clearQueue,
|
||||||
|
readyPdfs: getReadyMergeQueuePdfs(items),
|
||||||
|
canMerge: canMergeQueue(items),
|
||||||
|
hasErrors: hasMergeQueueErrors(items),
|
||||||
|
isLoading: isMergeQueueLoading(items),
|
||||||
|
};
|
||||||
|
}
|
||||||
57
src/pdf/pdfMergeService.test.ts
Normal file
57
src/pdf/pdfMergeService.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { PdfFile } from './pdfTypes';
|
||||||
|
import { mergePdfFilesAtPosition } from './pdfService';
|
||||||
|
|
||||||
|
async function makePdf(name: string, pageCount: number): Promise<PdfFile> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
for (let i = 0; i < pageCount; i += 1) {
|
||||||
|
doc.addPage([100, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await doc.save();
|
||||||
|
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(arrayBuffer).set(bytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
doc,
|
||||||
|
pageCount,
|
||||||
|
arrayBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergePdfFilesAtPosition', () => {
|
||||||
|
it('merges a queue without a current base PDF', async () => {
|
||||||
|
const first = await makePdf('first.pdf', 1);
|
||||||
|
const second = await makePdf('second.pdf', 2);
|
||||||
|
|
||||||
|
const merged = await mergePdfFilesAtPosition({
|
||||||
|
basePdf: null,
|
||||||
|
incomingPdfs: [first, second],
|
||||||
|
insertAt: 0,
|
||||||
|
name: 'merged.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.name).toBe('merged.pdf');
|
||||||
|
expect(merged.pageCount).toBe(3);
|
||||||
|
expect(merged.doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts queued PDFs into a current base PDF at the requested slot', async () => {
|
||||||
|
const base = await makePdf('base.pdf', 3);
|
||||||
|
const incoming = await makePdf('incoming.pdf', 2);
|
||||||
|
|
||||||
|
const merged = await mergePdfFilesAtPosition({
|
||||||
|
basePdf: base,
|
||||||
|
incomingPdfs: [incoming],
|
||||||
|
insertAt: 1,
|
||||||
|
name: 'base_plus_incoming.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.name).toBe('base_plus_incoming.pdf');
|
||||||
|
expect(merged.pageCount).toBe(5);
|
||||||
|
expect(merged.doc.getPageCount()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,6 +77,76 @@ export async function mergePdfFiles(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MergePdfFilesAtPositionOptions {
|
||||||
|
basePdf: PdfFile | null;
|
||||||
|
incomingPdfs: PdfFile[];
|
||||||
|
insertAt: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergePdfFilesAtPosition({
|
||||||
|
basePdf,
|
||||||
|
incomingPdfs,
|
||||||
|
insertAt,
|
||||||
|
name,
|
||||||
|
}: MergePdfFilesAtPositionOptions): Promise<PdfFile> {
|
||||||
|
if (!basePdf && incomingPdfs.length === 0) {
|
||||||
|
throw new Error('At least one PDF is required for merging');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDoc = await PDFDocument.create();
|
||||||
|
|
||||||
|
const addAllPages = async (sourcePdf: PdfFile) => {
|
||||||
|
const sourceDoc =
|
||||||
|
sourcePdf.doc ?? (await PDFDocument.load(sourcePdf.arrayBuffer));
|
||||||
|
const pageCount = sourceDoc.getPageCount();
|
||||||
|
const pages = await mergedDoc.copyPages(
|
||||||
|
sourceDoc,
|
||||||
|
Array.from({ length: pageCount }, (_, i) => i)
|
||||||
|
);
|
||||||
|
pages.forEach((page) => mergedDoc.addPage(page));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!basePdf) {
|
||||||
|
for (const incomingPdf of incomingPdfs) {
|
||||||
|
await addAllPages(incomingPdf);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const baseDoc =
|
||||||
|
basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||||
|
const basePageCount = baseDoc.getPageCount();
|
||||||
|
const clampedInsertAt = Math.min(Math.max(insertAt, 0), basePageCount);
|
||||||
|
|
||||||
|
const basePages = await mergedDoc.copyPages(
|
||||||
|
baseDoc,
|
||||||
|
Array.from({ length: basePageCount }, (_, i) => i)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < clampedInsertAt; i += 1) {
|
||||||
|
mergedDoc.addPage(basePages[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const incomingPdf of incomingPdfs) {
|
||||||
|
await addAllPages(incomingPdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = clampedInsertAt; i < basePages.length; i += 1) {
|
||||||
|
mergedDoc.addPage(basePages[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await mergedDoc.save();
|
||||||
|
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
name,
|
||||||
|
arrayBuffer: buffer,
|
||||||
|
pageCount: mergedDoc.getPageCount(),
|
||||||
|
doc: mergedDoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function splitIntoSinglePages(
|
export async function splitIntoSinglePages(
|
||||||
pdf: PdfFile
|
pdf: PdfFile
|
||||||
): Promise<SplitResult[]> {
|
): Promise<SplitResult[]> {
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
@@ -398,3 +398,94 @@ button.secondary {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.merge-queue-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-order {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e3a8a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-details strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-details span {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-actions button.secondary {
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-mode-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-mode-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-warning {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #9a3412;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.merge-queue-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-queue-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '0.3.0';
|
export const APP_VERSION = '0.3.2';
|
||||||
|
|||||||
Reference in New Issue
Block a user