refactoring, linting, formatting
This commit is contained in:
31
CHANGELOG.md
31
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.
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
54
README
54
README
@@ -75,17 +75,17 @@ This makes the project especially useful for self-hosted environments, public-se
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `F1` / `?` | Open in-app help and tutorial |
|
||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||
| `Esc` | Clear the current selection or close an open dialog |
|
||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||
| `←` / `→` in preview | Move to previous / next page |
|
||||
| `Esc` in preview | Close preview |
|
||||
| Shortcut | Action |
|
||||
| -------------------------- | --------------------------------------------------- |
|
||||
| `F1` / `?` | Open in-app help and tutorial |
|
||||
| `Ctrl`/`⌘` + `A` | Select all pages |
|
||||
| `Delete` / `Backspace` | Delete selected pages after confirmation |
|
||||
| `Esc` | Clear the current selection or close an open dialog |
|
||||
| `Ctrl`/`⌘` + `Z` | Undo |
|
||||
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
|
||||
| `Ctrl`/`⌘` + `Y` | Redo |
|
||||
| `←` / `→` in preview | Move to previous / next page |
|
||||
| `Esc` in preview | Close preview |
|
||||
|
||||
Keyboard shortcuts are ignored while typing in form fields.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
### Build from source
|
||||
### Build and test from source
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run check` runs the main project quality gate:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The production build is written to `dist/`.
|
||||
|
||||
To preview the production build locally:
|
||||
Useful individual development commands:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
For development:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run dev # start the Vite development server
|
||||
npm run preview # preview the production build locally
|
||||
npm run test # run tests once
|
||||
npm run test:watch # run tests in watch mode
|
||||
npm run typecheck # run TypeScript without emitting files
|
||||
npm run lint # run ESLint
|
||||
npm run format # format the project with Prettier
|
||||
npm run format:check # verify Prettier formatting
|
||||
```
|
||||
|
||||
### Static hosting
|
||||
@@ -219,11 +229,13 @@ The current development baseline is:
|
||||
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
|
||||
|
||||
```text
|
||||
src/
|
||||
App.tsx Main application state and orchestration
|
||||
App.tsx Main application orchestration and UI wiring
|
||||
components/
|
||||
ActionDialog.tsx Reusable confirmation/action dialog
|
||||
ActionsPanel.tsx Export, extract, and split actions
|
||||
|
||||
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",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"no lint configured\""
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run --environment jsdom",
|
||||
"test:watch": "vitest --environment jsdom",
|
||||
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -15,11 +22,24 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"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-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",
|
||||
"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."
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
842
src/App.tsx
842
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 {
|
||||
label: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
title?: string;
|
||||
@@ -18,21 +18,21 @@ interface ActionDialogProps {
|
||||
}
|
||||
|
||||
const backgroundByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
NonNullable<ActionDialogAction["variant"]>,
|
||||
string
|
||||
> = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#e5e7eb',
|
||||
danger: '#dc2626',
|
||||
primary: "#2563eb",
|
||||
secondary: "#e5e7eb",
|
||||
danger: "#dc2626",
|
||||
};
|
||||
|
||||
const colorByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
NonNullable<ActionDialogAction["variant"]>,
|
||||
string
|
||||
> = {
|
||||
primary: 'white',
|
||||
secondary: '#111827',
|
||||
danger: 'white',
|
||||
primary: "white",
|
||||
secondary: "#111827",
|
||||
danger: "white",
|
||||
};
|
||||
|
||||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
@@ -46,16 +46,16 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -72,42 +72,42 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
background: "rgba(15, 23, 42, 0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
width: "100%",
|
||||
maxWidth: "440px",
|
||||
background: "white",
|
||||
borderRadius: "0.75rem",
|
||||
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
|
||||
padding: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="action-dialog-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -117,18 +117,18 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
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',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
@@ -138,8 +138,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
fontSize: "0.9rem",
|
||||
color: "#4b5563",
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
@@ -148,15 +148,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '0.25rem',
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{actions.map((action) => {
|
||||
const variant = action.variant ?? 'secondary';
|
||||
const variant = action.variant ?? "secondary";
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -169,15 +169,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
autoFocus={action.autoFocus}
|
||||
title={action.title}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.45rem 0.8rem",
|
||||
background: action.disabled
|
||||
? '#e5e7eb'
|
||||
? "#e5e7eb"
|
||||
: backgroundByVariant[variant],
|
||||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
||||
cursor: action.disabled ? 'default' : 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: action.disabled ? "#6b7280" : colorByVariant[variant],
|
||||
cursor: action.disabled ? "default" : "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { SplitResult } from '../pdf/pdfTypes';
|
||||
import React from "react";
|
||||
import type {
|
||||
PdfDownload,
|
||||
SplitPdfDownload,
|
||||
} from "../hooks/usePdfGeneratedOutputs";
|
||||
|
||||
interface ActionsPanelProps {
|
||||
hasPdf: boolean;
|
||||
@@ -11,11 +14,9 @@ interface ActionsPanelProps {
|
||||
onExtractSelected: () => void;
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitResults: SplitResult[];
|
||||
subsetDownloadUrl: string | null;
|
||||
subsetFilename: string | null;
|
||||
exportDownloadUrl: string | null;
|
||||
exportFilename: string | null;
|
||||
splitDownloads: SplitPdfDownload[];
|
||||
subsetDownload: PdfDownload | null;
|
||||
exportDownload: PdfDownload | null;
|
||||
}
|
||||
|
||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
@@ -25,11 +26,9 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
onSplit,
|
||||
onExtractSelected,
|
||||
onExportReordered,
|
||||
splitResults,
|
||||
subsetDownloadUrl,
|
||||
subsetFilename,
|
||||
exportDownloadUrl,
|
||||
exportFilename,
|
||||
splitDownloads,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
}) => {
|
||||
const disabled = !hasPdf || isBusy;
|
||||
|
||||
@@ -41,20 +40,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
return (
|
||||
<div className="card">
|
||||
<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,
|
||||
with deletions). Nothing is uploaded to a server.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="button-row"
|
||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
style={{ justifyContent: "space-between", flexWrap: "wrap" }}
|
||||
>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onExportReordered}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
>
|
||||
🧾 Export new PDF
|
||||
</button>
|
||||
@@ -63,11 +62,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
className="secondary"
|
||||
disabled={disabled || selectedCount === 0}
|
||||
onClick={handleExtractSelectedClick}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? 'Select at least one page'
|
||||
: 'Create a PDF from selected pages'
|
||||
? "Select at least one page"
|
||||
: "Create a PDF from selected pages"
|
||||
}
|
||||
>
|
||||
📤 Extract selected ({selectedCount})
|
||||
@@ -77,58 +76,52 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onSplit}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
>
|
||||
📂 Split into single PDFs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subsetDownloadUrl && subsetFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Subset result:</strong>{' '}
|
||||
{subsetDownload && (
|
||||
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||
<strong>Subset result:</strong>{" "}
|
||||
<a
|
||||
className="download-link"
|
||||
href={subsetDownloadUrl}
|
||||
download={subsetFilename}
|
||||
href={subsetDownload.url}
|
||||
download={subsetDownload.filename}
|
||||
>
|
||||
Download {subsetFilename}
|
||||
Download {subsetDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportDownloadUrl && exportFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Exported document:</strong>{' '}
|
||||
{exportDownload && (
|
||||
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||
<strong>Exported document:</strong>{" "}
|
||||
<a
|
||||
className="download-link"
|
||||
href={exportDownloadUrl}
|
||||
download={exportFilename}
|
||||
href={exportDownload.url}
|
||||
download={exportDownload.filename}
|
||||
>
|
||||
Download {exportFilename}
|
||||
Download {exportDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitResults.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
{splitDownloads.length > 0 && (
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||
<strong>Single-page PDFs:</strong>
|
||||
<div>
|
||||
{splitResults.map((r) => {
|
||||
const url = URL.createObjectURL(r.blob);
|
||||
return (
|
||||
<a
|
||||
key={r.pageIndex}
|
||||
className="download-link"
|
||||
href={url}
|
||||
download={r.filename}
|
||||
onClick={() => {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}}
|
||||
>
|
||||
{r.filename}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{splitDownloads.map((download) => (
|
||||
<a
|
||||
key={download.id}
|
||||
className="download-link"
|
||||
href={download.url}
|
||||
download={download.filename}
|
||||
>
|
||||
{download.filename}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import React from "react";
|
||||
import type { PdfFile } from "../pdf/pdfTypes";
|
||||
|
||||
interface FileLoaderProps {
|
||||
pdf: PdfFile | null;
|
||||
@@ -11,7 +11,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (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} />
|
||||
|
||||
{pdf && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||
<div>
|
||||
<strong>Loaded:</strong> {pdf.name}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
interface HelpDialogProps {
|
||||
open: boolean;
|
||||
@@ -6,36 +6,51 @@ interface HelpDialogProps {
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
|
||||
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' },
|
||||
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' },
|
||||
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
|
||||
{ keys: 'Ctrl/⌘ + 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' },
|
||||
{ keys: "F1 / ?", description: "Open this help and tutorial dialog" },
|
||||
{
|
||||
keys: "Ctrl/⌘ + A",
|
||||
description: "Select all pages in the current workspace",
|
||||
},
|
||||
{
|
||||
keys: "Delete / Backspace",
|
||||
description: "Delete the selected pages after confirmation",
|
||||
},
|
||||
{
|
||||
keys: "Esc",
|
||||
description: "Clear the page selection or close an open dialog",
|
||||
},
|
||||
{ keys: "Ctrl/⌘ + Z", description: "Undo the latest workspace command" },
|
||||
{
|
||||
keys: "Ctrl/⌘ + Shift + Z",
|
||||
description: "Redo the next workspace command",
|
||||
},
|
||||
{ keys: "Ctrl/⌘ + Y", description: "Redo the next workspace command" },
|
||||
{
|
||||
keys: "← / → in preview",
|
||||
description: "Move to the previous or next page in the preview overlay",
|
||||
},
|
||||
];
|
||||
|
||||
const tutorialSteps = [
|
||||
{
|
||||
title: '1. Open a PDF or load a workspace',
|
||||
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.',
|
||||
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.",
|
||||
},
|
||||
{
|
||||
title: '2. Arrange pages visually',
|
||||
body: 'Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.',
|
||||
title: "2. Arrange pages visually",
|
||||
body: "Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.",
|
||||
},
|
||||
{
|
||||
title: '3. Select, copy, and delete pages',
|
||||
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.',
|
||||
title: "3. Select, copy, and delete pages",
|
||||
body: "Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.",
|
||||
},
|
||||
{
|
||||
title: '4. Save your workspace or export a PDF',
|
||||
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
||||
title: "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.",
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,7 +59,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (e.key !== "Escape") return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -52,10 +67,10 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -79,8 +94,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||
<p>
|
||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||
rearrange, split, merge, rotate, duplicate, and export PDFs without
|
||||
uploading documents to a server.
|
||||
rearrange, split, merge, rotate, duplicate, and export PDFs
|
||||
without uploading documents to a server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,8 +134,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
))}
|
||||
</div>
|
||||
<p className="help-note">
|
||||
Shortcuts are ignored while typing in text fields or other editable
|
||||
controls.
|
||||
Shortcuts are ignored while typing in text fields or other
|
||||
editable controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
import React from "react";
|
||||
import { APP_VERSION } from "../version";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { PdfFile } from "../pdf/pdfTypes";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
|
||||
|
||||
// pdf.js worker setup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -10,10 +10,10 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
interface PagePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
pdf: PdfFile | null;
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
totalPages: number;
|
||||
|
||||
canGoPrevious: boolean;
|
||||
@@ -43,28 +43,28 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' && canGoPrevious) {
|
||||
if (e.key === "ArrowLeft" && canGoPrevious) {
|
||||
e.preventDefault();
|
||||
onPrevious();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' && canGoNext) {
|
||||
if (e.key === "ArrowRight" && canGoNext) {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
|
||||
|
||||
@@ -77,7 +77,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
|
||||
const scale = Math.min(
|
||||
maxWidth / viewport.width,
|
||||
maxHeight / viewport.height
|
||||
maxHeight / viewport.height,
|
||||
);
|
||||
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
@@ -110,7 +110,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
const visibleCanvas = canvasRef.current;
|
||||
if (!visibleCanvas) return;
|
||||
|
||||
const visibleCtx = visibleCanvas.getContext('2d');
|
||||
const visibleCtx = visibleCanvas.getContext("2d");
|
||||
if (!visibleCtx) return;
|
||||
|
||||
let canvasWidth = scaledViewport.width;
|
||||
@@ -126,8 +126,8 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
visibleCanvas.width = canvasWidth;
|
||||
visibleCanvas.height = canvasHeight;
|
||||
|
||||
const baseCanvas = document.createElement('canvas');
|
||||
const baseCtx = baseCanvas.getContext('2d');
|
||||
const baseCanvas = document.createElement("canvas");
|
||||
const baseCtx = baseCanvas.getContext("2d");
|
||||
if (!baseCtx) return;
|
||||
|
||||
baseCanvas.width = scaledViewport.width;
|
||||
@@ -161,7 +161,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
visibleCtx.drawImage(baseCanvas, 0, 0);
|
||||
visibleCtx.restore();
|
||||
} catch (e) {
|
||||
console.error('Error rendering preview', e);
|
||||
console.error("Error rendering preview", e);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -181,30 +181,30 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
background: "rgba(15, 23, 42, 0.8)",
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: '#111827',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
overflow: 'visible',
|
||||
position: "relative",
|
||||
background: "#111827",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "0.75rem",
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Previous page */}
|
||||
@@ -216,22 +216,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
}}
|
||||
disabled={!canGoPrevious}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: canGoPrevious ? '#374151' : '#1f2937',
|
||||
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
|
||||
cursor: canGoPrevious ? 'pointer' : 'default',
|
||||
fontSize: '1.35rem',
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: canGoPrevious ? "#374151" : "#1f2937",
|
||||
color: canGoPrevious ? "#e5e7eb" : "#6b7280",
|
||||
cursor: canGoPrevious ? "pointer" : "default",
|
||||
fontSize: "1.35rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
}}
|
||||
title="Previous page (←)"
|
||||
@@ -249,22 +249,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
}}
|
||||
disabled={!canGoNext}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: 'translate(50%, -50%)',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: canGoNext ? '#374151' : '#1f2937',
|
||||
color: canGoNext ? '#e5e7eb' : '#6b7280',
|
||||
cursor: canGoNext ? 'pointer' : 'default',
|
||||
fontSize: '1.35rem',
|
||||
top: "50%",
|
||||
transform: "translate(50%, -50%)",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: canGoNext ? "#374151" : "#1f2937",
|
||||
color: canGoNext ? "#e5e7eb" : "#6b7280",
|
||||
cursor: canGoNext ? "pointer" : "default",
|
||||
fontSize: "1.35rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
}}
|
||||
title="Next page (→)"
|
||||
@@ -281,22 +281,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
transform: 'translate(50%, -50%)',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: '#374151',
|
||||
color: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.2rem',
|
||||
transform: "translate(50%, -50%)",
|
||||
width: "2.25rem",
|
||||
height: "2.25rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: "#374151",
|
||||
color: "#e5e7eb",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.2rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
}}
|
||||
title="Close preview (Esc)"
|
||||
@@ -308,14 +308,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '75vh',
|
||||
background: 'white',
|
||||
borderRadius: '0.5rem',
|
||||
maxWidth: "100%",
|
||||
maxHeight: "75vh",
|
||||
background: "white",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "0.85rem" }}>
|
||||
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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 type { PageRef } from '../pdf/pdfTypes';
|
||||
import React, { useRef, useState } from "react";
|
||||
import type { PageRef } from "../pdf/pdfTypes";
|
||||
import CopyPagesDialog from "./PageWorkspace/CopyPagesDialog";
|
||||
import PageGrid from "./PageWorkspace/PageGrid";
|
||||
import PageSelectionToolbar from "./PageWorkspace/PageSelectionToolbar";
|
||||
|
||||
interface ReorderPanelProps {
|
||||
pages: PageRef[];
|
||||
@@ -17,7 +20,7 @@ interface ReorderPanelProps {
|
||||
onToggleSelect: (
|
||||
pageId: string,
|
||||
visualIndex: number,
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
onSelectAll: () => void;
|
||||
|
||||
@@ -48,13 +51,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
|
||||
const [isCopyDragging, setIsCopyDragging] = useState(false);
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||
const [copyTargetPosition, setCopyTargetPosition] = useState('');
|
||||
const [copyTargetPosition, setCopyTargetPosition] = useState("");
|
||||
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
|
||||
|
||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
||||
|
||||
const cleanupDragGhost = () => {
|
||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||
@@ -71,7 +72,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
if (!draggedPage) return [];
|
||||
|
||||
const selectedInVisualOrder = pages.filter((page) =>
|
||||
selectedPageIds.includes(page.id)
|
||||
selectedPageIds.includes(page.id),
|
||||
);
|
||||
|
||||
const draggingIsSelected =
|
||||
@@ -84,20 +85,20 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const createDragGhost = (e: React.DragEvent, count: number) => {
|
||||
cleanupDragGhost();
|
||||
|
||||
const ghost = document.createElement('div');
|
||||
ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
|
||||
const ghost = document.createElement("div");
|
||||
ghost.textContent = count === 1 ? "1 page" : `${count} pages`;
|
||||
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.top = '0';
|
||||
ghost.style.left = '0';
|
||||
ghost.style.padding = '4px 8px';
|
||||
ghost.style.borderRadius = '999px';
|
||||
ghost.style.background = '#111827';
|
||||
ghost.style.color = '#e5e7eb';
|
||||
ghost.style.fontSize = '12px';
|
||||
ghost.style.position = "fixed";
|
||||
ghost.style.top = "0";
|
||||
ghost.style.left = "0";
|
||||
ghost.style.padding = "4px 8px";
|
||||
ghost.style.borderRadius = "999px";
|
||||
ghost.style.background = "#111827";
|
||||
ghost.style.color = "#e5e7eb";
|
||||
ghost.style.fontSize = "12px";
|
||||
ghost.style.fontFamily =
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
ghost.style.zIndex = '9999';
|
||||
ghost.style.zIndex = "9999";
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
dragGhostRef.current = ghost;
|
||||
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
||||
};
|
||||
|
||||
const resetDragState = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(visualIndex);
|
||||
setDropIndex(visualIndex);
|
||||
@@ -113,19 +121,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.setData('text/plain', String(visualIndex));
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
e.dataTransfer.setData("text/plain", String(visualIndex));
|
||||
|
||||
const draggedPages = getDraggedPages(visualIndex);
|
||||
createDragGhost(e, draggedPages.length);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
@@ -136,7 +141,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
@@ -153,7 +158,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
|
||||
setDropIndex(pages.length);
|
||||
};
|
||||
@@ -172,7 +177,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
if (shouldCopy) {
|
||||
onCopyPagesToSlot(
|
||||
draggedPages.map((page) => page.id),
|
||||
dropIndex
|
||||
dropIndex,
|
||||
);
|
||||
|
||||
setDraggingIndex(null);
|
||||
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (pageId: string) => () => {
|
||||
const handleDeleteClick = (pageId: string) => {
|
||||
onDelete(pageId);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleRotateClickClockwise = (pageId: string) => () => {
|
||||
onRotateClockwise(pageId);
|
||||
};
|
||||
|
||||
const handleRotateClickCounterclockwise = (pageId: string) => () => {
|
||||
onRotateCounterclockwise(pageId);
|
||||
};
|
||||
|
||||
const handleCardClick = (pageId: string) => () => {
|
||||
onOpenPreview(pageId);
|
||||
};
|
||||
|
||||
const handleCheckboxClick =
|
||||
(pageId: string, visualIndex: number) =>
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // don't trigger preview
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
|
||||
const handleCopySelectedClick = () => {
|
||||
if (selectedPageIds.length === 0) return;
|
||||
|
||||
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
|
||||
setCopyTargetPosition(String(pages.length + 1));
|
||||
setCopyDialogError(null);
|
||||
setCopyDialogOpen(true);
|
||||
};
|
||||
@@ -245,11 +238,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyTargetPositionChange = (value: string) => {
|
||||
setCopyTargetPosition(value);
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (selectedPageIds.length === 0) {
|
||||
setCopyDialogError('No pages selected.');
|
||||
setCopyDialogError("No pages selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!copyDialogOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCopyDialogCancel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [copyDialogOpen]);
|
||||
|
||||
if (!hasPdf) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -293,554 +274,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 draggingSelectionActive =
|
||||
draggingPage != null &&
|
||||
selectedPageIds.length > 0 &&
|
||||
selectedPageIds.includes(draggingPage.id);
|
||||
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
||||
const dropIndicatorColor = isCopyDragging ? "#16a34a" : "#2563eb";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<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
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves the
|
||||
whole selection. Hold Ctrl/⌘ while dropping to copy instead of move.
|
||||
Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears
|
||||
selection.
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
||||
Esc clears selection.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Selected: <strong>{selectedPageIds.length}</strong>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{selectedPageIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySelectedClick}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={'Copy selected pages to another position'}
|
||||
>
|
||||
Copy selected
|
||||
</button>
|
||||
)}
|
||||
{selectedPageIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#fee2e2',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#8dcd8d',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
|
||||
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageSelectionToolbar
|
||||
selectedCount={selectedPageIds.length}
|
||||
onCopySelected={handleCopySelectedClick}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
<PageGrid
|
||||
pages={pages}
|
||||
thumbnails={thumbnails}
|
||||
selectedPageIds={selectedPageIds}
|
||||
isBusy={isBusy}
|
||||
draggingIndex={draggingIndex}
|
||||
dropIndex={dropIndex}
|
||||
draggingSelectionActive={draggingSelectionActive}
|
||||
isCopyDragging={isCopyDragging}
|
||||
dropIndicatorColor={dropIndicatorColor}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onCardDragOver={handleCardDragOver}
|
||||
onEndSlotDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{pages.map((page, visualIndex) => {
|
||||
const thumb = thumbnails[page.id];
|
||||
const rotation = page.rotation;
|
||||
const selected = isSelected(page.id);
|
||||
|
||||
const isDraggingCard =
|
||||
draggingIndex != null &&
|
||||
((draggingSelectionActive && selected) ||
|
||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
draggable
|
||||
onDragStart={handleDragStart(visualIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleCardDragOver(visualIndex)}
|
||||
onClick={handleCardClick(page.id)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '162px',
|
||||
padding: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: isDraggingCard
|
||||
? isCopyDragging
|
||||
? '#dcfce7'
|
||||
: '#dbeafe'
|
||||
: selected
|
||||
? '#eff6ff'
|
||||
: '#f9fafb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
|
||||
opacity: isBusy ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{/* selection checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckboxClick(page.id, visualIndex)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: '4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '0.4rem',
|
||||
border: '1px solid #9ca3af',
|
||||
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
|
||||
color: selected ? 'white' : 'transparent',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Select page"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
{/* left drop indicator */}
|
||||
{showLeftLine(visualIndex) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-4px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* right drop indicator */}
|
||||
{showRightLine(visualIndex) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-4px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '110px',
|
||||
height: '90px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt={`Page ${page.sourcePageIndex + 1}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed #d1d5db',
|
||||
background: '#f3f4f6',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: '0.8rem' }}>Page {page.sourcePageIndex + 1}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
||||
Pos {visualIndex + 1} · Rot {rotation}°
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickClockwise(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickCounterclockwise(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#fecaca',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Remove this page from the exported PDF"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* end slot for dropping after the last card */}
|
||||
{pages.length > 0 && (
|
||||
<div
|
||||
onDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '120px',
|
||||
position: 'relative',
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
>
|
||||
{showEndLine() && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
onOpenPreview={onOpenPreview}
|
||||
onToggleSelect={handleCheckboxClick}
|
||||
onRotateClockwise={onRotateClockwise}
|
||||
onRotateCounterclockwise={onRotateCounterclockwise}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{copyDialogOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="copy-pages-dialog-title"
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleCopyDialogCancel();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleCopyDialogConfirm}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="copy-pages-dialog-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
Copy selected pages
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDialogCancel}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
aria-label="Close copy dialog"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
}}
|
||||
>
|
||||
Copy{' '}
|
||||
<strong>
|
||||
{selectedPageIds.length === 1
|
||||
? '1 selected page'
|
||||
: `${selectedPageIds.length} selected pages`}
|
||||
</strong>{' '}
|
||||
to a new position.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Insert before position
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pages.length + 1}
|
||||
value={copyTargetPosition}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setCopyTargetPosition(e.target.value);
|
||||
setCopyDialogError(null);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div>1 = before the first page</div>
|
||||
<div>{pages.length + 1} = after the last page</div>
|
||||
</div>
|
||||
|
||||
{copyDialogError && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#b91c1c',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{copyDialogError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDialogCancel}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#16a34a',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Copy pages
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CopyPagesDialog
|
||||
selectedCount={selectedPageIds.length}
|
||||
pageCount={pages.length}
|
||||
targetPosition={copyTargetPosition}
|
||||
error={copyDialogError}
|
||||
onTargetPositionChange={handleCopyTargetPositionChange}
|
||||
onCancel={handleCopyDialogCancel}
|
||||
onConfirm={handleCopyDialogConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,327 +1,327 @@
|
||||
import React from 'react';
|
||||
import type { WorkspaceSummary } from '../workspace/workspaceTypes';
|
||||
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
||||
import React from "react";
|
||||
import type { WorkspaceSummary } from "../workspace/workspaceTypes";
|
||||
import type { WorkspaceCommandRecord } from "../workspace/workspaceCommands";
|
||||
|
||||
interface WorkspacePanelProps {
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}
|
||||
|
||||
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
||||
placeholder="Workspace name"
|
||||
disabled={!hasPdf || isBusy}
|
||||
style={{
|
||||
flex: "1 1 220px",
|
||||
minWidth: 0,
|
||||
padding: "0.45rem 0.55rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!hasPdf || isBusy || !canUndo}
|
||||
title={latestUndo ? `Undo: ${latestUndo.label}` : "Nothing to undo"}
|
||||
>
|
||||
↶ Undo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!hasPdf || isBusy || !canRedo}
|
||||
title={latestRedo ? `Redo: ${latestRedo.label}` : "Nothing to redo"}
|
||||
>
|
||||
↷ Redo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onSaveWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? "Open a PDF first" : "Save workspace"}
|
||||
>
|
||||
💾 {activeWorkspaceId ? "Save" : "Save as"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onResetWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={
|
||||
!hasPdf ? "No active workspace" : "Close the current workspace"
|
||||
}
|
||||
>
|
||||
Reset workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRefreshWorkspaces}
|
||||
disabled={isBusy}
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceDirty && hasPdf && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
color: "#92400e",
|
||||
}}
|
||||
>
|
||||
Unsaved workspace changes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "#166534",
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<strong style={{ fontSize: "0.9rem" }}>Saved workspaces</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4rem",
|
||||
marginTop: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
const active = workspace.id === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
background: active ? "#eff6ff" : "#f9fafb",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: "0.9rem" }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: "#2563eb" }}> · active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
||||
{workspace.pdfName} · source pages:{" "}
|
||||
{workspace.sourcePageCount} · workspace pages:{" "}
|
||||
{workspace.workspacePageCount} · undo:{" "}
|
||||
{workspace.historyCount} · redo: {workspace.redoCount} ·
|
||||
updated {new Date(workspace.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.35rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onLoadWorkspace(workspace.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: "#fee2e2",
|
||||
color: "#991b1b",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(history.length > 0 || redoHistory.length > 0) && (
|
||||
<details style={{ marginTop: "0.75rem" }} open>
|
||||
<summary style={{ cursor: "pointer", fontSize: "0.9rem" }}>
|
||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||
</summary>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#374151",
|
||||
borderLeft: "3px solid #2563eb",
|
||||
paddingLeft: "0.45rem",
|
||||
paddingTop: "0.2rem",
|
||||
paddingBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
||||
placeholder="Workspace name"
|
||||
disabled={!hasPdf || isBusy}
|
||||
style={{
|
||||
flex: '1 1 220px',
|
||||
minWidth: 0,
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
style={{
|
||||
margin: "0.25rem 0",
|
||||
borderRadius: "999px",
|
||||
background: "#ecfdf5",
|
||||
color: "#166534",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
alignSelf: "flex-start",
|
||||
border: "2px solid #166534",
|
||||
width: "100%",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!hasPdf || isBusy || !canUndo}
|
||||
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
|
||||
>
|
||||
↶ Undo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!hasPdf || isBusy || !canRedo}
|
||||
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
|
||||
>
|
||||
↷ Redo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onSaveWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
|
||||
>
|
||||
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onResetWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'}
|
||||
>
|
||||
Reset workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRefreshWorkspaces}
|
||||
disabled={isBusy}
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceDirty && hasPdf && (
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: '#92400e',
|
||||
}}
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#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>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#166534',
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.4rem',
|
||||
marginTop: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
const active = workspace.id === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
background: active ? '#eff6ff' : '#f9fafb',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: '#2563eb' }}> · active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
|
||||
{workspace.pdfName} · source pages:{' '}
|
||||
{workspace.sourcePageCount} · workspace pages:{' '}
|
||||
{workspace.workspacePageCount} · undo:{' '}
|
||||
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
|
||||
{new Date(workspace.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.35rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onLoadWorkspace(workspace.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(history.length > 0 || redoHistory.length > 0) && (
|
||||
<details style={{ marginTop: '0.75rem' }} open>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||
</summary>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#374151',
|
||||
borderLeft: '3px solid #2563eb',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
borderRadius: '999px',
|
||||
background: '#ecfdf5',
|
||||
color: '#166534',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
alignSelf: 'flex-start',
|
||||
border: '2px solid #166534',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#9ca3af',
|
||||
borderLeft: '3px solid #d1d5db',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Redo {index + 1}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#9ca3af' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspacePanel;
|
||||
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 ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { PDFDocument, degrees } from 'pdf-lib';
|
||||
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
|
||||
import { PDFDocument, degrees } from "pdf-lib";
|
||||
import type { PdfFile, PageRef, SplitResult, Range } from "./pdfTypes";
|
||||
|
||||
function createId() {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function pdfBytesToBlob(bytes: Uint8Array): Blob {
|
||||
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: "application/pdf" });
|
||||
}
|
||||
|
||||
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const doc = await PDFDocument.load(arrayBuffer);
|
||||
@@ -21,10 +31,10 @@ export async function loadPdfFromFile(file: File): Promise<PdfFile> {
|
||||
export async function mergePdfFiles(
|
||||
basePdf: PdfFile,
|
||||
newPdf: PdfFile,
|
||||
insertAt: number
|
||||
insertAt: number,
|
||||
): Promise<PdfFile> {
|
||||
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
|
||||
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
|
||||
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
|
||||
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
|
||||
|
||||
const mergedDoc = await PDFDocument.create();
|
||||
|
||||
@@ -35,11 +45,11 @@ export async function mergePdfFiles(
|
||||
|
||||
const basePages = await mergedDoc.copyPages(
|
||||
baseDoc,
|
||||
Array.from({ length: basePageCount }, (_, i) => i)
|
||||
Array.from({ length: basePageCount }, (_, i) => i),
|
||||
);
|
||||
const newPages = await mergedDoc.copyPages(
|
||||
newDoc,
|
||||
Array.from({ length: newPageCount }, (_, i) => i)
|
||||
Array.from({ length: newPageCount }, (_, i) => i),
|
||||
);
|
||||
|
||||
for (let i = 0; i < clampedInsertAt; i += 1) {
|
||||
@@ -53,11 +63,10 @@ export async function mergePdfFiles(
|
||||
}
|
||||
|
||||
const bytes = await mergedDoc.save();
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
const buffer = pdfBytesToArrayBuffer(bytes);
|
||||
|
||||
const baseName = basePdf.name.replace(/\.pdf$/i, '');
|
||||
const newName = newPdf.name.replace(/\.pdf$/i, '');
|
||||
const baseName = basePdf.name.replace(/\.pdf$/i, "");
|
||||
const newName = newPdf.name.replace(/\.pdf$/i, "");
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
@@ -69,7 +78,7 @@ export async function mergePdfFiles(
|
||||
}
|
||||
|
||||
export async function splitIntoSinglePages(
|
||||
pdf: PdfFile
|
||||
pdf: PdfFile,
|
||||
): Promise<SplitResult[]> {
|
||||
const { doc, name } = pdf;
|
||||
|
||||
@@ -99,10 +108,10 @@ export async function splitIntoSinglePages(
|
||||
if (modificationDate) newDoc.setModificationDate(modificationDate);
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const blob = pdfBytesToBlob(bytes);
|
||||
|
||||
const base = name.replace(/\.pdf$/i, '');
|
||||
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
|
||||
const base = name.replace(/\.pdf$/i, "");
|
||||
const filename = `${base}_page_${String(i + 1).padStart(3, "0")}.pdf`;
|
||||
|
||||
results.push({
|
||||
pageIndex: i,
|
||||
@@ -114,10 +123,7 @@ export async function splitIntoSinglePages(
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function extractRange(
|
||||
pdf: PdfFile,
|
||||
range: Range
|
||||
): Promise<Blob> {
|
||||
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
|
||||
const { doc } = pdf;
|
||||
const pageCount = doc.getPageCount();
|
||||
|
||||
@@ -125,7 +131,7 @@ export async function extractRange(
|
||||
const toIndex = Math.min(pageCount - 1, range.to - 1);
|
||||
|
||||
if (fromIndex > toIndex) {
|
||||
throw new Error('Invalid range: from > to');
|
||||
throw new Error("Invalid range: from > to");
|
||||
}
|
||||
|
||||
const newDoc = await PDFDocument.create();
|
||||
@@ -136,7 +142,7 @@ export async function extractRange(
|
||||
copiedPages.forEach((p) => newDoc.addPage(p));
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||
@@ -150,26 +156,26 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
|
||||
}
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function exportPages(
|
||||
pdf: PdfFile,
|
||||
pages: PageRef[]
|
||||
pages: PageRef[],
|
||||
): Promise<Blob> {
|
||||
const { doc } = pdf;
|
||||
const pageCount = doc.getPageCount();
|
||||
|
||||
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 (
|
||||
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();
|
||||
@@ -180,7 +186,7 @@ export async function exportPages(
|
||||
copiedPages.forEach((page, idx) => {
|
||||
const angle = pages[idx].rotation;
|
||||
|
||||
if (typeof angle === 'number' && angle % 360 !== 0) {
|
||||
if (typeof angle === "number" && angle % 360 !== 0) {
|
||||
page.setRotation(degrees(angle));
|
||||
}
|
||||
|
||||
@@ -188,13 +194,13 @@ export async function exportPages(
|
||||
});
|
||||
|
||||
const bytes = await newDoc.save();
|
||||
return new Blob([bytes], { type: 'application/pdf' });
|
||||
return pdfBytesToBlob(bytes);
|
||||
}
|
||||
|
||||
export async function exportReordered(
|
||||
pdf: PdfFile,
|
||||
order: number[],
|
||||
rotations?: Record<number, number>
|
||||
rotations?: Record<number, number>,
|
||||
): Promise<Blob> {
|
||||
return exportPages(
|
||||
pdf,
|
||||
@@ -202,6 +208,6 @@ export async function exportReordered(
|
||||
id: String(sourcePageIndex),
|
||||
sourcePageIndex,
|
||||
rotation: rotations?.[sourcePageIndex] ?? 0,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
|
||||
|
||||
// pdf.js worker setup for Vite
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -37,13 +37,12 @@ interface ThumbnailGenerationOptions {
|
||||
onThumbnail?: (update: ThumbnailUpdate) => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unrotated thumbnails – used e.g. in the Split/Extract view.
|
||||
*/
|
||||
export async function generateThumbnailsProgressive(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
options: ThumbnailGenerationOptions = {}
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
): Promise<string[]> {
|
||||
return generateThumbnailsInternal(arrayBuffer, {}, options);
|
||||
}
|
||||
@@ -54,7 +53,7 @@ export async function generateThumbnailsProgressive(
|
||||
export async function generateThumbnailsWithRotationsProgressive(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
rotations: RotationsMap,
|
||||
options: ThumbnailGenerationOptions = {}
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
): Promise<string[]> {
|
||||
return generateThumbnailsInternal(arrayBuffer, rotations, options);
|
||||
}
|
||||
@@ -62,7 +61,7 @@ export async function generateThumbnailsWithRotationsProgressive(
|
||||
async function generateThumbnailsInternal(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
rotations: RotationsMap,
|
||||
options: ThumbnailGenerationOptions = {}
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
): Promise<string[]> {
|
||||
const maxHeight = options.maxHeight ?? 150;
|
||||
const maxWidth = options.maxWidth ?? 140;
|
||||
@@ -73,15 +72,15 @@ async function generateThumbnailsInternal(
|
||||
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
const thumbs = Array<string>(pdf.numPages).fill('');
|
||||
const thumbs = Array<string>(pdf.numPages).fill("");
|
||||
|
||||
const pageNums = options.pageIndices
|
||||
? Array.from(
|
||||
new Set(
|
||||
options.pageIndices
|
||||
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
|
||||
.map((pageIndex) => pageIndex + 1)
|
||||
)
|
||||
.map((pageIndex) => pageIndex + 1),
|
||||
),
|
||||
)
|
||||
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
|
||||
|
||||
@@ -99,7 +98,7 @@ async function generateThumbnailsInternal(
|
||||
pageIndex,
|
||||
rotations,
|
||||
maxHeight,
|
||||
maxWidth
|
||||
maxWidth,
|
||||
);
|
||||
|
||||
if (signal?.aborted) return;
|
||||
@@ -133,11 +132,15 @@ async function generateThumbnailsInternal(
|
||||
}
|
||||
|
||||
async function renderPageThumbnail(
|
||||
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
|
||||
page: Awaited<
|
||||
ReturnType<
|
||||
Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>["getPage"]
|
||||
>
|
||||
>,
|
||||
originalIndex: number,
|
||||
rotations: RotationsMap,
|
||||
maxHeight: number,
|
||||
maxWidth: number
|
||||
maxWidth: number,
|
||||
): Promise<string> {
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scaleH = maxHeight / viewport.height;
|
||||
@@ -145,10 +148,10 @@ async function renderPageThumbnail(
|
||||
const scale = Math.min(scaleH, scaleW);
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
|
||||
const baseCanvas = document.createElement('canvas');
|
||||
const baseCtx = baseCanvas.getContext('2d');
|
||||
const baseCanvas = document.createElement("canvas");
|
||||
const baseCtx = baseCanvas.getContext("2d");
|
||||
|
||||
if (!baseCtx) return '';
|
||||
if (!baseCtx) return "";
|
||||
|
||||
baseCanvas.width = scaledViewport.width;
|
||||
baseCanvas.height = scaledViewport.height;
|
||||
@@ -164,14 +167,14 @@ async function renderPageThumbnail(
|
||||
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
|
||||
|
||||
if (rotationDeg === 0) {
|
||||
return baseCanvas.toDataURL('image/png');
|
||||
return baseCanvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
const rotatedCanvas = document.createElement('canvas');
|
||||
const rotatedCtx = rotatedCanvas.getContext('2d');
|
||||
const rotatedCanvas = document.createElement("canvas");
|
||||
const rotatedCtx = rotatedCanvas.getContext("2d");
|
||||
|
||||
if (!rotatedCtx) {
|
||||
return baseCanvas.toDataURL('image/png');
|
||||
return baseCanvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
const rad = (rotationDeg * Math.PI) / 180;
|
||||
@@ -204,5 +207,5 @@ async function renderPageThumbnail(
|
||||
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
||||
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 {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
|
||||
@@ -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 {
|
||||
pages: PageRef[];
|
||||
@@ -26,7 +26,7 @@ export interface WorkspaceCommand extends WorkspaceCommandRecord {
|
||||
}
|
||||
|
||||
export function cloneCommandState(
|
||||
state: WorkspaceCommandState
|
||||
state: WorkspaceCommandState,
|
||||
): WorkspaceCommandState {
|
||||
return {
|
||||
pages: state.pages.map((page) => ({ ...page })),
|
||||
@@ -58,7 +58,7 @@ export function createSnapshotCommand(params: {
|
||||
}
|
||||
|
||||
export function reviveWorkspaceCommand(
|
||||
record: WorkspaceCommandRecord
|
||||
record: WorkspaceCommandRecord,
|
||||
): WorkspaceCommand {
|
||||
return {
|
||||
...record,
|
||||
@@ -68,7 +68,7 @@ export function reviveWorkspaceCommand(
|
||||
}
|
||||
|
||||
export function toWorkspaceCommandRecord(
|
||||
command: WorkspaceCommand
|
||||
command: WorkspaceCommand,
|
||||
): WorkspaceCommandRecord {
|
||||
return {
|
||||
id: command.id,
|
||||
|
||||
@@ -2,13 +2,13 @@ import type {
|
||||
LoadedWorkspace,
|
||||
StoredWorkspace,
|
||||
WorkspaceSummary,
|
||||
} from './workspaceTypes';
|
||||
} from "./workspaceTypes";
|
||||
|
||||
const DB_NAME = 'pdf-tools-workspaces';
|
||||
const DB_NAME = "pdf-tools-workspaces";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
const WORKSPACE_STORE = 'workspaces';
|
||||
const PDF_STORE = 'pdfBinaries';
|
||||
const WORKSPACE_STORE = "workspaces";
|
||||
const PDF_STORE = "pdfBinaries";
|
||||
|
||||
interface PdfBinaryRecord {
|
||||
pdfId: string;
|
||||
@@ -48,21 +48,21 @@ function openWorkspaceDb(): Promise<IDBDatabase> {
|
||||
|
||||
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
|
||||
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
|
||||
keyPath: 'id',
|
||||
keyPath: "id",
|
||||
});
|
||||
|
||||
workspaceStore.createIndex('updatedAt', 'updatedAt', {
|
||||
workspaceStore.createIndex("updatedAt", "updatedAt", {
|
||||
unique: false,
|
||||
});
|
||||
|
||||
workspaceStore.createIndex('pdfId', 'pdfId', {
|
||||
workspaceStore.createIndex("pdfId", "pdfId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(PDF_STORE)) {
|
||||
db.createObjectStore(PDF_STORE, {
|
||||
keyPath: 'pdfId',
|
||||
keyPath: "pdfId",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -76,7 +76,7 @@ export async function listWorkspaces(): Promise<WorkspaceSummary[]> {
|
||||
const db = await openWorkspaceDb();
|
||||
|
||||
try {
|
||||
const tx = db.transaction(WORKSPACE_STORE, 'readonly');
|
||||
const tx = db.transaction(WORKSPACE_STORE, "readonly");
|
||||
const store = tx.objectStore(WORKSPACE_STORE);
|
||||
|
||||
const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
|
||||
@@ -113,13 +113,13 @@ export async function saveWorkspaceToIndexedDb({
|
||||
const pdfRecord: PdfBinaryRecord = {
|
||||
pdfId: workspace.pdfId,
|
||||
name: workspace.pdfName,
|
||||
blob: new Blob([pdfArrayBuffer], { type: 'application/pdf' }),
|
||||
blob: new Blob([pdfArrayBuffer], { type: "application/pdf" }),
|
||||
size: pdfArrayBuffer.byteLength,
|
||||
createdAt: workspace.createdAt,
|
||||
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(WORKSPACE_STORE).put(workspace);
|
||||
@@ -131,15 +131,15 @@ export async function saveWorkspaceToIndexedDb({
|
||||
}
|
||||
|
||||
export async function loadWorkspaceFromIndexedDb(
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
): Promise<LoadedWorkspace | null> {
|
||||
const db = await openWorkspaceDb();
|
||||
|
||||
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>(
|
||||
tx.objectStore(WORKSPACE_STORE).get(workspaceId)
|
||||
tx.objectStore(WORKSPACE_STORE).get(workspaceId),
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
@@ -148,7 +148,7 @@ export async function loadWorkspaceFromIndexedDb(
|
||||
}
|
||||
|
||||
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
|
||||
tx.objectStore(PDF_STORE).get(workspace.pdfId)
|
||||
tx.objectStore(PDF_STORE).get(workspace.pdfId),
|
||||
);
|
||||
|
||||
await transactionDone(tx);
|
||||
@@ -169,20 +169,20 @@ export async function loadWorkspaceFromIndexedDb(
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceFromIndexedDb(
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const db = await openWorkspaceDb();
|
||||
|
||||
try {
|
||||
const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly');
|
||||
const lookupTx = db.transaction(WORKSPACE_STORE, "readonly");
|
||||
const workspace = await requestToPromise<StoredWorkspace | undefined>(
|
||||
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId)
|
||||
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId),
|
||||
);
|
||||
await transactionDone(lookupTx);
|
||||
|
||||
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);
|
||||
await transactionDone(deleteTx);
|
||||
|
||||
@@ -190,14 +190,14 @@ export async function deleteWorkspaceFromIndexedDb(
|
||||
const remainingWorkspaces = await listWorkspaces();
|
||||
|
||||
const pdfStillUsed = remainingWorkspaces.some(
|
||||
(summary) => summary.pdfId === workspace.pdfId
|
||||
(summary) => summary.pdfId === workspace.pdfId,
|
||||
);
|
||||
|
||||
if (!pdfStillUsed) {
|
||||
const cleanupDb = await openWorkspaceDb();
|
||||
|
||||
try {
|
||||
const cleanupTx = cleanupDb.transaction(PDF_STORE, 'readwrite');
|
||||
const cleanupTx = cleanupDb.transaction(PDF_STORE, "readwrite");
|
||||
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
|
||||
await transactionDone(cleanupTx);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageRef } from '../pdf/pdfTypes';
|
||||
import type { WorkspaceCommandRecord } from './workspaceCommands';
|
||||
import type { PageRef } from "../pdf/pdfTypes";
|
||||
import type { WorkspaceCommandRecord } from "./workspaceCommands";
|
||||
|
||||
export interface StoredWorkspace {
|
||||
schemaVersion: 1;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS
|
||||
allowedHosts: ["pdftools.add-ideas.de"], // ← ADD THIS
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user