refactoring, linting, formatting
This commit is contained in:
33
CHANGELOG.md
33
CHANGELOG.md
@@ -4,6 +4,37 @@ 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.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added TypeScript type-check, ESLint, Prettier, and aggregate `check` scripts.
|
||||||
|
- Added ESLint flat config with TypeScript, React Hooks, React Refresh, browser, and Node config support.
|
||||||
|
- Added Prettier configuration and ignore file.
|
||||||
|
- Added Vite client type declarations for worker URL imports.
|
||||||
|
- Added Vitest-based test scripts for one-off and watch-mode test runs.
|
||||||
|
- Added pure tests for workspace command cloning, snapshot command stability, and serializable command record round-tripping.
|
||||||
|
- Added hook-level tests for workspace load/replace behavior, command execution, undo, redo, history, redo clearing, dirty-state updates, and content-change callbacks.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Marked the package as an ES module package to remove the Vite CJS Node API deprecation warning during local tooling runs.
|
||||||
|
- Ran Prettier across the project after adding the formatting configuration.
|
||||||
|
- Split the former monolithic `ReorderPanel` into focused page-workspace components: `PageGrid`, `PageCard`, `PageSelectionToolbar`, `DropIndicator`, and `CopyPagesDialog`.
|
||||||
|
- Kept drag/drop move, Ctrl/⌘ copy-drag, selection, rotation, deletion, preview opening, and copy-by-position behavior wired through the existing `ReorderPanel` API.
|
||||||
|
- Extracted generated PDF download URL creation and cleanup for split, extract, and export results into `src/hooks/usePdfGeneratedOutputs.ts`.
|
||||||
|
- Updated `ActionsPanel` to render prepared download objects instead of creating object URLs during render.
|
||||||
|
- Extracted workspace page, selection, dirty-state, message, undo/redo history, command creation, command execution, and reset/load helpers from `App.tsx` into `src/workspace/useWorkspaceState.ts`.
|
||||||
|
- Extracted thumbnail state, caching, invalidation, progressive rendering, rotation-aware rendering, copied-page thumbnail reuse, and thumbnail error reporting into `src/pdf/usePdfThumbnails.ts`.
|
||||||
|
- Kept PDF loading, IndexedDB persistence, dialogs, preview, merge, export, and split orchestration in `App.tsx` for now.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed existing `tsc --noEmit` failures for Vite worker URL imports and `Uint8Array`/`BlobPart` PDF byte handling.
|
||||||
|
- Removed a duplicate copy-dialog validation error assignment in `ReorderPanel`.
|
||||||
|
- Rotated thumbnails from loaded/saved workspaces are now regenerated from the actual current page rotation instead of relying only on rotation changes after load.
|
||||||
|
- Copied/duplicated pages now receive thumbnails through the shared thumbnail hook/cache path instead of ad-hoc copy handling in `App.tsx`.
|
||||||
|
|
||||||
## 0.2.0 — Browser-only PDF workspace baseline
|
## 0.2.0 — Browser-only PDF workspace baseline
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -18,4 +49,4 @@ The project follows a pragmatic versioning scheme while the app is still below `
|
|||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
This release is the baseline for the next refactoring phase. The goal of the upcoming internal changes is to preserve behavior while extracting workspace state, thumbnail handling, generated object URLs, UI subcomponents, tests, and linting into clearer modules.
|
This release is the baseline for the next refactoring phase. The goal of the upcoming internal changes is to preserve behavior while extracting workspace state, thumbnail handling, generated object URLs, UI subcomponents, tests, and linting into clearer modules.
|
||||||
|
|||||||
54
README
54
README
@@ -75,17 +75,17 @@ This makes the project especially useful for self-hosted environments, public-se
|
|||||||
|
|
||||||
### Keyboard shortcuts
|
### Keyboard shortcuts
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| -------------------------- | --------------------------------------------------- |
|
||||||
| `F1` / `?` | Open in-app help and tutorial |
|
| `F1` / `?` | Open in-app help and tutorial |
|
||||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||||
| `Esc` | Clear the current selection or close an open dialog |
|
| `Esc` | Clear the current selection or close an open dialog |
|
||||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||||
| `←` / `→` in preview | Move to previous / next page |
|
| `←` / `→` in preview | Move to previous / next page |
|
||||||
| `Esc` in preview | Close preview |
|
| `Esc` in preview | Close preview |
|
||||||
|
|
||||||
Keyboard shortcuts are ignored while typing in form fields.
|
Keyboard shortcuts are ignored while typing in form fields.
|
||||||
|
|
||||||
@@ -115,25 +115,35 @@ browser <-- HTTPS --> static web server / reverse proxy --> built app assets
|
|||||||
|
|
||||||
There is no application server to operate. Administrators only need to host the built files from `dist/`.
|
There is no application server to operate. Administrators only need to host the built files from `dist/`.
|
||||||
|
|
||||||
### Build from source
|
### Build and test from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run check` runs the main project quality gate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run test
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The production build is written to `dist/`.
|
The production build is written to `dist/`.
|
||||||
|
|
||||||
To preview the production build locally:
|
Useful individual development commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run dev # start the Vite development server
|
||||||
```
|
npm run preview # preview the production build locally
|
||||||
|
npm run test # run tests once
|
||||||
For development:
|
npm run test:watch # run tests in watch mode
|
||||||
|
npm run typecheck # run TypeScript without emitting files
|
||||||
```bash
|
npm run lint # run ESLint
|
||||||
npm run dev
|
npm run format # format the project with Prettier
|
||||||
|
npm run format:check # verify Prettier formatting
|
||||||
```
|
```
|
||||||
|
|
||||||
### Static hosting
|
### Static hosting
|
||||||
@@ -219,11 +229,13 @@ The current development baseline is:
|
|||||||
v0.2.0 — Browser-only PDF workspace baseline
|
v0.2.0 — Browser-only PDF workspace baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This baseline is preserved through a staged refactoring path. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are now separated enough to support the next feature phase without turning `App.tsx` back into a monolith.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
App.tsx Main application state and orchestration
|
App.tsx Main application orchestration and UI wiring
|
||||||
components/
|
components/
|
||||||
ActionDialog.tsx Reusable confirmation/action dialog
|
ActionDialog.tsx Reusable confirmation/action dialog
|
||||||
ActionsPanel.tsx Export, extract, and split actions
|
ActionsPanel.tsx Export, extract, and split actions
|
||||||
|
|||||||
55
eslint.config.mjs
Normal file
55
eslint.config.mjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist", "coverage", "node_modules"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2022,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["*.config.{js,ts}", "eslint.config.js"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.es2022,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eslintConfigPrettier,
|
||||||
|
);
|
||||||
4183
package-lock.json
generated
4183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "pdf-tools",
|
"name": "pdf-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "echo \"no lint configured\""
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run --environment jsdom",
|
||||||
|
"test:watch": "vitest --environment jsdom",
|
||||||
|
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
@@ -15,11 +22,24 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.8.0",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.2",
|
"@types/react-dom": "^18.3.2",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"eslint": "^10.4.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10"
|
"typescript-eslint": "^8.59.3",
|
||||||
|
"vite": "^8.0.13",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
},
|
},
|
||||||
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
|
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
pdf-tools.zip
Normal file
BIN
pdf-tools.zip
Normal file
Binary file not shown.
4
prettierignore.txt
Normal file
4
prettierignore.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
*.zip
|
||||||
6
prettierrc.json
Normal file
6
prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
844
src/App.tsx
844
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
export interface ActionDialogAction {
|
export interface ActionDialogAction {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void | Promise<void>;
|
onClick: () => void | Promise<void>;
|
||||||
variant?: 'primary' | 'secondary' | 'danger';
|
variant?: "primary" | "secondary" | "danger";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -18,21 +18,21 @@ interface ActionDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backgroundByVariant: Record<
|
const backgroundByVariant: Record<
|
||||||
NonNullable<ActionDialogAction['variant']>,
|
NonNullable<ActionDialogAction["variant"]>,
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
primary: '#2563eb',
|
primary: "#2563eb",
|
||||||
secondary: '#e5e7eb',
|
secondary: "#e5e7eb",
|
||||||
danger: '#dc2626',
|
danger: "#dc2626",
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorByVariant: Record<
|
const colorByVariant: Record<
|
||||||
NonNullable<ActionDialogAction['variant']>,
|
NonNullable<ActionDialogAction["variant"]>,
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
primary: 'white',
|
primary: "white",
|
||||||
secondary: '#111827',
|
secondary: "#111827",
|
||||||
danger: 'white',
|
danger: "white",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||||
@@ -46,16 +46,16 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
@@ -72,42 +72,42 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 70,
|
zIndex: 70,
|
||||||
background: 'rgba(15, 23, 42, 0.55)',
|
background: "rgba(15, 23, 42, 0.55)",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
maxWidth: '440px',
|
maxWidth: "440px",
|
||||||
background: 'white',
|
background: "white",
|
||||||
borderRadius: '0.75rem',
|
borderRadius: "0.75rem",
|
||||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
gap: '0.75rem',
|
gap: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.75rem',
|
gap: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
id="action-dialog-title"
|
id="action-dialog-title"
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
fontSize: '1rem',
|
fontSize: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -117,18 +117,18 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: '999px',
|
borderRadius: "999px",
|
||||||
width: '1.8rem',
|
width: "1.8rem",
|
||||||
height: '1.8rem',
|
height: "1.8rem",
|
||||||
background: '#e5e7eb',
|
background: "#e5e7eb",
|
||||||
color: '#111827',
|
color: "#111827",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
fontSize: '1.1rem',
|
fontSize: "1.1rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
aria-label="Close dialog"
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
@@ -138,8 +138,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.9rem',
|
fontSize: "0.9rem",
|
||||||
color: '#4b5563',
|
color: "#4b5563",
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -148,15 +148,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'flex-end',
|
justifyContent: "flex-end",
|
||||||
gap: '0.5rem',
|
gap: "0.5rem",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
marginTop: '0.25rem',
|
marginTop: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const variant = action.variant ?? 'secondary';
|
const variant = action.variant ?? "secondary";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -169,15 +169,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
autoFocus={action.autoFocus}
|
autoFocus={action.autoFocus}
|
||||||
title={action.title}
|
title={action.title}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: '0.5rem',
|
borderRadius: "0.5rem",
|
||||||
padding: '0.45rem 0.8rem',
|
padding: "0.45rem 0.8rem",
|
||||||
background: action.disabled
|
background: action.disabled
|
||||||
? '#e5e7eb'
|
? "#e5e7eb"
|
||||||
: backgroundByVariant[variant],
|
: backgroundByVariant[variant],
|
||||||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
color: action.disabled ? "#6b7280" : colorByVariant[variant],
|
||||||
cursor: action.disabled ? 'default' : 'pointer',
|
cursor: action.disabled ? "default" : "pointer",
|
||||||
fontSize: '0.9rem',
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
@@ -190,4 +190,4 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ActionDialog;
|
export default ActionDialog;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import type { SplitResult } from '../pdf/pdfTypes';
|
import type {
|
||||||
|
PdfDownload,
|
||||||
|
SplitPdfDownload,
|
||||||
|
} from "../hooks/usePdfGeneratedOutputs";
|
||||||
|
|
||||||
interface ActionsPanelProps {
|
interface ActionsPanelProps {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
@@ -11,11 +14,9 @@ interface ActionsPanelProps {
|
|||||||
onExtractSelected: () => void;
|
onExtractSelected: () => void;
|
||||||
onExportReordered: () => void;
|
onExportReordered: () => void;
|
||||||
|
|
||||||
splitResults: SplitResult[];
|
splitDownloads: SplitPdfDownload[];
|
||||||
subsetDownloadUrl: string | null;
|
subsetDownload: PdfDownload | null;
|
||||||
subsetFilename: string | null;
|
exportDownload: PdfDownload | null;
|
||||||
exportDownloadUrl: string | null;
|
|
||||||
exportFilename: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||||
@@ -25,11 +26,9 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
onSplit,
|
onSplit,
|
||||||
onExtractSelected,
|
onExtractSelected,
|
||||||
onExportReordered,
|
onExportReordered,
|
||||||
splitResults,
|
splitDownloads,
|
||||||
subsetDownloadUrl,
|
subsetDownload,
|
||||||
subsetFilename,
|
exportDownload,
|
||||||
exportDownloadUrl,
|
|
||||||
exportFilename,
|
|
||||||
}) => {
|
}) => {
|
||||||
const disabled = !hasPdf || isBusy;
|
const disabled = !hasPdf || isBusy;
|
||||||
|
|
||||||
@@ -41,20 +40,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Tools</h2>
|
<h2>Tools</h2>
|
||||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||||
Use these tools on the current in-memory document (reordered, rotated,
|
Use these tools on the current in-memory document (reordered, rotated,
|
||||||
with deletions). Nothing is uploaded to a server.
|
with deletions). Nothing is uploaded to a server.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="button-row"
|
className="button-row"
|
||||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
style={{ justifyContent: "space-between", flexWrap: "wrap" }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onExportReordered}
|
onClick={onExportReordered}
|
||||||
style={{ flex: '1 1 45%' }}
|
style={{ flex: "1 1 45%" }}
|
||||||
>
|
>
|
||||||
🧾 Export new PDF
|
🧾 Export new PDF
|
||||||
</button>
|
</button>
|
||||||
@@ -63,11 +62,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={disabled || selectedCount === 0}
|
disabled={disabled || selectedCount === 0}
|
||||||
onClick={handleExtractSelectedClick}
|
onClick={handleExtractSelectedClick}
|
||||||
style={{ flex: '1 1 45%' }}
|
style={{ flex: "1 1 45%" }}
|
||||||
title={
|
title={
|
||||||
selectedCount === 0
|
selectedCount === 0
|
||||||
? 'Select at least one page'
|
? "Select at least one page"
|
||||||
: 'Create a PDF from selected pages'
|
: "Create a PDF from selected pages"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
📤 Extract selected ({selectedCount})
|
📤 Extract selected ({selectedCount})
|
||||||
@@ -77,58 +76,52 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
|||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
style={{ flex: '1 1 45%' }}
|
style={{ flex: "1 1 45%" }}
|
||||||
>
|
>
|
||||||
📂 Split into single PDFs
|
📂 Split into single PDFs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subsetDownloadUrl && subsetFilename && (
|
{subsetDownload && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||||
<strong>Subset result:</strong>{' '}
|
<strong>Subset result:</strong>{" "}
|
||||||
<a
|
<a
|
||||||
className="download-link"
|
className="download-link"
|
||||||
href={subsetDownloadUrl}
|
href={subsetDownload.url}
|
||||||
download={subsetFilename}
|
download={subsetDownload.filename}
|
||||||
>
|
>
|
||||||
Download {subsetFilename}
|
Download {subsetDownload.filename}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{exportDownloadUrl && exportFilename && (
|
{exportDownload && (
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||||
<strong>Exported document:</strong>{' '}
|
<strong>Exported document:</strong>{" "}
|
||||||
<a
|
<a
|
||||||
className="download-link"
|
className="download-link"
|
||||||
href={exportDownloadUrl}
|
href={exportDownload.url}
|
||||||
download={exportFilename}
|
download={exportDownload.filename}
|
||||||
>
|
>
|
||||||
Download {exportFilename}
|
Download {exportDownload.filename}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{splitResults.length > 0 && (
|
{splitDownloads.length > 0 && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||||
<strong>Single-page PDFs:</strong>
|
<strong>Single-page PDFs:</strong>
|
||||||
<div>
|
<div>
|
||||||
{splitResults.map((r) => {
|
{splitDownloads.map((download) => (
|
||||||
const url = URL.createObjectURL(r.blob);
|
<a
|
||||||
return (
|
key={download.id}
|
||||||
<a
|
className="download-link"
|
||||||
key={r.pageIndex}
|
href={download.url}
|
||||||
className="download-link"
|
download={download.filename}
|
||||||
href={url}
|
>
|
||||||
download={r.filename}
|
{download.filename}
|
||||||
onClick={() => {
|
</a>
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
))}
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.filename}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import type { PdfFile } from '../pdf/pdfTypes';
|
import type { PdfFile } from "../pdf/pdfTypes";
|
||||||
|
|
||||||
interface FileLoaderProps {
|
interface FileLoaderProps {
|
||||||
pdf: PdfFile | null;
|
pdf: PdfFile | null;
|
||||||
@@ -11,7 +11,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
onFileLoaded(file);
|
onFileLoaded(file);
|
||||||
e.target.value = '';
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
<input type="file" accept="application/pdf" onChange={handleChange} />
|
||||||
|
|
||||||
{pdf && (
|
{pdf && (
|
||||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<strong>Loaded:</strong> {pdf.name}
|
<strong>Loaded:</strong> {pdf.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
interface HelpDialogProps {
|
interface HelpDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -6,36 +6,51 @@ interface HelpDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
|
{ keys: "F1 / ?", description: "Open this help and tutorial dialog" },
|
||||||
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' },
|
{
|
||||||
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' },
|
keys: "Ctrl/⌘ + A",
|
||||||
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
|
description: "Select all pages in the current workspace",
|
||||||
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
|
},
|
||||||
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' },
|
{
|
||||||
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
|
keys: "Delete / Backspace",
|
||||||
{ keys: '← / → in preview', description: 'Move to the previous or next page in the preview overlay' },
|
description: "Delete the selected pages after confirmation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "Esc",
|
||||||
|
description: "Clear the page selection or close an open dialog",
|
||||||
|
},
|
||||||
|
{ keys: "Ctrl/⌘ + Z", description: "Undo the latest workspace command" },
|
||||||
|
{
|
||||||
|
keys: "Ctrl/⌘ + Shift + Z",
|
||||||
|
description: "Redo the next workspace command",
|
||||||
|
},
|
||||||
|
{ keys: "Ctrl/⌘ + Y", description: "Redo the next workspace command" },
|
||||||
|
{
|
||||||
|
keys: "← / → in preview",
|
||||||
|
description: "Move to the previous or next page in the preview overlay",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const tutorialSteps = [
|
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. If you saved workspaces before, you can restore one from browser storage instead.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '2. Arrange pages visually',
|
title: "2. Arrange pages visually",
|
||||||
body: 'Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.',
|
body: "Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '3. Select, copy, and delete pages',
|
title: "3. Select, copy, and delete pages",
|
||||||
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.',
|
body: "Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '4. Save your workspace or export a PDF',
|
title: "4. Save your workspace or export a PDF",
|
||||||
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
body: "Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '5. Use history deliberately',
|
title: "5. 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.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,7 +59,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') return;
|
if (e.key !== "Escape") return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -52,10 +67,10 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
@@ -79,8 +94,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||||
<p>
|
<p>
|
||||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||||
rearrange, split, merge, rotate, duplicate, and export PDFs without
|
rearrange, split, merge, rotate, duplicate, and export PDFs
|
||||||
uploading documents to a server.
|
without uploading documents to a server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,8 +134,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="help-note">
|
<p className="help-note">
|
||||||
Shortcuts are ignored while typing in text fields or other editable
|
Shortcuts are ignored while typing in text fields or other
|
||||||
controls.
|
editable controls.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { APP_VERSION } from '../version';
|
import { APP_VERSION } from "../version";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -44,4 +44,4 @@ const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from "react";
|
||||||
import type { PdfFile } from '../pdf/pdfTypes';
|
import type { PdfFile } from "../pdf/pdfTypes";
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
|
||||||
|
|
||||||
// pdf.js worker setup
|
// pdf.js worker setup
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -10,10 +10,10 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
|||||||
interface PagePreviewModalProps {
|
interface PagePreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
pdf: PdfFile | null;
|
pdf: PdfFile | null;
|
||||||
pageIndex: number | null; // original page index, 0-based
|
pageIndex: number | null; // original page index, 0-based
|
||||||
rotation: number; // degrees
|
rotation: number; // degrees
|
||||||
|
|
||||||
visualIndex: number | null; // current position in order, 0-based
|
visualIndex: number | null; // current position in order, 0-based
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
|
||||||
canGoPrevious: boolean;
|
canGoPrevious: boolean;
|
||||||
@@ -43,28 +43,28 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowLeft' && canGoPrevious) {
|
if (e.key === "ArrowLeft" && canGoPrevious) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPrevious();
|
onPrevious();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowRight' && canGoNext) {
|
if (e.key === "ArrowRight" && canGoNext) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onNext();
|
onNext();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
|
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
|
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
maxWidth / viewport.width,
|
maxWidth / viewport.width,
|
||||||
maxHeight / viewport.height
|
maxHeight / viewport.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scaledViewport = page.getViewport({ scale });
|
const scaledViewport = page.getViewport({ scale });
|
||||||
@@ -110,7 +110,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
const visibleCanvas = canvasRef.current;
|
const visibleCanvas = canvasRef.current;
|
||||||
if (!visibleCanvas) return;
|
if (!visibleCanvas) return;
|
||||||
|
|
||||||
const visibleCtx = visibleCanvas.getContext('2d');
|
const visibleCtx = visibleCanvas.getContext("2d");
|
||||||
if (!visibleCtx) return;
|
if (!visibleCtx) return;
|
||||||
|
|
||||||
let canvasWidth = scaledViewport.width;
|
let canvasWidth = scaledViewport.width;
|
||||||
@@ -126,8 +126,8 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
visibleCanvas.width = canvasWidth;
|
visibleCanvas.width = canvasWidth;
|
||||||
visibleCanvas.height = canvasHeight;
|
visibleCanvas.height = canvasHeight;
|
||||||
|
|
||||||
const baseCanvas = document.createElement('canvas');
|
const baseCanvas = document.createElement("canvas");
|
||||||
const baseCtx = baseCanvas.getContext('2d');
|
const baseCtx = baseCanvas.getContext("2d");
|
||||||
if (!baseCtx) return;
|
if (!baseCtx) return;
|
||||||
|
|
||||||
baseCanvas.width = scaledViewport.width;
|
baseCanvas.width = scaledViewport.width;
|
||||||
@@ -161,7 +161,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
visibleCtx.drawImage(baseCanvas, 0, 0);
|
visibleCtx.drawImage(baseCanvas, 0, 0);
|
||||||
visibleCtx.restore();
|
visibleCtx.restore();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error rendering preview', e);
|
console.error("Error rendering preview", e);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -181,30 +181,30 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: 'rgba(15, 23, 42, 0.8)',
|
background: "rgba(15, 23, 42, 0.8)",
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
padding: '1rem',
|
padding: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
background: '#111827',
|
background: "#111827",
|
||||||
borderRadius: '0.75rem',
|
borderRadius: "0.75rem",
|
||||||
padding: '0.75rem',
|
padding: "0.75rem",
|
||||||
maxWidth: '90vw',
|
maxWidth: "90vw",
|
||||||
maxHeight: '90vh',
|
maxHeight: "90vh",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.5rem',
|
gap: "0.5rem",
|
||||||
overflow: 'visible',
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Previous page */}
|
{/* Previous page */}
|
||||||
@@ -216,22 +216,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
disabled={!canGoPrevious}
|
disabled={!canGoPrevious}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
top: '50%',
|
top: "50%",
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: "translate(-50%, -50%)",
|
||||||
width: '2.5rem',
|
width: "2.5rem",
|
||||||
height: '2.5rem',
|
height: "2.5rem",
|
||||||
borderRadius: '999px',
|
borderRadius: "999px",
|
||||||
border: 'none',
|
border: "none",
|
||||||
background: canGoPrevious ? '#374151' : '#1f2937',
|
background: canGoPrevious ? "#374151" : "#1f2937",
|
||||||
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
|
color: canGoPrevious ? "#e5e7eb" : "#6b7280",
|
||||||
cursor: canGoPrevious ? 'pointer' : 'default',
|
cursor: canGoPrevious ? "pointer" : "default",
|
||||||
fontSize: '1.35rem',
|
fontSize: "1.35rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
title="Previous page (←)"
|
title="Previous page (←)"
|
||||||
@@ -249,22 +249,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
disabled={!canGoNext}
|
disabled={!canGoNext}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: '50%',
|
top: "50%",
|
||||||
transform: 'translate(50%, -50%)',
|
transform: "translate(50%, -50%)",
|
||||||
width: '2.5rem',
|
width: "2.5rem",
|
||||||
height: '2.5rem',
|
height: "2.5rem",
|
||||||
borderRadius: '999px',
|
borderRadius: "999px",
|
||||||
border: 'none',
|
border: "none",
|
||||||
background: canGoNext ? '#374151' : '#1f2937',
|
background: canGoNext ? "#374151" : "#1f2937",
|
||||||
color: canGoNext ? '#e5e7eb' : '#6b7280',
|
color: canGoNext ? "#e5e7eb" : "#6b7280",
|
||||||
cursor: canGoNext ? 'pointer' : 'default',
|
cursor: canGoNext ? "pointer" : "default",
|
||||||
fontSize: '1.35rem',
|
fontSize: "1.35rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
title="Next page (→)"
|
title="Next page (→)"
|
||||||
@@ -281,22 +281,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
transform: 'translate(50%, -50%)',
|
transform: "translate(50%, -50%)",
|
||||||
width: '2.25rem',
|
width: "2.25rem",
|
||||||
height: '2.25rem',
|
height: "2.25rem",
|
||||||
borderRadius: '999px',
|
borderRadius: "999px",
|
||||||
border: 'none',
|
border: "none",
|
||||||
background: '#374151',
|
background: "#374151",
|
||||||
color: '#e5e7eb',
|
color: "#e5e7eb",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
fontSize: '1.2rem',
|
fontSize: "1.2rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
}}
|
}}
|
||||||
title="Close preview (Esc)"
|
title="Close preview (Esc)"
|
||||||
@@ -308,14 +308,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
maxHeight: '75vh',
|
maxHeight: "75vh",
|
||||||
background: 'white',
|
background: "white",
|
||||||
borderRadius: '0.5rem',
|
borderRadius: "0.5rem",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
|
<div style={{ color: "#e5e7eb", fontSize: "0.85rem" }}>
|
||||||
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
|
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,4 +323,4 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PagePreviewModal;
|
export default PagePreviewModal;
|
||||||
|
|||||||
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
interface CopyPagesDialogProps {
|
||||||
|
selectedCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
targetPosition: string;
|
||||||
|
error: string | null;
|
||||||
|
onTargetPositionChange: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (e?: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||||
|
selectedCount,
|
||||||
|
pageCount,
|
||||||
|
targetPosition,
|
||||||
|
error,
|
||||||
|
onTargetPositionChange,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="copy-pages-dialog-title"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 60,
|
||||||
|
background: "rgba(15, 23, 42, 0.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={onConfirm}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "420px",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
|
||||||
|
padding: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="copy-pages-dialog-title"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy selected pages
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "999px",
|
||||||
|
width: "1.8rem",
|
||||||
|
height: "1.8rem",
|
||||||
|
background: "#e5e7eb",
|
||||||
|
color: "#111827",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
aria-label="Close copy dialog"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#4b5563",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy{" "}
|
||||||
|
<strong>
|
||||||
|
{selectedCount === 1
|
||||||
|
? "1 selected page"
|
||||||
|
: `${selectedCount} selected pages`}
|
||||||
|
</strong>{" "}
|
||||||
|
to a new position.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Insert before position
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={pageCount + 1}
|
||||||
|
value={targetPosition}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => onTargetPositionChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "0.45rem 0.55rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>1 = before the first page</div>
|
||||||
|
<div>{pageCount + 1} = after the last page</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
color: "#b91c1c",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
padding: "0.45rem 0.8rem",
|
||||||
|
background: "#e5e7eb",
|
||||||
|
color: "#111827",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
padding: "0.45rem 0.8rem",
|
||||||
|
background: "#16a34a",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy pages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyPagesDialog;
|
||||||
27
src/components/PageWorkspace/DropIndicator.tsx
Normal file
27
src/components/PageWorkspace/DropIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface DropIndicatorProps {
|
||||||
|
side: "left" | "right" | "end";
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropIndicator: React.FC<DropIndicatorProps> = ({ side, color }) => {
|
||||||
|
const isEnd = side === "end";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: side === "left" ? "-4px" : isEnd ? "8px" : undefined,
|
||||||
|
right: side === "right" ? "-4px" : undefined,
|
||||||
|
top: "4px",
|
||||||
|
bottom: "4px",
|
||||||
|
width: "3px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropIndicator;
|
||||||
213
src/components/PageWorkspace/PageCard.tsx
Normal file
213
src/components/PageWorkspace/PageCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { PageRef } from "../../pdf/pdfTypes";
|
||||||
|
import DropIndicator from "./DropIndicator";
|
||||||
|
|
||||||
|
interface PageCardProps {
|
||||||
|
page: PageRef;
|
||||||
|
visualIndex: number;
|
||||||
|
thumbnail?: string;
|
||||||
|
selected: boolean;
|
||||||
|
isDraggingCard: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
isCopyDragging: boolean;
|
||||||
|
showLeftLine: boolean;
|
||||||
|
showRightLine: boolean;
|
||||||
|
dropIndicatorColor: string;
|
||||||
|
onDragStart: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragEnd: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragOver: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onOpenPreview: () => void;
|
||||||
|
onToggleSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onRotateClockwise: () => void;
|
||||||
|
onRotateCounterclockwise: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageActionButtonStyle: React.CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "0.15rem 0.4rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageCard: React.FC<PageCardProps> = ({
|
||||||
|
page,
|
||||||
|
visualIndex,
|
||||||
|
thumbnail,
|
||||||
|
selected,
|
||||||
|
isDraggingCard,
|
||||||
|
isBusy,
|
||||||
|
isCopyDragging,
|
||||||
|
showLeftLine,
|
||||||
|
showRightLine,
|
||||||
|
dropIndicatorColor,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onOpenPreview,
|
||||||
|
onToggleSelect,
|
||||||
|
onRotateClockwise,
|
||||||
|
onRotateCounterclockwise,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const background = isDraggingCard
|
||||||
|
? isCopyDragging
|
||||||
|
? "#dcfce7"
|
||||||
|
: "#dbeafe"
|
||||||
|
: selected
|
||||||
|
? "#eff6ff"
|
||||||
|
: "#f9fafb";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onClick={onOpenPreview}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "162px",
|
||||||
|
padding: "0.4rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
background,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
cursor: isBusy ? "default" : isCopyDragging ? "copy" : "grab",
|
||||||
|
opacity: isBusy ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSelect}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "4px",
|
||||||
|
left: "4px",
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
borderRadius: "0.4rem",
|
||||||
|
border: "1px solid #9ca3af",
|
||||||
|
background: selected ? "#2563eb" : "rgba(255,255,255,0.9)",
|
||||||
|
color: selected ? "white" : "transparent",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
title="Select page"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLeftLine && <DropIndicator side="left" color={dropIndicatorColor} />}
|
||||||
|
{showRightLine && (
|
||||||
|
<DropIndicator side="right" color={dropIndicatorColor} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "110px",
|
||||||
|
height: "90px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={`Page ${page.sourcePageIndex + 1}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "60px",
|
||||||
|
height: "80px",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
border: "1px dashed #d1d5db",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ fontSize: "0.8rem" }}>
|
||||||
|
Page {page.sourcePageIndex + 1}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.7rem", color: "#6b7280" }}>
|
||||||
|
Pos {visualIndex + 1} · Rot {page.rotation}°
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRotateClockwise();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...pageActionButtonStyle,
|
||||||
|
background: "#e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ 90°
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRotateCounterclockwise();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...pageActionButtonStyle,
|
||||||
|
background: "#e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↺ 90°
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...pageActionButtonStyle,
|
||||||
|
background: "#fecaca",
|
||||||
|
color: "#b91c1c",
|
||||||
|
}}
|
||||||
|
title="Remove this page from the exported PDF"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageCard;
|
||||||
130
src/components/PageWorkspace/PageGrid.tsx
Normal file
130
src/components/PageWorkspace/PageGrid.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { PageRef } from "../../pdf/pdfTypes";
|
||||||
|
import DropIndicator from "./DropIndicator";
|
||||||
|
import PageCard from "./PageCard";
|
||||||
|
|
||||||
|
interface PageGridProps {
|
||||||
|
pages: PageRef[];
|
||||||
|
thumbnails: Record<string, string>;
|
||||||
|
selectedPageIds: string[];
|
||||||
|
isBusy: boolean;
|
||||||
|
draggingIndex: number | null;
|
||||||
|
dropIndex: number | null;
|
||||||
|
draggingSelectionActive: boolean;
|
||||||
|
isCopyDragging: boolean;
|
||||||
|
dropIndicatorColor: string;
|
||||||
|
onDragStart: (visualIndex: number) => React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onDragEnd: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onCardDragOver: (
|
||||||
|
visualIndex: number,
|
||||||
|
) => React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onEndSlotDragOver: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onDrop: React.DragEventHandler<HTMLDivElement>;
|
||||||
|
onOpenPreview: (pageId: string) => void;
|
||||||
|
onToggleSelect: (
|
||||||
|
pageId: string,
|
||||||
|
visualIndex: number,
|
||||||
|
) => React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onRotateClockwise: (pageId: string) => void;
|
||||||
|
onRotateCounterclockwise: (pageId: string) => void;
|
||||||
|
onDelete: (pageId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageGrid: React.FC<PageGridProps> = ({
|
||||||
|
pages,
|
||||||
|
thumbnails,
|
||||||
|
selectedPageIds,
|
||||||
|
isBusy,
|
||||||
|
draggingIndex,
|
||||||
|
dropIndex,
|
||||||
|
draggingSelectionActive,
|
||||||
|
isCopyDragging,
|
||||||
|
dropIndicatorColor,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onCardDragOver,
|
||||||
|
onEndSlotDragOver,
|
||||||
|
onDrop,
|
||||||
|
onOpenPreview,
|
||||||
|
onToggleSelect,
|
||||||
|
onRotateClockwise,
|
||||||
|
onRotateCounterclockwise,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
||||||
|
|
||||||
|
const showLeftLine = (visualIndex: number) =>
|
||||||
|
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||||
|
|
||||||
|
const showRightLine = (visualIndex: number) =>
|
||||||
|
dropIndex !== null &&
|
||||||
|
dropIndex === visualIndex + 1 &&
|
||||||
|
draggingIndex !== null;
|
||||||
|
|
||||||
|
const showEndLine = () =>
|
||||||
|
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
{pages.map((page, visualIndex) => {
|
||||||
|
const selected = isSelected(page.id);
|
||||||
|
const isDraggingCard =
|
||||||
|
draggingIndex != null &&
|
||||||
|
((draggingSelectionActive && selected) ||
|
||||||
|
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageCard
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
visualIndex={visualIndex}
|
||||||
|
thumbnail={thumbnails[page.id]}
|
||||||
|
selected={selected}
|
||||||
|
isDraggingCard={isDraggingCard}
|
||||||
|
isBusy={isBusy}
|
||||||
|
isCopyDragging={isCopyDragging}
|
||||||
|
showLeftLine={showLeftLine(visualIndex)}
|
||||||
|
showRightLine={showRightLine(visualIndex)}
|
||||||
|
dropIndicatorColor={dropIndicatorColor}
|
||||||
|
onDragStart={onDragStart(visualIndex)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onCardDragOver(visualIndex)}
|
||||||
|
onOpenPreview={() => onOpenPreview(page.id)}
|
||||||
|
onToggleSelect={onToggleSelect(page.id, visualIndex)}
|
||||||
|
onRotateClockwise={() => onRotateClockwise(page.id)}
|
||||||
|
onRotateCounterclockwise={() => onRotateCounterclockwise(page.id)}
|
||||||
|
onDelete={() => onDelete(page.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pages.length > 0 && (
|
||||||
|
<div
|
||||||
|
onDragOver={onEndSlotDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
style={{
|
||||||
|
width: "20px",
|
||||||
|
height: "120px",
|
||||||
|
position: "relative",
|
||||||
|
alignSelf: "stretch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showEndLine() && (
|
||||||
|
<DropIndicator side="end" color={dropIndicatorColor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageGrid;
|
||||||
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface PageSelectionToolbarProps {
|
||||||
|
selectedCount: number;
|
||||||
|
onCopySelected: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillButtonStyle: React.CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "0.15rem 0.6rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||||
|
selectedCount,
|
||||||
|
onCopySelected,
|
||||||
|
onDeleteSelected,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
}) => {
|
||||||
|
const hasSelection = selectedCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Selected: <strong>{selectedCount}</strong>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.4rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasSelection && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopySelected}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
style={{
|
||||||
|
...pillButtonStyle,
|
||||||
|
background: "#dcfce7",
|
||||||
|
color: "#166534",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
title="Copy selected pages to another position"
|
||||||
|
>
|
||||||
|
Copy selected
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSelection && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeleteSelected}
|
||||||
|
style={{
|
||||||
|
...pillButtonStyle,
|
||||||
|
background: "#fee2e2",
|
||||||
|
color: "#b91c1c",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete selected
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectAll}
|
||||||
|
style={{
|
||||||
|
...pillButtonStyle,
|
||||||
|
background: "#8dcd8d",
|
||||||
|
color: "#111827",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
style={{
|
||||||
|
...pillButtonStyle,
|
||||||
|
background: "#e5e7eb",
|
||||||
|
color: hasSelection ? "#111827" : "#6b7280",
|
||||||
|
cursor: hasSelection ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageSelectionToolbar;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useRef, useState } from "react";
|
||||||
import type { PageRef } from '../pdf/pdfTypes';
|
import type { PageRef } from "../pdf/pdfTypes";
|
||||||
|
import CopyPagesDialog from "./PageWorkspace/CopyPagesDialog";
|
||||||
|
import PageGrid from "./PageWorkspace/PageGrid";
|
||||||
|
import PageSelectionToolbar from "./PageWorkspace/PageSelectionToolbar";
|
||||||
|
|
||||||
interface ReorderPanelProps {
|
interface ReorderPanelProps {
|
||||||
pages: PageRef[];
|
pages: PageRef[];
|
||||||
@@ -17,7 +20,7 @@ interface ReorderPanelProps {
|
|||||||
onToggleSelect: (
|
onToggleSelect: (
|
||||||
pageId: string,
|
pageId: string,
|
||||||
visualIndex: number,
|
visualIndex: number,
|
||||||
e: React.MouseEvent<HTMLButtonElement>
|
e: React.MouseEvent<HTMLButtonElement>,
|
||||||
) => void;
|
) => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
|
|
||||||
@@ -48,13 +51,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
|
|
||||||
const [isCopyDragging, setIsCopyDragging] = useState(false);
|
const [isCopyDragging, setIsCopyDragging] = useState(false);
|
||||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||||
const [copyTargetPosition, setCopyTargetPosition] = useState('');
|
const [copyTargetPosition, setCopyTargetPosition] = useState("");
|
||||||
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
|
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
|
||||||
|
|
||||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
|
||||||
|
|
||||||
const cleanupDragGhost = () => {
|
const cleanupDragGhost = () => {
|
||||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||||
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||||
@@ -71,7 +72,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
if (!draggedPage) return [];
|
if (!draggedPage) return [];
|
||||||
|
|
||||||
const selectedInVisualOrder = pages.filter((page) =>
|
const selectedInVisualOrder = pages.filter((page) =>
|
||||||
selectedPageIds.includes(page.id)
|
selectedPageIds.includes(page.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const draggingIsSelected =
|
const draggingIsSelected =
|
||||||
@@ -84,20 +85,20 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
const createDragGhost = (e: React.DragEvent, count: number) => {
|
const createDragGhost = (e: React.DragEvent, count: number) => {
|
||||||
cleanupDragGhost();
|
cleanupDragGhost();
|
||||||
|
|
||||||
const ghost = document.createElement('div');
|
const ghost = document.createElement("div");
|
||||||
ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
|
ghost.textContent = count === 1 ? "1 page" : `${count} pages`;
|
||||||
|
|
||||||
ghost.style.position = 'fixed';
|
ghost.style.position = "fixed";
|
||||||
ghost.style.top = '0';
|
ghost.style.top = "0";
|
||||||
ghost.style.left = '0';
|
ghost.style.left = "0";
|
||||||
ghost.style.padding = '4px 8px';
|
ghost.style.padding = "4px 8px";
|
||||||
ghost.style.borderRadius = '999px';
|
ghost.style.borderRadius = "999px";
|
||||||
ghost.style.background = '#111827';
|
ghost.style.background = "#111827";
|
||||||
ghost.style.color = '#e5e7eb';
|
ghost.style.color = "#e5e7eb";
|
||||||
ghost.style.fontSize = '12px';
|
ghost.style.fontSize = "12px";
|
||||||
ghost.style.fontFamily =
|
ghost.style.fontFamily =
|
||||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
ghost.style.zIndex = '9999';
|
ghost.style.zIndex = "9999";
|
||||||
|
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
dragGhostRef.current = ghost;
|
dragGhostRef.current = ghost;
|
||||||
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
cleanupDragGhost();
|
||||||
|
setDraggingIndex(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
setIsCopyDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||||
setDraggingIndex(visualIndex);
|
setDraggingIndex(visualIndex);
|
||||||
setDropIndex(visualIndex);
|
setDropIndex(visualIndex);
|
||||||
@@ -113,19 +121,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
const copying = isCopyModifierPressed(e);
|
const copying = isCopyModifierPressed(e);
|
||||||
setIsCopyDragging(copying);
|
setIsCopyDragging(copying);
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = 'copyMove';
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||||
e.dataTransfer.setData('text/plain', String(visualIndex));
|
e.dataTransfer.setData("text/plain", String(visualIndex));
|
||||||
|
|
||||||
const draggedPages = getDraggedPages(visualIndex);
|
const draggedPages = getDraggedPages(visualIndex);
|
||||||
createDragGhost(e, draggedPages.length);
|
createDragGhost(e, draggedPages.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
cleanupDragGhost();
|
resetDragState();
|
||||||
setDraggingIndex(null);
|
|
||||||
setDropIndex(null);
|
|
||||||
setIsCopyDragging(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||||
@@ -136,7 +141,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
const copying = isCopyModifierPressed(e);
|
const copying = isCopyModifierPressed(e);
|
||||||
setIsCopyDragging(copying);
|
setIsCopyDragging(copying);
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||||
|
|
||||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
@@ -153,7 +158,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
const copying = isCopyModifierPressed(e);
|
const copying = isCopyModifierPressed(e);
|
||||||
setIsCopyDragging(copying);
|
setIsCopyDragging(copying);
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||||
|
|
||||||
setDropIndex(pages.length);
|
setDropIndex(pages.length);
|
||||||
};
|
};
|
||||||
@@ -172,7 +177,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
if (shouldCopy) {
|
if (shouldCopy) {
|
||||||
onCopyPagesToSlot(
|
onCopyPagesToSlot(
|
||||||
draggedPages.map((page) => page.id),
|
draggedPages.map((page) => page.id),
|
||||||
dropIndex
|
dropIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setIsCopyDragging(false);
|
setIsCopyDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (pageId: string) => () => {
|
const handleDeleteClick = (pageId: string) => {
|
||||||
onDelete(pageId);
|
onDelete(pageId);
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRotateClickClockwise = (pageId: string) => () => {
|
|
||||||
onRotateClockwise(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRotateClickCounterclockwise = (pageId: string) => () => {
|
|
||||||
onRotateCounterclockwise(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardClick = (pageId: string) => () => {
|
|
||||||
onOpenPreview(pageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckboxClick =
|
const handleCheckboxClick =
|
||||||
(pageId: string, visualIndex: number) =>
|
(pageId: string, visualIndex: number) =>
|
||||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation(); // don't trigger preview
|
e.stopPropagation();
|
||||||
onToggleSelect(pageId, visualIndex, e);
|
onToggleSelect(pageId, visualIndex, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopySelectedClick = () => {
|
const handleCopySelectedClick = () => {
|
||||||
if (selectedPageIds.length === 0) return;
|
if (selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
|
setCopyTargetPosition(String(pages.length + 1));
|
||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
setCopyDialogOpen(true);
|
setCopyDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -245,11 +238,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyTargetPositionChange = (value: string) => {
|
||||||
|
setCopyTargetPosition(value);
|
||||||
|
setCopyDialogError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (selectedPageIds.length === 0) {
|
if (selectedPageIds.length === 0) {
|
||||||
setCopyDialogError('No pages selected.');
|
setCopyDialogError("No pages selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
setCopyDialogError(null);
|
setCopyDialogError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copyDialogOpen) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCopyDialogCancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [copyDialogOpen]);
|
|
||||||
|
|
||||||
if (!hasPdf) {
|
if (!hasPdf) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -293,554 +274,66 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showLeftLine = (visualIndex: number) =>
|
|
||||||
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
|
||||||
|
|
||||||
const showRightLine = (visualIndex: number) =>
|
|
||||||
dropIndex !== null &&
|
|
||||||
dropIndex === visualIndex + 1 &&
|
|
||||||
draggingIndex !== null;
|
|
||||||
|
|
||||||
const showEndLine = () =>
|
|
||||||
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
|
|
||||||
|
|
||||||
// For highlighting the whole selection while dragging it
|
|
||||||
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
||||||
const draggingSelectionActive =
|
const draggingSelectionActive =
|
||||||
draggingPage != null &&
|
draggingPage != null &&
|
||||||
selectedPageIds.length > 0 &&
|
selectedPageIds.length > 0 &&
|
||||||
selectedPageIds.includes(draggingPage.id);
|
selectedPageIds.includes(draggingPage.id);
|
||||||
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
const dropIndicatorColor = isCopyDragging ? "#16a34a" : "#2563eb";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Pages</h2>
|
<h2>Pages</h2>
|
||||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||||
Tap/click a page to preview it. Use the checkbox to select pages
|
Tap/click a page to preview it. Use the checkbox to select pages
|
||||||
(Shift for ranges). Drag to reorder; dragging a selected page moves the
|
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||||
whole selection. Hold Ctrl/⌘ while dropping to copy instead of move.
|
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||||
Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears
|
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
||||||
selection.
|
Esc clears selection.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<PageSelectionToolbar
|
||||||
style={{
|
selectedCount={selectedPageIds.length}
|
||||||
display: 'flex',
|
onCopySelected={handleCopySelectedClick}
|
||||||
justifyContent: 'space-between',
|
onDeleteSelected={onDeleteSelected}
|
||||||
alignItems: 'center',
|
onSelectAll={onSelectAll}
|
||||||
marginBottom: '0.5rem',
|
onClearSelection={onClearSelection}
|
||||||
fontSize: '0.85rem',
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Selected: <strong>{selectedPageIds.length}</strong>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPageIds.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopySelectedClick}
|
|
||||||
disabled={selectedPageIds.length === 0}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#dcfce7',
|
|
||||||
color: '#166534',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title={'Copy selected pages to another position'}
|
|
||||||
>
|
|
||||||
Copy selected
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{selectedPageIds.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDeleteSelected}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#fee2e2',
|
|
||||||
color: '#b91c1c',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete selected
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelectAll}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#8dcd8d',
|
|
||||||
color: '#111827',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClearSelection}
|
|
||||||
disabled={selectedPageIds.length === 0}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.6rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
|
|
||||||
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<PageGrid
|
||||||
style={{
|
pages={pages}
|
||||||
display: 'flex',
|
thumbnails={thumbnails}
|
||||||
flexWrap: 'wrap',
|
selectedPageIds={selectedPageIds}
|
||||||
gap: '0.5rem',
|
isBusy={isBusy}
|
||||||
alignItems: 'flex-start',
|
draggingIndex={draggingIndex}
|
||||||
marginBottom: '0.75rem',
|
dropIndex={dropIndex}
|
||||||
}}
|
draggingSelectionActive={draggingSelectionActive}
|
||||||
|
isCopyDragging={isCopyDragging}
|
||||||
|
dropIndicatorColor={dropIndicatorColor}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onCardDragOver={handleCardDragOver}
|
||||||
|
onEndSlotDragOver={handleEndSlotDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
onOpenPreview={onOpenPreview}
|
||||||
{pages.map((page, visualIndex) => {
|
onToggleSelect={handleCheckboxClick}
|
||||||
const thumb = thumbnails[page.id];
|
onRotateClockwise={onRotateClockwise}
|
||||||
const rotation = page.rotation;
|
onRotateCounterclockwise={onRotateCounterclockwise}
|
||||||
const selected = isSelected(page.id);
|
onDelete={handleDeleteClick}
|
||||||
|
/>
|
||||||
const isDraggingCard =
|
|
||||||
draggingIndex != null &&
|
|
||||||
((draggingSelectionActive && selected) ||
|
|
||||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={page.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={handleDragStart(visualIndex)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleCardDragOver(visualIndex)}
|
|
||||||
onClick={handleCardClick(page.id)}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '162px',
|
|
||||||
padding: '0.4rem',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
background: isDraggingCard
|
|
||||||
? isCopyDragging
|
|
||||||
? '#dcfce7'
|
|
||||||
: '#dbeafe'
|
|
||||||
: selected
|
|
||||||
? '#eff6ff'
|
|
||||||
: '#f9fafb',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem',
|
|
||||||
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
|
|
||||||
opacity: isBusy ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* selection checkbox */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCheckboxClick(page.id, visualIndex)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '4px',
|
|
||||||
left: '4px',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
borderRadius: '0.4rem',
|
|
||||||
border: '1px solid #9ca3af',
|
|
||||||
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
|
|
||||||
color: selected ? 'white' : 'transparent',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="Select page"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* left drop indicator */}
|
|
||||||
{showLeftLine(visualIndex) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '-4px',
|
|
||||||
top: '4px',
|
|
||||||
bottom: '4px',
|
|
||||||
width: '3px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* right drop indicator */}
|
|
||||||
{showRightLine(visualIndex) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: '-4px',
|
|
||||||
top: '4px',
|
|
||||||
bottom: '4px',
|
|
||||||
width: '3px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '110px',
|
|
||||||
height: '90px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{thumb ? (
|
|
||||||
<img
|
|
||||||
src={thumb}
|
|
||||||
alt={`Page ${page.sourcePageIndex + 1}`}
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
width: 'auto',
|
|
||||||
height: 'auto',
|
|
||||||
objectFit: 'contain',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
background: 'white',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '60px',
|
|
||||||
height: '80px',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
border: '1px dashed #d1d5db',
|
|
||||||
background: '#f3f4f6',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={{ fontSize: '0.8rem' }}>Page {page.sourcePageIndex + 1}</span>
|
|
||||||
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
|
||||||
Pos {visualIndex + 1} · Rot {rotation}°
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.25rem',
|
|
||||||
marginTop: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRotateClickClockwise(page.id)();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.4rem',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↻ 90°
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRotateClickCounterclockwise(page.id)();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.4rem',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↺ 90°
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteClick(page.id)();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '0.15rem 0.4rem',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
background: '#fecaca',
|
|
||||||
color: '#b91c1c',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="Remove this page from the exported PDF"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* end slot for dropping after the last card */}
|
|
||||||
{pages.length > 0 && (
|
|
||||||
<div
|
|
||||||
onDragOver={handleEndSlotDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '120px',
|
|
||||||
position: 'relative',
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showEndLine() && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '8px',
|
|
||||||
top: '4px',
|
|
||||||
bottom: '4px',
|
|
||||||
width: '3px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: dropIndicatorColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{copyDialogOpen && (
|
{copyDialogOpen && (
|
||||||
<div
|
<CopyPagesDialog
|
||||||
role="dialog"
|
selectedCount={selectedPageIds.length}
|
||||||
aria-modal="true"
|
pageCount={pages.length}
|
||||||
aria-labelledby="copy-pages-dialog-title"
|
targetPosition={copyTargetPosition}
|
||||||
onPointerDown={(e) => {
|
error={copyDialogError}
|
||||||
if (e.target === e.currentTarget) {
|
onTargetPositionChange={handleCopyTargetPositionChange}
|
||||||
handleCopyDialogCancel();
|
onCancel={handleCopyDialogCancel}
|
||||||
}
|
onConfirm={handleCopyDialogConfirm}
|
||||||
}}
|
/>
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 60,
|
|
||||||
background: 'rgba(15, 23, 42, 0.55)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
onSubmit={handleCopyDialogConfirm}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '420px',
|
|
||||||
background: 'white',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
|
||||||
padding: '1rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
id="copy-pages-dialog-title"
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy selected pages
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyDialogCancel}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '999px',
|
|
||||||
width: '1.8rem',
|
|
||||||
height: '1.8rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
color: '#111827',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
aria-label="Close copy dialog"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
color: '#4b5563',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy{' '}
|
|
||||||
<strong>
|
|
||||||
{selectedPageIds.length === 1
|
|
||||||
? '1 selected page'
|
|
||||||
: `${selectedPageIds.length} selected pages`}
|
|
||||||
</strong>{' '}
|
|
||||||
to a new position.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Insert before position
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={pages.length + 1}
|
|
||||||
value={copyTargetPosition}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => {
|
|
||||||
setCopyTargetPosition(e.target.value);
|
|
||||||
setCopyDialogError(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '0.45rem 0.55rem',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: '#6b7280',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>1 = before the first page</div>
|
|
||||||
<div>{pages.length + 1} = after the last page</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{copyDialogError && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
background: '#fef2f2',
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
color: '#b91c1c',
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copyDialogError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: '0.5rem',
|
|
||||||
marginTop: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyDialogCancel}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
padding: '0.45rem 0.8rem',
|
|
||||||
background: '#e5e7eb',
|
|
||||||
color: '#111827',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
padding: '0.45rem 0.8rem',
|
|
||||||
background: '#16a34a',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy pages
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,327 +1,327 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import type { WorkspaceSummary } from '../workspace/workspaceTypes';
|
import type { WorkspaceSummary } from "../workspace/workspaceTypes";
|
||||||
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
import type { WorkspaceCommandRecord } from "../workspace/workspaceCommands";
|
||||||
|
|
||||||
interface WorkspacePanelProps {
|
interface WorkspacePanelProps {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
|
||||||
activeWorkspaceId: string | null;
|
activeWorkspaceId: string | null;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
workspaceDirty: boolean;
|
workspaceDirty: boolean;
|
||||||
workspaceMessage: string | null;
|
workspaceMessage: string | null;
|
||||||
|
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
history: WorkspaceCommandRecord[];
|
history: WorkspaceCommandRecord[];
|
||||||
redoHistory: WorkspaceCommandRecord[];
|
redoHistory: WorkspaceCommandRecord[];
|
||||||
|
|
||||||
onWorkspaceNameChange: (value: string) => void;
|
onWorkspaceNameChange: (value: string) => void;
|
||||||
onSaveWorkspace: () => void;
|
onSaveWorkspace: () => void;
|
||||||
onLoadWorkspace: (workspaceId: string) => void;
|
onLoadWorkspace: (workspaceId: string) => void;
|
||||||
onDeleteWorkspace: (workspaceId: string) => void;
|
onDeleteWorkspace: (workspaceId: string) => void;
|
||||||
onRefreshWorkspaces: () => void;
|
onRefreshWorkspaces: () => void;
|
||||||
onResetWorkspace: () => void;
|
onResetWorkspace: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||||
hasPdf,
|
hasPdf,
|
||||||
isBusy,
|
isBusy,
|
||||||
activeWorkspaceId,
|
activeWorkspaceId,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
workspaceDirty,
|
workspaceDirty,
|
||||||
workspaceMessage,
|
workspaceMessage,
|
||||||
workspaces,
|
workspaces,
|
||||||
history,
|
history,
|
||||||
redoHistory,
|
redoHistory,
|
||||||
onWorkspaceNameChange,
|
onWorkspaceNameChange,
|
||||||
onSaveWorkspace,
|
onSaveWorkspace,
|
||||||
onLoadWorkspace,
|
onLoadWorkspace,
|
||||||
onDeleteWorkspace,
|
onDeleteWorkspace,
|
||||||
onRefreshWorkspaces,
|
onRefreshWorkspaces,
|
||||||
onResetWorkspace,
|
onResetWorkspace,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
}) => {
|
}) => {
|
||||||
const canUndo = history.length > 0;
|
const canUndo = history.length > 0;
|
||||||
const canRedo = redoHistory.length > 0;
|
const canRedo = redoHistory.length > 0;
|
||||||
|
|
||||||
const latestUndo = history[history.length - 1];
|
const latestUndo = history[history.length - 1];
|
||||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Workspace</h2>
|
<h2>Workspace</h2>
|
||||||
|
|
||||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||||
Save named workspaces in this browser. PDF binaries are stored in
|
Save named workspaces in this browser. PDF binaries are stored in
|
||||||
IndexedDB; nothing is uploaded.
|
IndexedDB; nothing is uploaded.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workspaceName}
|
||||||
|
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
||||||
|
placeholder="Workspace name"
|
||||||
|
disabled={!hasPdf || isBusy}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 220px",
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "0.45rem 0.55rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!hasPdf || isBusy || !canUndo}
|
||||||
|
title={latestUndo ? `Undo: ${latestUndo.label}` : "Nothing to undo"}
|
||||||
|
>
|
||||||
|
↶ Undo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!hasPdf || isBusy || !canRedo}
|
||||||
|
title={latestRedo ? `Redo: ${latestRedo.label}` : "Nothing to redo"}
|
||||||
|
>
|
||||||
|
↷ Redo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={onSaveWorkspace}
|
||||||
|
disabled={!hasPdf || isBusy}
|
||||||
|
title={!hasPdf ? "Open a PDF first" : "Save workspace"}
|
||||||
|
>
|
||||||
|
💾 {activeWorkspaceId ? "Save" : "Save as"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={onResetWorkspace}
|
||||||
|
disabled={!hasPdf || isBusy}
|
||||||
|
title={
|
||||||
|
!hasPdf ? "No active workspace" : "Close the current workspace"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset workspace
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={onRefreshWorkspaces}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaceDirty && hasPdf && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#92400e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unsaved workspace changes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceMessage && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "#166534",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspaceMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaces.length > 0 && (
|
||||||
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
|
<strong style={{ fontSize: "0.9rem" }}>Saved workspaces</strong>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.4rem",
|
||||||
|
marginTop: "0.4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspaces.map((workspace) => {
|
||||||
|
const active = workspace.id === activeWorkspaceId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={workspace.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
padding: "0.5rem",
|
||||||
|
background: active ? "#eff6ff" : "#f9fafb",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: "0.9rem" }}>
|
||||||
|
<strong>{workspace.name}</strong>
|
||||||
|
{active && (
|
||||||
|
<span style={{ color: "#2563eb" }}> · active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
||||||
|
{workspace.pdfName} · source pages:{" "}
|
||||||
|
{workspace.sourcePageCount} · workspace pages:{" "}
|
||||||
|
{workspace.workspacePageCount} · undo:{" "}
|
||||||
|
{workspace.historyCount} · redo: {workspace.redoCount} ·
|
||||||
|
updated {new Date(workspace.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.35rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onLoadWorkspace(workspace.id)}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||||
|
style={{
|
||||||
|
background: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(history.length > 0 || redoHistory.length > 0) && (
|
||||||
|
<details style={{ marginTop: "0.75rem" }} open>
|
||||||
|
<summary style={{ cursor: "pointer", fontSize: "0.9rem" }}>
|
||||||
|
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{history.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#374151",
|
||||||
|
borderLeft: "3px solid #2563eb",
|
||||||
|
paddingLeft: "0.45rem",
|
||||||
|
paddingTop: "0.2rem",
|
||||||
|
paddingBottom: "0.2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
Undo {history.length - index}. {entry.label}
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: "#6b7280" }}>
|
||||||
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
margin: "0.25rem 0",
|
||||||
gap: '0.5rem',
|
borderRadius: "999px",
|
||||||
flexWrap: 'wrap',
|
background: "#ecfdf5",
|
||||||
alignItems: 'center',
|
color: "#166534",
|
||||||
}}
|
fontSize: "0.8rem",
|
||||||
>
|
fontWeight: 600,
|
||||||
<input
|
alignSelf: "flex-start",
|
||||||
type="text"
|
border: "2px solid #166534",
|
||||||
value={workspaceName}
|
width: "100%",
|
||||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
}}
|
||||||
placeholder="Workspace name"
|
></div>
|
||||||
disabled={!hasPdf || isBusy}
|
|
||||||
style={{
|
|
||||||
flex: '1 1 220px',
|
|
||||||
minWidth: 0,
|
|
||||||
padding: '0.45rem 0.55rem',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
{redoHistory
|
||||||
type="button"
|
.slice()
|
||||||
className="secondary"
|
.reverse()
|
||||||
onClick={onUndo}
|
.map((entry, index) => (
|
||||||
disabled={!hasPdf || isBusy || !canUndo}
|
|
||||||
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
|
|
||||||
>
|
|
||||||
↶ Undo
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={onRedo}
|
|
||||||
disabled={!hasPdf || isBusy || !canRedo}
|
|
||||||
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
|
|
||||||
>
|
|
||||||
↷ Redo
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={onSaveWorkspace}
|
|
||||||
disabled={!hasPdf || isBusy}
|
|
||||||
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
|
|
||||||
>
|
|
||||||
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={onResetWorkspace}
|
|
||||||
disabled={!hasPdf || isBusy}
|
|
||||||
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'}
|
|
||||||
>
|
|
||||||
Reset workspace
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={onRefreshWorkspaces}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
↻ Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{workspaceDirty && hasPdf && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
key={entry.id}
|
||||||
marginTop: '0.5rem',
|
style={{
|
||||||
fontSize: '0.8rem',
|
fontSize: "0.8rem",
|
||||||
color: '#92400e',
|
color: "#9ca3af",
|
||||||
}}
|
borderLeft: "3px solid #d1d5db",
|
||||||
|
paddingLeft: "0.45rem",
|
||||||
|
paddingTop: "0.2rem",
|
||||||
|
paddingBottom: "0.2rem",
|
||||||
|
opacity: 0.75,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Unsaved workspace changes.
|
<strong>
|
||||||
|
Redo {index + 1}. {entry.label}
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: "#9ca3af" }}>
|
||||||
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
{workspaceMessage && (
|
</details>
|
||||||
<div
|
)}
|
||||||
style={{
|
</div>
|
||||||
marginTop: '0.5rem',
|
);
|
||||||
fontSize: '0.85rem',
|
|
||||||
color: '#166534',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{workspaceMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{workspaces.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
|
||||||
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.4rem',
|
|
||||||
marginTop: '0.4rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{workspaces.map((workspace) => {
|
|
||||||
const active = workspace.id === activeWorkspaceId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={workspace.id}
|
|
||||||
style={{
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
background: active ? '#eff6ff' : '#f9fafb',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '0.75rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: '0.9rem' }}>
|
|
||||||
<strong>{workspace.name}</strong>
|
|
||||||
{active && (
|
|
||||||
<span style={{ color: '#2563eb' }}> · active</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
|
|
||||||
{workspace.pdfName} · source pages:{' '}
|
|
||||||
{workspace.sourcePageCount} · workspace pages:{' '}
|
|
||||||
{workspace.workspacePageCount} · undo:{' '}
|
|
||||||
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
|
|
||||||
{new Date(workspace.updatedAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.35rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => onLoadWorkspace(workspace.id)}
|
|
||||||
>
|
|
||||||
Load
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
|
||||||
style={{
|
|
||||||
background: '#fee2e2',
|
|
||||||
color: '#991b1b',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(history.length > 0 || redoHistory.length > 0) && (
|
|
||||||
<details style={{ marginTop: '0.75rem' }} open>
|
|
||||||
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
|
|
||||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{history.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: '#374151',
|
|
||||||
borderLeft: '3px solid #2563eb',
|
|
||||||
paddingLeft: '0.45rem',
|
|
||||||
paddingTop: '0.2rem',
|
|
||||||
paddingBottom: '0.2rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>
|
|
||||||
Undo {history.length - index}. {entry.label}
|
|
||||||
</strong>
|
|
||||||
<br />
|
|
||||||
<span style={{ color: '#6b7280' }}>
|
|
||||||
{new Date(entry.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0.25rem 0',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: '#ecfdf5',
|
|
||||||
color: '#166534',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
border: '2px solid #166534',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{redoHistory
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: '#9ca3af',
|
|
||||||
borderLeft: '3px solid #d1d5db',
|
|
||||||
paddingLeft: '0.45rem',
|
|
||||||
paddingTop: '0.2rem',
|
|
||||||
paddingBottom: '0.2rem',
|
|
||||||
opacity: 0.75,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>
|
|
||||||
Redo {index + 1}. {entry.label}
|
|
||||||
</strong>
|
|
||||||
<br />
|
|
||||||
<span style={{ color: '#9ca3af' }}>
|
|
||||||
{new Date(entry.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkspacePanel;
|
export default WorkspacePanel;
|
||||||
|
|||||||
128
src/hooks/usePdfGeneratedOutputs.ts
Normal file
128
src/hooks/usePdfGeneratedOutputs.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { SplitResult } from "../pdf/pdfTypes";
|
||||||
|
|
||||||
|
export interface PdfDownload {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitPdfDownload extends PdfDownload {
|
||||||
|
pageIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeDownload(download: PdfDownload | null): void {
|
||||||
|
if (download) {
|
||||||
|
URL.revokeObjectURL(download.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeDownloads(downloads: PdfDownload[]): void {
|
||||||
|
downloads.forEach(revokeDownload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDownload(id: string, filename: string, blob: Blob): PdfDownload {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePdfGeneratedOutputs() {
|
||||||
|
const [splitDownloads, setSplitDownloads] = useState<SplitPdfDownload[]>([]);
|
||||||
|
const [subsetDownload, setSubsetDownload] = useState<PdfDownload | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
|
||||||
|
const subsetDownloadRef = useRef<PdfDownload | null>(null);
|
||||||
|
const exportDownloadRef = useRef<PdfDownload | null>(null);
|
||||||
|
|
||||||
|
const replaceSplitResults = useCallback((results: SplitResult[]) => {
|
||||||
|
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
|
||||||
|
...createDownload(
|
||||||
|
`split-${result.pageIndex}-${result.filename}`,
|
||||||
|
result.filename,
|
||||||
|
result.blob,
|
||||||
|
),
|
||||||
|
pageIndex: result.pageIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
splitDownloadsRef.current = nextDownloads;
|
||||||
|
setSplitDownloads(nextDownloads);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSplitResults = useCallback(() => {
|
||||||
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
splitDownloadsRef.current = [];
|
||||||
|
setSplitDownloads([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
|
||||||
|
const nextDownload = createDownload("subset", filename, blob);
|
||||||
|
|
||||||
|
revokeDownload(subsetDownloadRef.current);
|
||||||
|
subsetDownloadRef.current = nextDownload;
|
||||||
|
setSubsetDownload(nextDownload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSubsetResult = useCallback(() => {
|
||||||
|
revokeDownload(subsetDownloadRef.current);
|
||||||
|
subsetDownloadRef.current = null;
|
||||||
|
setSubsetDownload(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceExportResult = useCallback((blob: Blob, filename: string) => {
|
||||||
|
const nextDownload = createDownload("export", filename, blob);
|
||||||
|
|
||||||
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
exportDownloadRef.current = nextDownload;
|
||||||
|
setExportDownload(nextDownload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearExportResult = useCallback(() => {
|
||||||
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
exportDownloadRef.current = null;
|
||||||
|
setExportDownload(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllResults = useCallback(() => {
|
||||||
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
revokeDownload(subsetDownloadRef.current);
|
||||||
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
|
||||||
|
splitDownloadsRef.current = [];
|
||||||
|
subsetDownloadRef.current = null;
|
||||||
|
exportDownloadRef.current = null;
|
||||||
|
|
||||||
|
setSplitDownloads([]);
|
||||||
|
setSubsetDownload(null);
|
||||||
|
setExportDownload(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
revokeDownloads(splitDownloadsRef.current);
|
||||||
|
revokeDownload(subsetDownloadRef.current);
|
||||||
|
revokeDownload(exportDownloadRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
splitDownloads,
|
||||||
|
subsetDownload,
|
||||||
|
exportDownload,
|
||||||
|
replaceSplitResults,
|
||||||
|
clearSplitResults,
|
||||||
|
replaceSubsetResult,
|
||||||
|
clearSubsetResult,
|
||||||
|
replaceExportResult,
|
||||||
|
clearExportResult,
|
||||||
|
clearAllResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/main.tsx
12
src/main.tsx
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App';
|
import App from "./App";
|
||||||
import './styles.css';
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { PDFDocument, degrees } from 'pdf-lib';
|
import { PDFDocument, degrees } from "pdf-lib";
|
||||||
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
|
import type { PdfFile, PageRef, SplitResult, Range } from "./pdfTypes";
|
||||||
|
|
||||||
function createId() {
|
function createId() {
|
||||||
return Math.random().toString(36).slice(2);
|
return Math.random().toString(36).slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(buffer).set(bytes);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfBytesToBlob(bytes: Uint8Array): Blob {
|
||||||
|
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: "application/pdf" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const doc = await PDFDocument.load(arrayBuffer);
|
const doc = await PDFDocument.load(arrayBuffer);
|
||||||
@@ -21,10 +31,10 @@ export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
|||||||
export async function mergePdfFiles(
|
export async function mergePdfFiles(
|
||||||
basePdf: PdfFile,
|
basePdf: PdfFile,
|
||||||
newPdf: PdfFile,
|
newPdf: PdfFile,
|
||||||
insertAt: number
|
insertAt: number,
|
||||||
): Promise<PdfFile> {
|
): Promise<PdfFile> {
|
||||||
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
|
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||||
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
|
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
|
||||||
|
|
||||||
const mergedDoc = await PDFDocument.create();
|
const mergedDoc = await PDFDocument.create();
|
||||||
|
|
||||||
@@ -35,11 +45,11 @@ export async function mergePdfFiles(
|
|||||||
|
|
||||||
const basePages = await mergedDoc.copyPages(
|
const basePages = await mergedDoc.copyPages(
|
||||||
baseDoc,
|
baseDoc,
|
||||||
Array.from({ length: basePageCount }, (_, i) => i)
|
Array.from({ length: basePageCount }, (_, i) => i),
|
||||||
);
|
);
|
||||||
const newPages = await mergedDoc.copyPages(
|
const newPages = await mergedDoc.copyPages(
|
||||||
newDoc,
|
newDoc,
|
||||||
Array.from({ length: newPageCount }, (_, i) => i)
|
Array.from({ length: newPageCount }, (_, i) => i),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < clampedInsertAt; i += 1) {
|
for (let i = 0; i < clampedInsertAt; i += 1) {
|
||||||
@@ -53,11 +63,10 @@ export async function mergePdfFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await mergedDoc.save();
|
const bytes = await mergedDoc.save();
|
||||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||||
new Uint8Array(buffer).set(bytes);
|
|
||||||
|
|
||||||
const baseName = basePdf.name.replace(/\.pdf$/i, '');
|
const baseName = basePdf.name.replace(/\.pdf$/i, "");
|
||||||
const newName = newPdf.name.replace(/\.pdf$/i, '');
|
const newName = newPdf.name.replace(/\.pdf$/i, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -69,7 +78,7 @@ export async function mergePdfFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function splitIntoSinglePages(
|
export async function splitIntoSinglePages(
|
||||||
pdf: PdfFile
|
pdf: PdfFile,
|
||||||
): Promise<SplitResult[]> {
|
): Promise<SplitResult[]> {
|
||||||
const { doc, name } = pdf;
|
const { doc, name } = pdf;
|
||||||
|
|
||||||
@@ -99,10 +108,10 @@ export async function splitIntoSinglePages(
|
|||||||
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
const blob = pdfBytesToBlob(bytes);
|
||||||
|
|
||||||
const base = name.replace(/\.pdf$/i, '');
|
const base = name.replace(/\.pdf$/i, "");
|
||||||
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
const filename = `${base}_page_${String(i + 1).padStart(3, "0")}.pdf`;
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
pageIndex: i,
|
pageIndex: i,
|
||||||
@@ -114,10 +123,7 @@ export async function splitIntoSinglePages(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractRange(
|
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
|
||||||
pdf: PdfFile,
|
|
||||||
range: Range
|
|
||||||
): Promise<Blob> {
|
|
||||||
const { doc } = pdf;
|
const { doc } = pdf;
|
||||||
const pageCount = doc.getPageCount();
|
const pageCount = doc.getPageCount();
|
||||||
|
|
||||||
@@ -125,7 +131,7 @@ export async function extractRange(
|
|||||||
const toIndex = Math.min(pageCount - 1, range.to - 1);
|
const toIndex = Math.min(pageCount - 1, range.to - 1);
|
||||||
|
|
||||||
if (fromIndex > toIndex) {
|
if (fromIndex > toIndex) {
|
||||||
throw new Error('Invalid range: from > to');
|
throw new Error("Invalid range: from > to");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDoc = await PDFDocument.create();
|
const newDoc = await PDFDocument.create();
|
||||||
@@ -136,7 +142,7 @@ export async function extractRange(
|
|||||||
copiedPages.forEach((p) => newDoc.addPage(p));
|
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||||
@@ -150,26 +156,26 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPages(
|
export async function exportPages(
|
||||||
pdf: PdfFile,
|
pdf: PdfFile,
|
||||||
pages: PageRef[]
|
pages: PageRef[],
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const { doc } = pdf;
|
const { doc } = pdf;
|
||||||
const pageCount = doc.getPageCount();
|
const pageCount = doc.getPageCount();
|
||||||
|
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
throw new Error('Pages must contain at least one page');
|
throw new Error("Pages must contain at least one page");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pages.some(
|
pages.some(
|
||||||
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount
|
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error('Pages contain invalid source page indices');
|
throw new Error("Pages contain invalid source page indices");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDoc = await PDFDocument.create();
|
const newDoc = await PDFDocument.create();
|
||||||
@@ -180,7 +186,7 @@ export async function exportPages(
|
|||||||
copiedPages.forEach((page, idx) => {
|
copiedPages.forEach((page, idx) => {
|
||||||
const angle = pages[idx].rotation;
|
const angle = pages[idx].rotation;
|
||||||
|
|
||||||
if (typeof angle === 'number' && angle % 360 !== 0) {
|
if (typeof angle === "number" && angle % 360 !== 0) {
|
||||||
page.setRotation(degrees(angle));
|
page.setRotation(degrees(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +194,13 @@ export async function exportPages(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bytes = await newDoc.save();
|
const bytes = await newDoc.save();
|
||||||
return new Blob([bytes], { type: 'application/pdf' });
|
return pdfBytesToBlob(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportReordered(
|
export async function exportReordered(
|
||||||
pdf: PdfFile,
|
pdf: PdfFile,
|
||||||
order: number[],
|
order: number[],
|
||||||
rotations?: Record<number, number>
|
rotations?: Record<number, number>,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
return exportPages(
|
return exportPages(
|
||||||
pdf,
|
pdf,
|
||||||
@@ -202,6 +208,6 @@ export async function exportReordered(
|
|||||||
id: String(sourcePageIndex),
|
id: String(sourcePageIndex),
|
||||||
sourcePageIndex,
|
sourcePageIndex,
|
||||||
rotation: rotations?.[sourcePageIndex] ?? 0,
|
rotation: rotations?.[sourcePageIndex] ?? 0,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
|
||||||
|
|
||||||
// pdf.js worker setup for Vite
|
// pdf.js worker setup for Vite
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -37,13 +37,12 @@ interface ThumbnailGenerationOptions {
|
|||||||
onThumbnail?: (update: ThumbnailUpdate) => void;
|
onThumbnail?: (update: ThumbnailUpdate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
||||||
*/
|
*/
|
||||||
export async function generateThumbnailsProgressive(
|
export async function generateThumbnailsProgressive(
|
||||||
arrayBuffer: ArrayBuffer,
|
arrayBuffer: ArrayBuffer,
|
||||||
options: ThumbnailGenerationOptions = {}
|
options: ThumbnailGenerationOptions = {},
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
return generateThumbnailsInternal(arrayBuffer, {}, options);
|
return generateThumbnailsInternal(arrayBuffer, {}, options);
|
||||||
}
|
}
|
||||||
@@ -54,7 +53,7 @@ export async function generateThumbnailsProgressive(
|
|||||||
export async function generateThumbnailsWithRotationsProgressive(
|
export async function generateThumbnailsWithRotationsProgressive(
|
||||||
arrayBuffer: ArrayBuffer,
|
arrayBuffer: ArrayBuffer,
|
||||||
rotations: RotationsMap,
|
rotations: RotationsMap,
|
||||||
options: ThumbnailGenerationOptions = {}
|
options: ThumbnailGenerationOptions = {},
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
return generateThumbnailsInternal(arrayBuffer, rotations, options);
|
return generateThumbnailsInternal(arrayBuffer, rotations, options);
|
||||||
}
|
}
|
||||||
@@ -62,7 +61,7 @@ export async function generateThumbnailsWithRotationsProgressive(
|
|||||||
async function generateThumbnailsInternal(
|
async function generateThumbnailsInternal(
|
||||||
arrayBuffer: ArrayBuffer,
|
arrayBuffer: ArrayBuffer,
|
||||||
rotations: RotationsMap,
|
rotations: RotationsMap,
|
||||||
options: ThumbnailGenerationOptions = {}
|
options: ThumbnailGenerationOptions = {},
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const maxHeight = options.maxHeight ?? 150;
|
const maxHeight = options.maxHeight ?? 150;
|
||||||
const maxWidth = options.maxWidth ?? 140;
|
const maxWidth = options.maxWidth ?? 140;
|
||||||
@@ -73,15 +72,15 @@ async function generateThumbnailsInternal(
|
|||||||
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
|
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
|
||||||
const pdf = await loadingTask.promise;
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
const thumbs = Array<string>(pdf.numPages).fill('');
|
const thumbs = Array<string>(pdf.numPages).fill("");
|
||||||
|
|
||||||
const pageNums = options.pageIndices
|
const pageNums = options.pageIndices
|
||||||
? Array.from(
|
? Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
options.pageIndices
|
options.pageIndices
|
||||||
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
|
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
|
||||||
.map((pageIndex) => pageIndex + 1)
|
.map((pageIndex) => pageIndex + 1),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
|
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ async function generateThumbnailsInternal(
|
|||||||
pageIndex,
|
pageIndex,
|
||||||
rotations,
|
rotations,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
maxWidth
|
maxWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
@@ -112,7 +111,7 @@ async function generateThumbnailsInternal(
|
|||||||
while (!signal?.aborted) {
|
while (!signal?.aborted) {
|
||||||
const pageNum = pageNums[nextPageIndex];
|
const pageNum = pageNums[nextPageIndex];
|
||||||
nextPageIndex += 1;
|
nextPageIndex += 1;
|
||||||
|
|
||||||
if (pageNum == null) return;
|
if (pageNum == null) return;
|
||||||
|
|
||||||
await renderOne(pageNum);
|
await renderOne(pageNum);
|
||||||
@@ -133,11 +132,15 @@ async function generateThumbnailsInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderPageThumbnail(
|
async function renderPageThumbnail(
|
||||||
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
|
page: Awaited<
|
||||||
|
ReturnType<
|
||||||
|
Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>["getPage"]
|
||||||
|
>
|
||||||
|
>,
|
||||||
originalIndex: number,
|
originalIndex: number,
|
||||||
rotations: RotationsMap,
|
rotations: RotationsMap,
|
||||||
maxHeight: number,
|
maxHeight: number,
|
||||||
maxWidth: number
|
maxWidth: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const viewport = page.getViewport({ scale: 1 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
const scaleH = maxHeight / viewport.height;
|
const scaleH = maxHeight / viewport.height;
|
||||||
@@ -145,10 +148,10 @@ async function renderPageThumbnail(
|
|||||||
const scale = Math.min(scaleH, scaleW);
|
const scale = Math.min(scaleH, scaleW);
|
||||||
const scaledViewport = page.getViewport({ scale });
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
|
||||||
const baseCanvas = document.createElement('canvas');
|
const baseCanvas = document.createElement("canvas");
|
||||||
const baseCtx = baseCanvas.getContext('2d');
|
const baseCtx = baseCanvas.getContext("2d");
|
||||||
|
|
||||||
if (!baseCtx) return '';
|
if (!baseCtx) return "";
|
||||||
|
|
||||||
baseCanvas.width = scaledViewport.width;
|
baseCanvas.width = scaledViewport.width;
|
||||||
baseCanvas.height = scaledViewport.height;
|
baseCanvas.height = scaledViewport.height;
|
||||||
@@ -164,14 +167,14 @@ async function renderPageThumbnail(
|
|||||||
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
|
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
|
||||||
|
|
||||||
if (rotationDeg === 0) {
|
if (rotationDeg === 0) {
|
||||||
return baseCanvas.toDataURL('image/png');
|
return baseCanvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotatedCanvas = document.createElement('canvas');
|
const rotatedCanvas = document.createElement("canvas");
|
||||||
const rotatedCtx = rotatedCanvas.getContext('2d');
|
const rotatedCtx = rotatedCanvas.getContext("2d");
|
||||||
|
|
||||||
if (!rotatedCtx) {
|
if (!rotatedCtx) {
|
||||||
return baseCanvas.toDataURL('image/png');
|
return baseCanvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rad = (rotationDeg * Math.PI) / 180;
|
const rad = (rotationDeg * Math.PI) / 180;
|
||||||
@@ -204,5 +207,5 @@ async function renderPageThumbnail(
|
|||||||
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
||||||
rotatedCtx.restore();
|
rotatedCtx.restore();
|
||||||
|
|
||||||
return rotatedCanvas.toDataURL('image/png');
|
return rotatedCanvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PDFDocument } from 'pdf-lib';
|
import type { PDFDocument } from "pdf-lib";
|
||||||
|
|
||||||
export interface PdfFile {
|
export interface PdfFile {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
212
src/pdf/usePdfThumbnails.ts
Normal file
212
src/pdf/usePdfThumbnails.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { PageRef, PdfFile } from "./pdfTypes";
|
||||||
|
import { generateThumbnailsWithRotationsProgressive } from "./pdfThumbnailService";
|
||||||
|
import { normalizeRotation } from "../workspace/useWorkspaceState";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_HEIGHT = 150;
|
||||||
|
const DEFAULT_MAX_WIDTH = 140;
|
||||||
|
const DEFAULT_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
interface UsePdfThumbnailsOptions {
|
||||||
|
pdf: PdfFile | null;
|
||||||
|
pages: PageRef[];
|
||||||
|
maxHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
concurrency?: number;
|
||||||
|
onError?: (message: string, error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePdfThumbnailsResult {
|
||||||
|
thumbnails: Record<string, string>;
|
||||||
|
resetThumbnails: () => void;
|
||||||
|
clearThumbnailCache: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailCacheKey(
|
||||||
|
pdfId: string,
|
||||||
|
sourcePageIndex: number,
|
||||||
|
rotation: number,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
pdfId,
|
||||||
|
sourcePageIndex,
|
||||||
|
normalizeRotation(rotation),
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneAndMergeThumbnails(
|
||||||
|
previous: Record<string, string>,
|
||||||
|
pages: PageRef[],
|
||||||
|
updates: Record<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const pageIds = new Set(pages.map((page) => page.id));
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [pageId, thumbnail] of Object.entries(previous)) {
|
||||||
|
if (pageIds.has(pageId)) {
|
||||||
|
next[pageId] = thumbnail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePdfThumbnails({
|
||||||
|
pdf,
|
||||||
|
pages,
|
||||||
|
maxHeight = DEFAULT_MAX_HEIGHT,
|
||||||
|
maxWidth = DEFAULT_MAX_WIDTH,
|
||||||
|
concurrency = DEFAULT_CONCURRENCY,
|
||||||
|
onError,
|
||||||
|
}: UsePdfThumbnailsOptions): UsePdfThumbnailsResult {
|
||||||
|
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||||
|
const thumbnailCacheRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const latestPagesRef = useRef<PageRef[]>(pages);
|
||||||
|
const previousPdfIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestPagesRef.current = pages;
|
||||||
|
}, [pages]);
|
||||||
|
|
||||||
|
const resetThumbnails = useCallback(() => {
|
||||||
|
setThumbnails({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearThumbnailCache = useCallback(() => {
|
||||||
|
thumbnailCacheRef.current.clear();
|
||||||
|
setThumbnails({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPdfId = pdf?.id ?? null;
|
||||||
|
|
||||||
|
if (!pdf) {
|
||||||
|
previousPdfIdRef.current = null;
|
||||||
|
clearThumbnailCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousPdfIdRef.current !== currentPdfId) {
|
||||||
|
previousPdfIdRef.current = currentPdfId;
|
||||||
|
clearThumbnailCache();
|
||||||
|
}
|
||||||
|
}, [clearThumbnailCache, pdf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pdf) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cachedUpdates: Record<string, string> = {};
|
||||||
|
const renderGroups = new Map<number, Set<number>>();
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const rotation = normalizeRotation(page.rotation);
|
||||||
|
const cacheKey = thumbnailCacheKey(
|
||||||
|
pdf.id,
|
||||||
|
page.sourcePageIndex,
|
||||||
|
rotation,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
);
|
||||||
|
const cached = thumbnailCacheRef.current.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
cachedUpdates[page.id] = cached;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIndices = renderGroups.get(rotation) ?? new Set<number>();
|
||||||
|
pageIndices.add(page.sourcePageIndex);
|
||||||
|
renderGroups.set(rotation, pageIndices);
|
||||||
|
}
|
||||||
|
|
||||||
|
setThumbnails((previous) =>
|
||||||
|
pruneAndMergeThumbnails(previous, pages, cachedUpdates),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderGroups.size === 0) return;
|
||||||
|
|
||||||
|
const renderMissingThumbnails = async () => {
|
||||||
|
for (const [rotation, pageIndexSet] of renderGroups) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const pageIndices = Array.from(pageIndexSet);
|
||||||
|
const rotationsBySourcePage: Record<number, number> = {};
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndices) {
|
||||||
|
rotationsBySourcePage[pageIndex] = rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateThumbnailsWithRotationsProgressive(
|
||||||
|
pdf.arrayBuffer,
|
||||||
|
rotationsBySourcePage,
|
||||||
|
{
|
||||||
|
maxHeight,
|
||||||
|
maxWidth,
|
||||||
|
concurrency: Math.min(concurrency, pageIndices.length),
|
||||||
|
pageIndices,
|
||||||
|
signal: controller.signal,
|
||||||
|
onThumbnail: ({ pageIndex, dataUrl }) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
thumbnailCacheRef.current.set(
|
||||||
|
thumbnailCacheKey(
|
||||||
|
pdf.id,
|
||||||
|
pageIndex,
|
||||||
|
rotation,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
),
|
||||||
|
dataUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const page of latestPagesRef.current) {
|
||||||
|
if (
|
||||||
|
page.sourcePageIndex === pageIndex &&
|
||||||
|
normalizeRotation(page.rotation) === rotation
|
||||||
|
) {
|
||||||
|
updates[page.id] = dataUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
|
||||||
|
setThumbnails((previous) =>
|
||||||
|
pruneAndMergeThumbnails(
|
||||||
|
previous,
|
||||||
|
latestPagesRef.current,
|
||||||
|
updates,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void renderMissingThumbnails().catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
onError?.("Failed to generate thumbnails (see console).", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [concurrency, maxHeight, maxWidth, onError, pages, pdf]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnails,
|
||||||
|
resetThumbnails,
|
||||||
|
clearThumbnailCache,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '0.2.0';
|
export const APP_VERSION = "0.2.1";
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
208
src/workspace/useWorkspaceState.test.tsx
Normal file
208
src/workspace/useWorkspaceState.test.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { forwardRef, useImperativeHandle } from "react";
|
||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PageRef } from "../pdf/pdfTypes";
|
||||||
|
import type {
|
||||||
|
WorkspaceCommandRecord,
|
||||||
|
WorkspaceCommandState,
|
||||||
|
} from "./workspaceCommands";
|
||||||
|
import { useWorkspaceState } from "./useWorkspaceState";
|
||||||
|
|
||||||
|
function page(id: string, sourcePageIndex: number, rotation = 0): PageRef {
|
||||||
|
return { id, sourcePageIndex, rotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(
|
||||||
|
pages: PageRef[],
|
||||||
|
selectedPageIds: string[] = [],
|
||||||
|
lastSelectedVisualIndex: number | null = null,
|
||||||
|
): WorkspaceCommandState {
|
||||||
|
return { pages, selectedPageIds, lastSelectedVisualIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HarnessRef {
|
||||||
|
snapshot: () => {
|
||||||
|
pages: PageRef[];
|
||||||
|
selectedPageIds: string[];
|
||||||
|
lastSelectedVisualIndex: number | null;
|
||||||
|
workspaceDirty: boolean;
|
||||||
|
workspaceMessage: string | null;
|
||||||
|
workspaceHistory: WorkspaceCommandRecord[];
|
||||||
|
redoHistory: WorkspaceCommandRecord[];
|
||||||
|
};
|
||||||
|
replaceWorkspaceState: ReturnType<
|
||||||
|
typeof useWorkspaceState
|
||||||
|
>["replaceWorkspaceState"];
|
||||||
|
getCurrentCommandState: ReturnType<
|
||||||
|
typeof useWorkspaceState
|
||||||
|
>["getCurrentCommandState"];
|
||||||
|
createWorkspaceCommand: ReturnType<
|
||||||
|
typeof useWorkspaceState
|
||||||
|
>["createWorkspaceCommand"];
|
||||||
|
executeWorkspaceCommand: ReturnType<
|
||||||
|
typeof useWorkspaceState
|
||||||
|
>["executeWorkspaceCommand"];
|
||||||
|
handleUndo: ReturnType<typeof useWorkspaceState>["handleUndo"];
|
||||||
|
handleRedo: ReturnType<typeof useWorkspaceState>["handleRedo"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Harness = forwardRef<HarnessRef, { onContentChanged?: () => void }>(
|
||||||
|
({ onContentChanged }, ref) => {
|
||||||
|
const workspace = useWorkspaceState({ onContentChanged });
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
snapshot: () => ({
|
||||||
|
pages: workspace.pages,
|
||||||
|
selectedPageIds: workspace.selectedPageIds,
|
||||||
|
lastSelectedVisualIndex: workspace.lastSelectedVisualIndex,
|
||||||
|
workspaceDirty: workspace.workspaceDirty,
|
||||||
|
workspaceMessage: workspace.workspaceMessage,
|
||||||
|
workspaceHistory: workspace.workspaceHistory,
|
||||||
|
redoHistory: workspace.redoHistory,
|
||||||
|
}),
|
||||||
|
replaceWorkspaceState: workspace.replaceWorkspaceState,
|
||||||
|
getCurrentCommandState: workspace.getCurrentCommandState,
|
||||||
|
createWorkspaceCommand: workspace.createWorkspaceCommand,
|
||||||
|
executeWorkspaceCommand: workspace.executeWorkspaceCommand,
|
||||||
|
handleUndo: workspace.handleUndo,
|
||||||
|
handleRedo: workspace.handleRedo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderHarness(onContentChanged = vi.fn()) {
|
||||||
|
const ref = React.createRef<HarnessRef>();
|
||||||
|
render(<Harness ref={ref} onContentChanged={onContentChanged} />);
|
||||||
|
|
||||||
|
if (!ref.current) {
|
||||||
|
throw new Error("Harness ref was not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ref, onContentChanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useWorkspaceState", () => {
|
||||||
|
it("replaces workspace state from loaded data without marking it dirty", () => {
|
||||||
|
const { ref } = renderHarness();
|
||||||
|
const loadedPages = [page("p1", 0), page("p2", 1, 90)];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current?.replaceWorkspaceState({
|
||||||
|
pages: loadedPages,
|
||||||
|
selectedPageIds: ["p2"],
|
||||||
|
lastSelectedVisualIndex: 1,
|
||||||
|
history: [],
|
||||||
|
redoHistory: [],
|
||||||
|
dirty: false,
|
||||||
|
message: "Workspace loaded.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ref.current?.snapshot()).toMatchObject({
|
||||||
|
pages: loadedPages,
|
||||||
|
selectedPageIds: ["p2"],
|
||||||
|
lastSelectedVisualIndex: 1,
|
||||||
|
workspaceDirty: false,
|
||||||
|
workspaceMessage: "Workspace loaded.",
|
||||||
|
workspaceHistory: [],
|
||||||
|
redoHistory: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes commands, stores history, clears redo, and marks content changed", () => {
|
||||||
|
const { ref, onContentChanged } = renderHarness();
|
||||||
|
const before = state([page("p1", 0), page("p2", 1)], ["p1"], 0);
|
||||||
|
const after = state([page("p2", 1), page("p1", 0)], ["p2"], 0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current?.replaceWorkspaceState({
|
||||||
|
...before,
|
||||||
|
redoHistory: [
|
||||||
|
{
|
||||||
|
id: "redo-record",
|
||||||
|
type: "old-redo",
|
||||||
|
label: "Old redo",
|
||||||
|
timestamp: "2026-05-17T10:00:00.000Z",
|
||||||
|
payload: { before, after },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const command = ref.current?.createWorkspaceCommand({
|
||||||
|
type: "reorder-pages",
|
||||||
|
label: "Move page 2 before page 1",
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!command) throw new Error("Command was not created");
|
||||||
|
ref.current?.executeWorkspaceCommand(command);
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = ref.current?.snapshot();
|
||||||
|
expect(snapshot?.pages).toEqual(after.pages);
|
||||||
|
expect(snapshot?.selectedPageIds).toEqual(["p2"]);
|
||||||
|
expect(snapshot?.workspaceDirty).toBe(true);
|
||||||
|
expect(snapshot?.workspaceMessage).toBeNull();
|
||||||
|
expect(snapshot?.workspaceHistory).toHaveLength(1);
|
||||||
|
expect(snapshot?.workspaceHistory[0]).toMatchObject({
|
||||||
|
type: "reorder-pages",
|
||||||
|
label: "Move page 2 before page 1",
|
||||||
|
});
|
||||||
|
expect(snapshot?.redoHistory).toHaveLength(0);
|
||||||
|
expect(onContentChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes and redoes command records in stack order", () => {
|
||||||
|
const { ref, onContentChanged } = renderHarness();
|
||||||
|
const initial = state([page("p1", 0), page("p2", 1)], ["p1"], 0);
|
||||||
|
const reordered = state([page("p2", 1), page("p1", 0)], ["p2"], 0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current?.replaceWorkspaceState(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const command = ref.current?.createWorkspaceCommand({
|
||||||
|
type: "reorder-pages",
|
||||||
|
label: "Move page",
|
||||||
|
before: initial,
|
||||||
|
after: reordered,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!command) throw new Error("Command was not created");
|
||||||
|
ref.current?.executeWorkspaceCommand(command);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current?.handleUndo();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ref.current?.snapshot()).toMatchObject({
|
||||||
|
pages: initial.pages,
|
||||||
|
selectedPageIds: initial.selectedPageIds,
|
||||||
|
lastSelectedVisualIndex: initial.lastSelectedVisualIndex,
|
||||||
|
workspaceDirty: true,
|
||||||
|
});
|
||||||
|
expect(ref.current?.snapshot().workspaceHistory).toHaveLength(0);
|
||||||
|
expect(ref.current?.snapshot().redoHistory).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current?.handleRedo();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ref.current?.snapshot()).toMatchObject({
|
||||||
|
pages: reordered.pages,
|
||||||
|
selectedPageIds: reordered.selectedPageIds,
|
||||||
|
lastSelectedVisualIndex: reordered.lastSelectedVisualIndex,
|
||||||
|
workspaceDirty: true,
|
||||||
|
});
|
||||||
|
expect(ref.current?.snapshot().workspaceHistory).toHaveLength(1);
|
||||||
|
expect(ref.current?.snapshot().redoHistory).toHaveLength(0);
|
||||||
|
expect(onContentChanged).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
246
src/workspace/useWorkspaceState.ts
Normal file
246
src/workspace/useWorkspaceState.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { PageRef } from "../pdf/pdfTypes";
|
||||||
|
import type {
|
||||||
|
WorkspaceCommand,
|
||||||
|
WorkspaceCommandRecord,
|
||||||
|
WorkspaceCommandState,
|
||||||
|
} from "./workspaceCommands";
|
||||||
|
import {
|
||||||
|
createSnapshotCommand,
|
||||||
|
reviveWorkspaceCommand,
|
||||||
|
toWorkspaceCommandRecord,
|
||||||
|
} from "./workspaceCommands";
|
||||||
|
|
||||||
|
function createId(prefix: string): string {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceId(): string {
|
||||||
|
return createId("workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultWorkspaceNameFromPdfName(pdfName: string): string {
|
||||||
|
return pdfName.replace(/\.pdf$/i, "") || "Untitled workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPageRefId(): string {
|
||||||
|
return createId("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialPageRefs(pageCount: number): PageRef[] {
|
||||||
|
return Array.from({ length: pageCount }, (_, sourcePageIndex) => ({
|
||||||
|
id: createPageRefId(),
|
||||||
|
sourcePageIndex,
|
||||||
|
rotation: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRotation(rotation: number | undefined): number {
|
||||||
|
return (((rotation ?? 0) % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetStateAction<T> = T | ((previous: T) => T);
|
||||||
|
|
||||||
|
interface UseWorkspaceStateOptions {
|
||||||
|
onContentChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplaceWorkspaceStateOptions {
|
||||||
|
pages?: PageRef[];
|
||||||
|
selectedPageIds?: string[];
|
||||||
|
lastSelectedVisualIndex?: number | null;
|
||||||
|
history?: WorkspaceCommandRecord[];
|
||||||
|
redoHistory?: WorkspaceCommandRecord[];
|
||||||
|
dirty?: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceState({
|
||||||
|
onContentChanged,
|
||||||
|
}: UseWorkspaceStateOptions = {}) {
|
||||||
|
const [pages, setPagesState] = useState<PageRef[]>([]);
|
||||||
|
const [selectedPageIds, setSelectedPageIdsState] = useState<string[]>([]);
|
||||||
|
const [lastSelectedVisualIndex, setLastSelectedVisualIndexState] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [workspaceDirty, setWorkspaceDirty] = useState(false);
|
||||||
|
const [workspaceMessage, setWorkspaceMessage] = useState<string | null>(null);
|
||||||
|
const [workspaceHistory, setWorkspaceHistory] = useState<
|
||||||
|
WorkspaceCommandRecord[]
|
||||||
|
>([]);
|
||||||
|
const [redoHistory, setRedoHistory] = useState<WorkspaceCommandRecord[]>([]);
|
||||||
|
|
||||||
|
const latestPagesRef = useRef<PageRef[]>([]);
|
||||||
|
const selectedPageIdsRef = useRef<string[]>([]);
|
||||||
|
const lastSelectedVisualIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const setPages = useCallback((action: SetStateAction<PageRef[]>) => {
|
||||||
|
setPagesState((previous) => {
|
||||||
|
const next = typeof action === "function" ? action(previous) : action;
|
||||||
|
latestPagesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSelectedPageIds = useCallback((action: SetStateAction<string[]>) => {
|
||||||
|
setSelectedPageIdsState((previous) => {
|
||||||
|
const next = typeof action === "function" ? action(previous) : action;
|
||||||
|
selectedPageIdsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLastSelectedVisualIndex = useCallback(
|
||||||
|
(action: SetStateAction<number | null>) => {
|
||||||
|
setLastSelectedVisualIndexState((previous) => {
|
||||||
|
const next = typeof action === "function" ? action(previous) : action;
|
||||||
|
lastSelectedVisualIndexRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCurrentCommandState = useCallback(
|
||||||
|
(): WorkspaceCommandState => ({
|
||||||
|
pages: latestPagesRef.current,
|
||||||
|
selectedPageIds: selectedPageIdsRef.current,
|
||||||
|
lastSelectedVisualIndex: lastSelectedVisualIndexRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyCommandState = useCallback(
|
||||||
|
(state: WorkspaceCommandState) => {
|
||||||
|
setPages(state.pages);
|
||||||
|
setSelectedPageIds(state.selectedPageIds);
|
||||||
|
setLastSelectedVisualIndex(state.lastSelectedVisualIndex);
|
||||||
|
},
|
||||||
|
[setLastSelectedVisualIndex, setPages, setSelectedPageIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markWorkspaceChanged = useCallback(() => {
|
||||||
|
setWorkspaceDirty(true);
|
||||||
|
setWorkspaceMessage(null);
|
||||||
|
onContentChanged?.();
|
||||||
|
}, [onContentChanged]);
|
||||||
|
|
||||||
|
const createWorkspaceCommand = useCallback(
|
||||||
|
(params: {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
before: WorkspaceCommandState;
|
||||||
|
after: WorkspaceCommandState;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}): WorkspaceCommand =>
|
||||||
|
createSnapshotCommand({
|
||||||
|
id: createId("command"),
|
||||||
|
type: params.type,
|
||||||
|
label: params.label,
|
||||||
|
before: params.before,
|
||||||
|
after: params.after,
|
||||||
|
details: params.details,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeWorkspaceCommand = useCallback(
|
||||||
|
(command: WorkspaceCommand) => {
|
||||||
|
const nextState = command.do(getCurrentCommandState());
|
||||||
|
|
||||||
|
applyCommandState(nextState);
|
||||||
|
setWorkspaceHistory((previous) => [
|
||||||
|
...previous,
|
||||||
|
toWorkspaceCommandRecord(command),
|
||||||
|
]);
|
||||||
|
setRedoHistory([]);
|
||||||
|
markWorkspaceChanged();
|
||||||
|
},
|
||||||
|
[applyCommandState, getCurrentCommandState, markWorkspaceChanged],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
const record = workspaceHistory[workspaceHistory.length - 1];
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const command = reviveWorkspaceCommand(record);
|
||||||
|
const previousState = command.undo(getCurrentCommandState());
|
||||||
|
|
||||||
|
applyCommandState(previousState);
|
||||||
|
setWorkspaceHistory((previous) => previous.slice(0, -1));
|
||||||
|
setRedoHistory((previous) => [...previous, record]);
|
||||||
|
markWorkspaceChanged();
|
||||||
|
}, [
|
||||||
|
applyCommandState,
|
||||||
|
getCurrentCommandState,
|
||||||
|
markWorkspaceChanged,
|
||||||
|
workspaceHistory,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
const record = redoHistory[redoHistory.length - 1];
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const command = reviveWorkspaceCommand(record);
|
||||||
|
const nextState = command.do(getCurrentCommandState());
|
||||||
|
|
||||||
|
applyCommandState(nextState);
|
||||||
|
setRedoHistory((previous) => previous.slice(0, -1));
|
||||||
|
setWorkspaceHistory((previous) => [...previous, record]);
|
||||||
|
markWorkspaceChanged();
|
||||||
|
}, [
|
||||||
|
applyCommandState,
|
||||||
|
getCurrentCommandState,
|
||||||
|
markWorkspaceChanged,
|
||||||
|
redoHistory,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const replaceWorkspaceState = useCallback(
|
||||||
|
(state: ReplaceWorkspaceStateOptions = {}) => {
|
||||||
|
setPages(state.pages ?? []);
|
||||||
|
setSelectedPageIds(state.selectedPageIds ?? []);
|
||||||
|
setLastSelectedVisualIndex(state.lastSelectedVisualIndex ?? null);
|
||||||
|
setWorkspaceHistory(state.history ?? []);
|
||||||
|
setRedoHistory(state.redoHistory ?? []);
|
||||||
|
setWorkspaceDirty(state.dirty ?? false);
|
||||||
|
setWorkspaceMessage(state.message ?? null);
|
||||||
|
},
|
||||||
|
[setLastSelectedVisualIndex, setPages, setSelectedPageIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetWorkspaceState = useCallback(() => {
|
||||||
|
replaceWorkspaceState();
|
||||||
|
}, [replaceWorkspaceState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
setPages,
|
||||||
|
selectedPageIds,
|
||||||
|
setSelectedPageIds,
|
||||||
|
lastSelectedVisualIndex,
|
||||||
|
setLastSelectedVisualIndex,
|
||||||
|
latestPagesRef,
|
||||||
|
|
||||||
|
workspaceDirty,
|
||||||
|
setWorkspaceDirty,
|
||||||
|
workspaceMessage,
|
||||||
|
setWorkspaceMessage,
|
||||||
|
workspaceHistory,
|
||||||
|
setWorkspaceHistory,
|
||||||
|
redoHistory,
|
||||||
|
setRedoHistory,
|
||||||
|
|
||||||
|
getCurrentCommandState,
|
||||||
|
applyCommandState,
|
||||||
|
createWorkspaceCommand,
|
||||||
|
executeWorkspaceCommand,
|
||||||
|
handleUndo,
|
||||||
|
handleRedo,
|
||||||
|
replaceWorkspaceState,
|
||||||
|
resetWorkspaceState,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/workspace/workspaceCommands.test.ts
Normal file
105
src/workspace/workspaceCommands.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkspaceCommandState } from "./workspaceCommands";
|
||||||
|
import {
|
||||||
|
cloneCommandState,
|
||||||
|
createSnapshotCommand,
|
||||||
|
reviveWorkspaceCommand,
|
||||||
|
toWorkspaceCommandRecord,
|
||||||
|
} from "./workspaceCommands";
|
||||||
|
|
||||||
|
function makeState(pageIds: string[]): WorkspaceCommandState {
|
||||||
|
return {
|
||||||
|
pages: pageIds.map((id, index) => ({
|
||||||
|
id,
|
||||||
|
sourcePageIndex: index,
|
||||||
|
rotation: index * 90,
|
||||||
|
})),
|
||||||
|
selectedPageIds: pageIds.slice(0, 1),
|
||||||
|
lastSelectedVisualIndex: pageIds.length > 0 ? 0 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspaceCommands", () => {
|
||||||
|
it("clones command state deeply enough for page and selection changes", () => {
|
||||||
|
const original = makeState(["a", "b"]);
|
||||||
|
const cloned = cloneCommandState(original);
|
||||||
|
|
||||||
|
original.pages[0].rotation = 270;
|
||||||
|
original.selectedPageIds.push("b");
|
||||||
|
original.lastSelectedVisualIndex = 1;
|
||||||
|
|
||||||
|
expect(cloned).toEqual({
|
||||||
|
pages: [
|
||||||
|
{ id: "a", sourcePageIndex: 0, rotation: 0 },
|
||||||
|
{ id: "b", sourcePageIndex: 1, rotation: 90 },
|
||||||
|
],
|
||||||
|
selectedPageIds: ["a"],
|
||||||
|
lastSelectedVisualIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates snapshot commands that are stable after source states mutate", () => {
|
||||||
|
const before = makeState(["a", "b"]);
|
||||||
|
const after = makeState(["b", "a"]);
|
||||||
|
after.selectedPageIds = ["b"];
|
||||||
|
after.lastSelectedVisualIndex = 0;
|
||||||
|
|
||||||
|
const command = createSnapshotCommand({
|
||||||
|
id: "cmd-1",
|
||||||
|
type: "reorder-pages",
|
||||||
|
label: "Move page",
|
||||||
|
timestamp: "2026-05-17T10:00:00.000Z",
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
details: { moved: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
before.pages.length = 0;
|
||||||
|
after.pages[0].rotation = 180;
|
||||||
|
after.selectedPageIds.push("a");
|
||||||
|
|
||||||
|
expect(command.undo(makeState(["ignored"]))).toEqual({
|
||||||
|
pages: [
|
||||||
|
{ id: "a", sourcePageIndex: 0, rotation: 0 },
|
||||||
|
{ id: "b", sourcePageIndex: 1, rotation: 90 },
|
||||||
|
],
|
||||||
|
selectedPageIds: ["a"],
|
||||||
|
lastSelectedVisualIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command.do(makeState(["ignored"]))).toEqual({
|
||||||
|
pages: [
|
||||||
|
{ id: "b", sourcePageIndex: 0, rotation: 0 },
|
||||||
|
{ id: "a", sourcePageIndex: 1, rotation: 90 },
|
||||||
|
],
|
||||||
|
selectedPageIds: ["b"],
|
||||||
|
lastSelectedVisualIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips commands through serializable records", () => {
|
||||||
|
const before = makeState(["a", "b", "c"]);
|
||||||
|
const after: WorkspaceCommandState = {
|
||||||
|
pages: [before.pages[2], before.pages[0], before.pages[1]],
|
||||||
|
selectedPageIds: ["c"],
|
||||||
|
lastSelectedVisualIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = createSnapshotCommand({
|
||||||
|
id: "cmd-2",
|
||||||
|
type: "copy-pages",
|
||||||
|
label: "Copy pages",
|
||||||
|
timestamp: "2026-05-17T10:05:00.000Z",
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = toWorkspaceCommandRecord(command);
|
||||||
|
const revived = reviveWorkspaceCommand(record);
|
||||||
|
|
||||||
|
expect(record).not.toHaveProperty("do");
|
||||||
|
expect(record).not.toHaveProperty("undo");
|
||||||
|
expect(revived.do(before)).toEqual(after);
|
||||||
|
expect(revived.undo(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PageRef } from '../pdf/pdfTypes';
|
import type { PageRef } from "../pdf/pdfTypes";
|
||||||
|
|
||||||
export interface WorkspaceCommandState {
|
export interface WorkspaceCommandState {
|
||||||
pages: PageRef[];
|
pages: PageRef[];
|
||||||
@@ -26,7 +26,7 @@ export interface WorkspaceCommand extends WorkspaceCommandRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cloneCommandState(
|
export function cloneCommandState(
|
||||||
state: WorkspaceCommandState
|
state: WorkspaceCommandState,
|
||||||
): WorkspaceCommandState {
|
): WorkspaceCommandState {
|
||||||
return {
|
return {
|
||||||
pages: state.pages.map((page) => ({ ...page })),
|
pages: state.pages.map((page) => ({ ...page })),
|
||||||
@@ -58,7 +58,7 @@ export function createSnapshotCommand(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function reviveWorkspaceCommand(
|
export function reviveWorkspaceCommand(
|
||||||
record: WorkspaceCommandRecord
|
record: WorkspaceCommandRecord,
|
||||||
): WorkspaceCommand {
|
): WorkspaceCommand {
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
@@ -68,7 +68,7 @@ export function reviveWorkspaceCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toWorkspaceCommandRecord(
|
export function toWorkspaceCommandRecord(
|
||||||
command: WorkspaceCommand
|
command: WorkspaceCommand,
|
||||||
): WorkspaceCommandRecord {
|
): WorkspaceCommandRecord {
|
||||||
return {
|
return {
|
||||||
id: command.id,
|
id: command.id,
|
||||||
@@ -81,4 +81,4 @@ export function toWorkspaceCommandRecord(
|
|||||||
details: command.payload.details,
|
details: command.payload.details,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type {
|
|||||||
LoadedWorkspace,
|
LoadedWorkspace,
|
||||||
StoredWorkspace,
|
StoredWorkspace,
|
||||||
WorkspaceSummary,
|
WorkspaceSummary,
|
||||||
} from './workspaceTypes';
|
} from "./workspaceTypes";
|
||||||
|
|
||||||
const DB_NAME = 'pdf-tools-workspaces';
|
const DB_NAME = "pdf-tools-workspaces";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
const WORKSPACE_STORE = 'workspaces';
|
const WORKSPACE_STORE = "workspaces";
|
||||||
const PDF_STORE = 'pdfBinaries';
|
const PDF_STORE = "pdfBinaries";
|
||||||
|
|
||||||
interface PdfBinaryRecord {
|
interface PdfBinaryRecord {
|
||||||
pdfId: string;
|
pdfId: string;
|
||||||
@@ -48,21 +48,21 @@ function openWorkspaceDb(): Promise<IDBDatabase> {
|
|||||||
|
|
||||||
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
|
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
|
||||||
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
|
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
|
||||||
keyPath: 'id',
|
keyPath: "id",
|
||||||
});
|
});
|
||||||
|
|
||||||
workspaceStore.createIndex('updatedAt', 'updatedAt', {
|
workspaceStore.createIndex("updatedAt", "updatedAt", {
|
||||||
unique: false,
|
unique: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
workspaceStore.createIndex('pdfId', 'pdfId', {
|
workspaceStore.createIndex("pdfId", "pdfId", {
|
||||||
unique: false,
|
unique: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(PDF_STORE)) {
|
if (!db.objectStoreNames.contains(PDF_STORE)) {
|
||||||
db.createObjectStore(PDF_STORE, {
|
db.createObjectStore(PDF_STORE, {
|
||||||
keyPath: 'pdfId',
|
keyPath: "pdfId",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -76,7 +76,7 @@ export async function listWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||||||
const db = await openWorkspaceDb();
|
const db = await openWorkspaceDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tx = db.transaction(WORKSPACE_STORE, 'readonly');
|
const tx = db.transaction(WORKSPACE_STORE, "readonly");
|
||||||
const store = tx.objectStore(WORKSPACE_STORE);
|
const store = tx.objectStore(WORKSPACE_STORE);
|
||||||
|
|
||||||
const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
|
const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
|
||||||
@@ -113,13 +113,13 @@ export async function saveWorkspaceToIndexedDb({
|
|||||||
const pdfRecord: PdfBinaryRecord = {
|
const pdfRecord: PdfBinaryRecord = {
|
||||||
pdfId: workspace.pdfId,
|
pdfId: workspace.pdfId,
|
||||||
name: workspace.pdfName,
|
name: workspace.pdfName,
|
||||||
blob: new Blob([pdfArrayBuffer], { type: 'application/pdf' }),
|
blob: new Blob([pdfArrayBuffer], { type: "application/pdf" }),
|
||||||
size: pdfArrayBuffer.byteLength,
|
size: pdfArrayBuffer.byteLength,
|
||||||
createdAt: workspace.createdAt,
|
createdAt: workspace.createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
|
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readwrite");
|
||||||
|
|
||||||
tx.objectStore(PDF_STORE).put(pdfRecord);
|
tx.objectStore(PDF_STORE).put(pdfRecord);
|
||||||
tx.objectStore(WORKSPACE_STORE).put(workspace);
|
tx.objectStore(WORKSPACE_STORE).put(workspace);
|
||||||
@@ -131,15 +131,15 @@ export async function saveWorkspaceToIndexedDb({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWorkspaceFromIndexedDb(
|
export async function loadWorkspaceFromIndexedDb(
|
||||||
workspaceId: string
|
workspaceId: string,
|
||||||
): Promise<LoadedWorkspace | null> {
|
): Promise<LoadedWorkspace | null> {
|
||||||
const db = await openWorkspaceDb();
|
const db = await openWorkspaceDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readonly');
|
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readonly");
|
||||||
|
|
||||||
const workspace = await requestToPromise<StoredWorkspace | undefined>(
|
const workspace = await requestToPromise<StoredWorkspace | undefined>(
|
||||||
tx.objectStore(WORKSPACE_STORE).get(workspaceId)
|
tx.objectStore(WORKSPACE_STORE).get(workspaceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -148,7 +148,7 @@ export async function loadWorkspaceFromIndexedDb(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
|
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
|
||||||
tx.objectStore(PDF_STORE).get(workspace.pdfId)
|
tx.objectStore(PDF_STORE).get(workspace.pdfId),
|
||||||
);
|
);
|
||||||
|
|
||||||
await transactionDone(tx);
|
await transactionDone(tx);
|
||||||
@@ -169,20 +169,20 @@ export async function loadWorkspaceFromIndexedDb(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWorkspaceFromIndexedDb(
|
export async function deleteWorkspaceFromIndexedDb(
|
||||||
workspaceId: string
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = await openWorkspaceDb();
|
const db = await openWorkspaceDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly');
|
const lookupTx = db.transaction(WORKSPACE_STORE, "readonly");
|
||||||
const workspace = await requestToPromise<StoredWorkspace | undefined>(
|
const workspace = await requestToPromise<StoredWorkspace | undefined>(
|
||||||
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId)
|
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId),
|
||||||
);
|
);
|
||||||
await transactionDone(lookupTx);
|
await transactionDone(lookupTx);
|
||||||
|
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
|
|
||||||
const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
|
const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readwrite");
|
||||||
deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId);
|
deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId);
|
||||||
await transactionDone(deleteTx);
|
await transactionDone(deleteTx);
|
||||||
|
|
||||||
@@ -190,14 +190,14 @@ export async function deleteWorkspaceFromIndexedDb(
|
|||||||
const remainingWorkspaces = await listWorkspaces();
|
const remainingWorkspaces = await listWorkspaces();
|
||||||
|
|
||||||
const pdfStillUsed = remainingWorkspaces.some(
|
const pdfStillUsed = remainingWorkspaces.some(
|
||||||
(summary) => summary.pdfId === workspace.pdfId
|
(summary) => summary.pdfId === workspace.pdfId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pdfStillUsed) {
|
if (!pdfStillUsed) {
|
||||||
const cleanupDb = await openWorkspaceDb();
|
const cleanupDb = await openWorkspaceDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cleanupTx = cleanupDb.transaction(PDF_STORE, 'readwrite');
|
const cleanupTx = cleanupDb.transaction(PDF_STORE, "readwrite");
|
||||||
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
|
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
|
||||||
await transactionDone(cleanupTx);
|
await transactionDone(cleanupTx);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,4 +207,4 @@ export async function deleteWorkspaceFromIndexedDb(
|
|||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PageRef } from '../pdf/pdfTypes';
|
import type { PageRef } from "../pdf/pdfTypes";
|
||||||
import type { WorkspaceCommandRecord } from './workspaceCommands';
|
import type { WorkspaceCommandRecord } from "./workspaceCommands";
|
||||||
|
|
||||||
export interface StoredWorkspace {
|
export interface StoredWorkspace {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
@@ -37,4 +37,4 @@ export interface WorkspaceSummary {
|
|||||||
export interface LoadedWorkspace {
|
export interface LoadedWorkspace {
|
||||||
workspace: StoredWorkspace;
|
workspace: StoredWorkspace;
|
||||||
pdfArrayBuffer: ArrayBuffer;
|
pdfArrayBuffer: ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react-swc';
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
allowedHosts: ["pdftools.add-ideas.de"], // ← ADD THIS
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user