refactoring, linting, formatting

This commit is contained in:
2026-05-17 02:05:27 +02:00
parent bdbb6c0a1c
commit 07f4361573
38 changed files with 6121 additions and 2647 deletions

View File

@@ -4,6 +4,37 @@ All notable changes to `pdf-tools` are documented here.
The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements. The project follows a pragmatic versioning scheme while the app is still below `1.0.0`: minor versions mark coherent user-facing milestones; patch versions mark fixes and small improvements.
## Unreleased
### Added
- Added TypeScript type-check, ESLint, Prettier, and aggregate `check` scripts.
- Added ESLint flat config with TypeScript, React Hooks, React Refresh, browser, and Node config support.
- Added Prettier configuration and ignore file.
- Added Vite client type declarations for worker URL imports.
- Added Vitest-based test scripts for one-off and watch-mode test runs.
- Added pure tests for workspace command cloning, snapshot command stability, and serializable command record round-tripping.
- Added hook-level tests for workspace load/replace behavior, command execution, undo, redo, history, redo clearing, dirty-state updates, and content-change callbacks.
### Changed
- Marked the package as an ES module package to remove the Vite CJS Node API deprecation warning during local tooling runs.
- Ran Prettier across the project after adding the formatting configuration.
- Split the former monolithic `ReorderPanel` into focused page-workspace components: `PageGrid`, `PageCard`, `PageSelectionToolbar`, `DropIndicator`, and `CopyPagesDialog`.
- Kept drag/drop move, Ctrl/⌘ copy-drag, selection, rotation, deletion, preview opening, and copy-by-position behavior wired through the existing `ReorderPanel` API.
- Extracted generated PDF download URL creation and cleanup for split, extract, and export results into `src/hooks/usePdfGeneratedOutputs.ts`.
- Updated `ActionsPanel` to render prepared download objects instead of creating object URLs during render.
- Extracted workspace page, selection, dirty-state, message, undo/redo history, command creation, command execution, and reset/load helpers from `App.tsx` into `src/workspace/useWorkspaceState.ts`.
- Extracted thumbnail state, caching, invalidation, progressive rendering, rotation-aware rendering, copied-page thumbnail reuse, and thumbnail error reporting into `src/pdf/usePdfThumbnails.ts`.
- Kept PDF loading, IndexedDB persistence, dialogs, preview, merge, export, and split orchestration in `App.tsx` for now.
### Fixed
- Fixed existing `tsc --noEmit` failures for Vite worker URL imports and `Uint8Array`/`BlobPart` PDF byte handling.
- Removed a duplicate copy-dialog validation error assignment in `ReorderPanel`.
- Rotated thumbnails from loaded/saved workspaces are now regenerated from the actual current page rotation instead of relying only on rotation changes after load.
- Copied/duplicated pages now receive thumbnails through the shared thumbnail hook/cache path instead of ad-hoc copy handling in `App.tsx`.
## 0.2.0 — Browser-only PDF workspace baseline ## 0.2.0 — Browser-only PDF workspace baseline
### Added ### Added

34
README
View File

@@ -76,7 +76,7 @@ This makes the project especially useful for self-hosted environments, public-se
### Keyboard shortcuts ### Keyboard shortcuts
| Shortcut | Action | | Shortcut | Action |
| --- | --- | | -------------------------- | --------------------------------------------------- |
| `F1` / `?` | Open in-app help and tutorial | | `F1` / `?` | Open in-app help and tutorial |
| `Ctrl`/`⌘` + `A` | Select all pages | | `Ctrl`/`⌘` + `A` | Select all pages |
| `Delete` / `Backspace` | Delete selected pages after confirmation | | `Delete` / `Backspace` | Delete selected pages after confirmation |
@@ -115,25 +115,35 @@ browser <-- HTTPS --> static web server / reverse proxy --> built app assets
There is no application server to operate. Administrators only need to host the built files from `dist/`. There is no application server to operate. Administrators only need to host the built files from `dist/`.
### Build from source ### Build and test from source
```bash ```bash
npm ci npm ci
npm run check
```
`npm run check` runs the main project quality gate:
```bash
npm run typecheck
npm run lint
npm run test
npm run build npm run build
``` ```
The production build is written to `dist/`. The production build is written to `dist/`.
To preview the production build locally: Useful individual development commands:
```bash ```bash
npm run preview npm run dev # start the Vite development server
``` npm run preview # preview the production build locally
npm run test # run tests once
For development: npm run test:watch # run tests in watch mode
npm run typecheck # run TypeScript without emitting files
```bash npm run lint # run ESLint
npm run dev npm run format # format the project with Prettier
npm run format:check # verify Prettier formatting
``` ```
### Static hosting ### Static hosting
@@ -219,11 +229,13 @@ The current development baseline is:
v0.2.0 — Browser-only PDF workspace baseline v0.2.0 — Browser-only PDF workspace baseline
``` ```
This baseline is preserved through a staged refactoring path. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are now separated enough to support the next feature phase without turning `App.tsx` back into a monolith.
## Project structure ## Project structure
```text ```text
src/ src/
App.tsx Main application state and orchestration App.tsx Main application orchestration and UI wiring
components/ components/
ActionDialog.tsx Reusable confirmation/action dialog ActionDialog.tsx Reusable confirmation/action dialog
ActionsPanel.tsx Export, extract, and split actions ActionsPanel.tsx Export, extract, and split actions

55
eslint.config.mjs Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,19 @@
{ {
"name": "pdf-tools", "name": "pdf-tools",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "echo \"no lint configured\"" "typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run --environment jsdom",
"test:watch": "vitest --environment jsdom",
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
}, },
"dependencies": { "dependencies": {
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
@@ -15,11 +22,24 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.8.0",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.2", "@types/react-dom": "^18.3.2",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10" "typescript-eslint": "^8.59.3",
"vite": "^8.0.13",
"vitest": "^4.1.6"
}, },
"description": "Browser-only, self-hostable PDF workbench for page-level PDF operations." "description": "Browser-only, self-hostable PDF workbench for page-level PDF operations."
} }

BIN
pdf-tools.zip Normal file

Binary file not shown.

4
prettierignore.txt Normal file
View File

@@ -0,0 +1,4 @@
node_modules
coverage
dist
*.zip

6
prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 80
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
export interface ActionDialogAction { export interface ActionDialogAction {
label: string; label: string;
onClick: () => void | Promise<void>; onClick: () => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger'; variant?: "primary" | "secondary" | "danger";
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
title?: string; title?: string;
@@ -18,21 +18,21 @@ interface ActionDialogProps {
} }
const backgroundByVariant: Record< const backgroundByVariant: Record<
NonNullable<ActionDialogAction['variant']>, NonNullable<ActionDialogAction["variant"]>,
string string
> = { > = {
primary: '#2563eb', primary: "#2563eb",
secondary: '#e5e7eb', secondary: "#e5e7eb",
danger: '#dc2626', danger: "#dc2626",
}; };
const colorByVariant: Record< const colorByVariant: Record<
NonNullable<ActionDialogAction['variant']>, NonNullable<ActionDialogAction["variant"]>,
string string
> = { > = {
primary: 'white', primary: "white",
secondary: '#111827', secondary: "#111827",
danger: 'white', danger: "white",
}; };
const ActionDialog: React.FC<ActionDialogProps> = ({ const ActionDialog: React.FC<ActionDialogProps> = ({
@@ -46,16 +46,16 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
if (!open) return; if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onClose(); onClose();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [open, onClose]); }, [open, onClose]);
@@ -72,42 +72,42 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
} }
}} }}
style={{ style={{
position: 'fixed', position: "fixed",
inset: 0, inset: 0,
zIndex: 70, zIndex: 70,
background: 'rgba(15, 23, 42, 0.55)', background: "rgba(15, 23, 42, 0.55)",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
padding: '1rem', padding: "1rem",
}} }}
> >
<div <div
style={{ style={{
width: '100%', width: "100%",
maxWidth: '440px', maxWidth: "440px",
background: 'white', background: "white",
borderRadius: '0.75rem', borderRadius: "0.75rem",
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)', boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
padding: '1rem', padding: "1rem",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
gap: '0.75rem', gap: "0.75rem",
}} }}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
gap: '0.75rem', gap: "0.75rem",
}} }}
> >
<h2 <h2
id="action-dialog-title" id="action-dialog-title"
style={{ style={{
margin: 0, margin: 0,
fontSize: '1rem', fontSize: "1rem",
}} }}
> >
{title} {title}
@@ -117,18 +117,18 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
type="button" type="button"
onClick={onClose} onClick={onClose}
style={{ style={{
border: 'none', border: "none",
borderRadius: '999px', borderRadius: "999px",
width: '1.8rem', width: "1.8rem",
height: '1.8rem', height: "1.8rem",
background: '#e5e7eb', background: "#e5e7eb",
color: '#111827', color: "#111827",
cursor: 'pointer', cursor: "pointer",
fontSize: '1.1rem', fontSize: "1.1rem",
lineHeight: 1, lineHeight: 1,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}} }}
aria-label="Close dialog" aria-label="Close dialog"
> >
@@ -138,8 +138,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
<div <div
style={{ style={{
fontSize: '0.9rem', fontSize: "0.9rem",
color: '#4b5563', color: "#4b5563",
lineHeight: 1.45, lineHeight: 1.45,
}} }}
> >
@@ -148,15 +148,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'flex-end', justifyContent: "flex-end",
gap: '0.5rem', gap: "0.5rem",
flexWrap: 'wrap', flexWrap: "wrap",
marginTop: '0.25rem', marginTop: "0.25rem",
}} }}
> >
{actions.map((action) => { {actions.map((action) => {
const variant = action.variant ?? 'secondary'; const variant = action.variant ?? "secondary";
return ( return (
<button <button
@@ -169,15 +169,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
autoFocus={action.autoFocus} autoFocus={action.autoFocus}
title={action.title} title={action.title}
style={{ style={{
border: 'none', border: "none",
borderRadius: '0.5rem', borderRadius: "0.5rem",
padding: '0.45rem 0.8rem', padding: "0.45rem 0.8rem",
background: action.disabled background: action.disabled
? '#e5e7eb' ? "#e5e7eb"
: backgroundByVariant[variant], : backgroundByVariant[variant],
color: action.disabled ? '#6b7280' : colorByVariant[variant], color: action.disabled ? "#6b7280" : colorByVariant[variant],
cursor: action.disabled ? 'default' : 'pointer', cursor: action.disabled ? "default" : "pointer",
fontSize: '0.9rem', fontSize: "0.9rem",
}} }}
> >
{action.label} {action.label}

View File

@@ -1,5 +1,8 @@
import React from 'react'; import React from "react";
import type { SplitResult } from '../pdf/pdfTypes'; import type {
PdfDownload,
SplitPdfDownload,
} from "../hooks/usePdfGeneratedOutputs";
interface ActionsPanelProps { interface ActionsPanelProps {
hasPdf: boolean; hasPdf: boolean;
@@ -11,11 +14,9 @@ interface ActionsPanelProps {
onExtractSelected: () => void; onExtractSelected: () => void;
onExportReordered: () => void; onExportReordered: () => void;
splitResults: SplitResult[]; splitDownloads: SplitPdfDownload[];
subsetDownloadUrl: string | null; subsetDownload: PdfDownload | null;
subsetFilename: string | null; exportDownload: PdfDownload | null;
exportDownloadUrl: string | null;
exportFilename: string | null;
} }
const ActionsPanel: React.FC<ActionsPanelProps> = ({ const ActionsPanel: React.FC<ActionsPanelProps> = ({
@@ -25,11 +26,9 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
onSplit, onSplit,
onExtractSelected, onExtractSelected,
onExportReordered, onExportReordered,
splitResults, splitDownloads,
subsetDownloadUrl, subsetDownload,
subsetFilename, exportDownload,
exportDownloadUrl,
exportFilename,
}) => { }) => {
const disabled = !hasPdf || isBusy; const disabled = !hasPdf || isBusy;
@@ -41,20 +40,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
return ( return (
<div className="card"> <div className="card">
<h2>Tools</h2> <h2>Tools</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}> <p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
Use these tools on the current in-memory document (reordered, rotated, Use these tools on the current in-memory document (reordered, rotated,
with deletions). Nothing is uploaded to a server. with deletions). Nothing is uploaded to a server.
</p> </p>
<div <div
className="button-row" className="button-row"
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }} style={{ justifyContent: "space-between", flexWrap: "wrap" }}
> >
<button <button
className="secondary" className="secondary"
disabled={disabled} disabled={disabled}
onClick={onExportReordered} onClick={onExportReordered}
style={{ flex: '1 1 45%' }} style={{ flex: "1 1 45%" }}
> >
🧾 Export new PDF 🧾 Export new PDF
</button> </button>
@@ -63,11 +62,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
className="secondary" className="secondary"
disabled={disabled || selectedCount === 0} disabled={disabled || selectedCount === 0}
onClick={handleExtractSelectedClick} onClick={handleExtractSelectedClick}
style={{ flex: '1 1 45%' }} style={{ flex: "1 1 45%" }}
title={ title={
selectedCount === 0 selectedCount === 0
? 'Select at least one page' ? "Select at least one page"
: 'Create a PDF from selected pages' : "Create a PDF from selected pages"
} }
> >
📤 Extract selected ({selectedCount}) 📤 Extract selected ({selectedCount})
@@ -77,58 +76,52 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
className="secondary" className="secondary"
disabled={disabled} disabled={disabled}
onClick={onSplit} onClick={onSplit}
style={{ flex: '1 1 45%' }} style={{ flex: "1 1 45%" }}
> >
📂 Split into single PDFs 📂 Split into single PDFs
</button> </button>
</div> </div>
{subsetDownloadUrl && subsetFilename && ( {subsetDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}> <div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
<strong>Subset result:</strong>{' '} <strong>Subset result:</strong>{" "}
<a <a
className="download-link" className="download-link"
href={subsetDownloadUrl} href={subsetDownload.url}
download={subsetFilename} download={subsetDownload.filename}
> >
Download {subsetFilename} Download {subsetDownload.filename}
</a> </a>
</div> </div>
)} )}
{exportDownloadUrl && exportFilename && ( {exportDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}> <div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
<strong>Exported document:</strong>{' '} <strong>Exported document:</strong>{" "}
<a <a
className="download-link" className="download-link"
href={exportDownloadUrl} href={exportDownload.url}
download={exportFilename} download={exportDownload.filename}
> >
Download {exportFilename} Download {exportDownload.filename}
</a> </a>
</div> </div>
)} )}
{splitResults.length > 0 && ( {splitDownloads.length > 0 && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}> <div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
<strong>Single-page PDFs:</strong> <strong>Single-page PDFs:</strong>
<div> <div>
{splitResults.map((r) => { {splitDownloads.map((download) => (
const url = URL.createObjectURL(r.blob);
return (
<a <a
key={r.pageIndex} key={download.id}
className="download-link" className="download-link"
href={url} href={download.url}
download={r.filename} download={download.filename}
onClick={() => {
setTimeout(() => URL.revokeObjectURL(url), 5000);
}}
> >
{r.filename} {download.filename}
</a> </a>
); ))}
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from "react";
import type { PdfFile } from '../pdf/pdfTypes'; import type { PdfFile } from "../pdf/pdfTypes";
interface FileLoaderProps { interface FileLoaderProps {
pdf: PdfFile | null; pdf: PdfFile | null;
@@ -11,7 +11,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
onFileLoaded(file); onFileLoaded(file);
e.target.value = ''; e.target.value = "";
} }
}; };
@@ -22,7 +22,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
<input type="file" accept="application/pdf" onChange={handleChange} /> <input type="file" accept="application/pdf" onChange={handleChange} />
{pdf && ( {pdf && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}> <div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
<div> <div>
<strong>Loaded:</strong> {pdf.name} <strong>Loaded:</strong> {pdf.name}
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
interface HelpDialogProps { interface HelpDialogProps {
open: boolean; open: boolean;
@@ -6,36 +6,51 @@ interface HelpDialogProps {
} }
const shortcuts = [ const shortcuts = [
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' }, { keys: "F1 / ?", description: "Open this help and tutorial dialog" },
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' }, {
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' }, keys: "Ctrl/⌘ + A",
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' }, description: "Select all pages in the current workspace",
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' }, },
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' }, {
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' }, keys: "Delete / Backspace",
{ keys: '← / → in preview', description: 'Move to the previous or next page in the preview overlay' }, description: "Delete the selected pages after confirmation",
},
{
keys: "Esc",
description: "Clear the page selection or close an open dialog",
},
{ keys: "Ctrl/⌘ + Z", description: "Undo the latest workspace command" },
{
keys: "Ctrl/⌘ + Shift + Z",
description: "Redo the next workspace command",
},
{ keys: "Ctrl/⌘ + Y", description: "Redo the next workspace command" },
{
keys: "← / → in preview",
description: "Move to the previous or next page in the preview overlay",
},
]; ];
const tutorialSteps = [ const tutorialSteps = [
{ {
title: '1. Open a PDF or load a workspace', title: "1. Open a PDF or load a workspace",
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.', body: "Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.",
}, },
{ {
title: '2. Arrange pages visually', title: "2. Arrange pages visually",
body: 'Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.', body: "Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.",
}, },
{ {
title: '3. Select, copy, and delete pages', title: "3. Select, copy, and delete pages",
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.', body: "Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.",
}, },
{ {
title: '4. Save your workspace or export a PDF', title: "4. Save your workspace or export a PDF",
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.', body: "Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.",
}, },
{ {
title: '5. Use history deliberately', title: "5. Use history deliberately",
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.', body: "Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.",
}, },
]; ];
@@ -44,7 +59,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
if (!open) return; if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; if (e.key !== "Escape") return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -52,10 +67,10 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
onClose(); onClose();
}; };
window.addEventListener('keydown', handleKeyDown, { capture: true }); window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true }); window.removeEventListener("keydown", handleKeyDown, { capture: true });
}; };
}, [open, onClose]); }, [open, onClose]);
@@ -79,8 +94,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h2 id="help-dialog-title">Help & tutorial</h2> <h2 id="help-dialog-title">Help & tutorial</h2>
<p> <p>
PDF Workbench is a browser-only page workspace. Use it to quickly PDF Workbench is a browser-only page workspace. Use it to quickly
rearrange, split, merge, rotate, duplicate, and export PDFs without rearrange, split, merge, rotate, duplicate, and export PDFs
uploading documents to a server. without uploading documents to a server.
</p> </p>
</div> </div>
@@ -119,8 +134,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
))} ))}
</div> </div>
<p className="help-note"> <p className="help-note">
Shortcuts are ignored while typing in text fields or other editable Shortcuts are ignored while typing in text fields or other
controls. editable controls.
</p> </p>
</section> </section>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from "react";
import { APP_VERSION } from '../version'; import { APP_VERSION } from "../version";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from "react";
import type { PdfFile } from '../pdf/pdfTypes'; import type { PdfFile } from "../pdf/pdfTypes";
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url'; import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
// pdf.js worker setup // pdf.js worker setup
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -43,28 +43,28 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
if (!isOpen) return; if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onClose(); onClose();
return; return;
} }
if (e.key === 'ArrowLeft' && canGoPrevious) { if (e.key === "ArrowLeft" && canGoPrevious) {
e.preventDefault(); e.preventDefault();
onPrevious(); onPrevious();
return; return;
} }
if (e.key === 'ArrowRight' && canGoNext) { if (e.key === "ArrowRight" && canGoNext) {
e.preventDefault(); e.preventDefault();
onNext(); onNext();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]); }, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
@@ -77,7 +77,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
try { try {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) { if (canvas) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
} }
@@ -102,7 +102,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
const scale = Math.min( const scale = Math.min(
maxWidth / viewport.width, maxWidth / viewport.width,
maxHeight / viewport.height maxHeight / viewport.height,
); );
const scaledViewport = page.getViewport({ scale }); const scaledViewport = page.getViewport({ scale });
@@ -110,7 +110,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
const visibleCanvas = canvasRef.current; const visibleCanvas = canvasRef.current;
if (!visibleCanvas) return; if (!visibleCanvas) return;
const visibleCtx = visibleCanvas.getContext('2d'); const visibleCtx = visibleCanvas.getContext("2d");
if (!visibleCtx) return; if (!visibleCtx) return;
let canvasWidth = scaledViewport.width; let canvasWidth = scaledViewport.width;
@@ -126,8 +126,8 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
visibleCanvas.width = canvasWidth; visibleCanvas.width = canvasWidth;
visibleCanvas.height = canvasHeight; visibleCanvas.height = canvasHeight;
const baseCanvas = document.createElement('canvas'); const baseCanvas = document.createElement("canvas");
const baseCtx = baseCanvas.getContext('2d'); const baseCtx = baseCanvas.getContext("2d");
if (!baseCtx) return; if (!baseCtx) return;
baseCanvas.width = scaledViewport.width; baseCanvas.width = scaledViewport.width;
@@ -161,7 +161,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
visibleCtx.drawImage(baseCanvas, 0, 0); visibleCtx.drawImage(baseCanvas, 0, 0);
visibleCtx.restore(); visibleCtx.restore();
} catch (e) { } catch (e) {
console.error('Error rendering preview', e); console.error("Error rendering preview", e);
} }
})(); })();
@@ -181,30 +181,30 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
<div <div
onClick={onClose} onClick={onClose}
style={{ style={{
position: 'fixed', position: "fixed",
inset: 0, inset: 0,
background: 'rgba(15, 23, 42, 0.8)', background: "rgba(15, 23, 42, 0.8)",
zIndex: 50, zIndex: 50,
display: 'flex', display: "flex",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
padding: '1rem', padding: "1rem",
}} }}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
position: 'relative', position: "relative",
background: '#111827', background: "#111827",
borderRadius: '0.75rem', borderRadius: "0.75rem",
padding: '0.75rem', padding: "0.75rem",
maxWidth: '90vw', maxWidth: "90vw",
maxHeight: '90vh', maxHeight: "90vh",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
gap: '0.5rem', gap: "0.5rem",
overflow: 'visible', overflow: "visible",
}} }}
> >
{/* Previous page */} {/* Previous page */}
@@ -216,22 +216,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
}} }}
disabled={!canGoPrevious} disabled={!canGoPrevious}
style={{ style={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
top: '50%', top: "50%",
transform: 'translate(-50%, -50%)', transform: "translate(-50%, -50%)",
width: '2.5rem', width: "2.5rem",
height: '2.5rem', height: "2.5rem",
borderRadius: '999px', borderRadius: "999px",
border: 'none', border: "none",
background: canGoPrevious ? '#374151' : '#1f2937', background: canGoPrevious ? "#374151" : "#1f2937",
color: canGoPrevious ? '#e5e7eb' : '#6b7280', color: canGoPrevious ? "#e5e7eb" : "#6b7280",
cursor: canGoPrevious ? 'pointer' : 'default', cursor: canGoPrevious ? "pointer" : "default",
fontSize: '1.35rem', fontSize: "1.35rem",
lineHeight: 1, lineHeight: 1,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
zIndex: 2, zIndex: 2,
}} }}
title="Previous page (←)" title="Previous page (←)"
@@ -249,22 +249,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
}} }}
disabled={!canGoNext} disabled={!canGoNext}
style={{ style={{
position: 'absolute', position: "absolute",
right: 0, right: 0,
top: '50%', top: "50%",
transform: 'translate(50%, -50%)', transform: "translate(50%, -50%)",
width: '2.5rem', width: "2.5rem",
height: '2.5rem', height: "2.5rem",
borderRadius: '999px', borderRadius: "999px",
border: 'none', border: "none",
background: canGoNext ? '#374151' : '#1f2937', background: canGoNext ? "#374151" : "#1f2937",
color: canGoNext ? '#e5e7eb' : '#6b7280', color: canGoNext ? "#e5e7eb" : "#6b7280",
cursor: canGoNext ? 'pointer' : 'default', cursor: canGoNext ? "pointer" : "default",
fontSize: '1.35rem', fontSize: "1.35rem",
lineHeight: 1, lineHeight: 1,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
zIndex: 2, zIndex: 2,
}} }}
title="Next page (→)" title="Next page (→)"
@@ -281,22 +281,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
onClose(); onClose();
}} }}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
right: 0, right: 0,
transform: 'translate(50%, -50%)', transform: "translate(50%, -50%)",
width: '2.25rem', width: "2.25rem",
height: '2.25rem', height: "2.25rem",
borderRadius: '999px', borderRadius: "999px",
border: 'none', border: "none",
background: '#374151', background: "#374151",
color: '#e5e7eb', color: "#e5e7eb",
cursor: 'pointer', cursor: "pointer",
fontSize: '1.2rem', fontSize: "1.2rem",
lineHeight: 1, lineHeight: 1,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
zIndex: 3, zIndex: 3,
}} }}
title="Close preview (Esc)" title="Close preview (Esc)"
@@ -308,14 +308,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
maxWidth: '100%', maxWidth: "100%",
maxHeight: '75vh', maxHeight: "75vh",
background: 'white', background: "white",
borderRadius: '0.5rem', borderRadius: "0.5rem",
}} }}
/> />
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}> <div style={{ color: "#e5e7eb", fontSize: "0.85rem" }}>
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}° {positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
</div> </div>
</div> </div>

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,5 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useRef, useState } from "react";
import type { PageRef } from '../pdf/pdfTypes'; import type { PageRef } from "../pdf/pdfTypes";
import CopyPagesDialog from "./PageWorkspace/CopyPagesDialog";
import PageGrid from "./PageWorkspace/PageGrid";
import PageSelectionToolbar from "./PageWorkspace/PageSelectionToolbar";
interface ReorderPanelProps { interface ReorderPanelProps {
pages: PageRef[]; pages: PageRef[];
@@ -17,7 +20,7 @@ interface ReorderPanelProps {
onToggleSelect: ( onToggleSelect: (
pageId: string, pageId: string,
visualIndex: number, visualIndex: number,
e: React.MouseEvent<HTMLButtonElement> e: React.MouseEvent<HTMLButtonElement>,
) => void; ) => void;
onSelectAll: () => void; onSelectAll: () => void;
@@ -48,13 +51,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const [isCopyDragging, setIsCopyDragging] = useState(false); const [isCopyDragging, setIsCopyDragging] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyTargetPosition, setCopyTargetPosition] = useState(''); const [copyTargetPosition, setCopyTargetPosition] = useState("");
const [copyDialogError, setCopyDialogError] = useState<string | null>(null); const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
const dragGhostRef = useRef<HTMLDivElement | null>(null); const dragGhostRef = useRef<HTMLDivElement | null>(null);
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
const cleanupDragGhost = () => { const cleanupDragGhost = () => {
if (dragGhostRef.current && dragGhostRef.current.parentNode) { if (dragGhostRef.current && dragGhostRef.current.parentNode) {
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current); dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
@@ -71,7 +72,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
if (!draggedPage) return []; if (!draggedPage) return [];
const selectedInVisualOrder = pages.filter((page) => const selectedInVisualOrder = pages.filter((page) =>
selectedPageIds.includes(page.id) selectedPageIds.includes(page.id),
); );
const draggingIsSelected = const draggingIsSelected =
@@ -84,20 +85,20 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const createDragGhost = (e: React.DragEvent, count: number) => { const createDragGhost = (e: React.DragEvent, count: number) => {
cleanupDragGhost(); cleanupDragGhost();
const ghost = document.createElement('div'); const ghost = document.createElement("div");
ghost.textContent = count === 1 ? '1 page' : `${count} pages`; ghost.textContent = count === 1 ? "1 page" : `${count} pages`;
ghost.style.position = 'fixed'; ghost.style.position = "fixed";
ghost.style.top = '0'; ghost.style.top = "0";
ghost.style.left = '0'; ghost.style.left = "0";
ghost.style.padding = '4px 8px'; ghost.style.padding = "4px 8px";
ghost.style.borderRadius = '999px'; ghost.style.borderRadius = "999px";
ghost.style.background = '#111827'; ghost.style.background = "#111827";
ghost.style.color = '#e5e7eb'; ghost.style.color = "#e5e7eb";
ghost.style.fontSize = '12px'; ghost.style.fontSize = "12px";
ghost.style.fontFamily = ghost.style.fontFamily =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ghost.style.zIndex = '9999'; ghost.style.zIndex = "9999";
document.body.appendChild(ghost); document.body.appendChild(ghost);
dragGhostRef.current = ghost; dragGhostRef.current = ghost;
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2); e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
}; };
const resetDragState = () => {
cleanupDragGhost();
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
};
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => { const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
setDraggingIndex(visualIndex); setDraggingIndex(visualIndex);
setDropIndex(visualIndex); setDropIndex(visualIndex);
@@ -113,19 +121,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const copying = isCopyModifierPressed(e); const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying); setIsCopyDragging(copying);
e.dataTransfer.effectAllowed = 'copyMove'; e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; e.dataTransfer.dropEffect = copying ? "copy" : "move";
e.dataTransfer.setData('text/plain', String(visualIndex)); e.dataTransfer.setData("text/plain", String(visualIndex));
const draggedPages = getDraggedPages(visualIndex); const draggedPages = getDraggedPages(visualIndex);
createDragGhost(e, draggedPages.length); createDragGhost(e, draggedPages.length);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
cleanupDragGhost(); resetDragState();
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
}; };
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => { const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
@@ -136,7 +141,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const copying = isCopyModifierPressed(e); const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying); setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; e.dataTransfer.dropEffect = copying ? "copy" : "move";
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
@@ -153,7 +158,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const copying = isCopyModifierPressed(e); const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying); setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; e.dataTransfer.dropEffect = copying ? "copy" : "move";
setDropIndex(pages.length); setDropIndex(pages.length);
}; };
@@ -172,7 +177,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
if (shouldCopy) { if (shouldCopy) {
onCopyPagesToSlot( onCopyPagesToSlot(
draggedPages.map((page) => page.id), draggedPages.map((page) => page.id),
dropIndex dropIndex,
); );
setDraggingIndex(null); setDraggingIndex(null);
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setIsCopyDragging(false); setIsCopyDragging(false);
}; };
const handleDeleteClick = (pageId: string) => () => { const handleDeleteClick = (pageId: string) => {
onDelete(pageId); onDelete(pageId);
setDraggingIndex(null); setDraggingIndex(null);
setDropIndex(null); setDropIndex(null);
}; };
const handleRotateClickClockwise = (pageId: string) => () => {
onRotateClockwise(pageId);
};
const handleRotateClickCounterclockwise = (pageId: string) => () => {
onRotateCounterclockwise(pageId);
};
const handleCardClick = (pageId: string) => () => {
onOpenPreview(pageId);
};
const handleCheckboxClick = const handleCheckboxClick =
(pageId: string, visualIndex: number) => (pageId: string, visualIndex: number) =>
(e: React.MouseEvent<HTMLButtonElement>) => { (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // don't trigger preview e.stopPropagation();
onToggleSelect(pageId, visualIndex, e); onToggleSelect(pageId, visualIndex, e);
}; };
const handleCopySelectedClick = () => { const handleCopySelectedClick = () => {
if (selectedPageIds.length === 0) return; if (selectedPageIds.length === 0) return;
setCopyTargetPosition(String(pages.length + 1)); // default: after last page setCopyTargetPosition(String(pages.length + 1));
setCopyDialogError(null); setCopyDialogError(null);
setCopyDialogOpen(true); setCopyDialogOpen(true);
}; };
@@ -245,11 +238,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setCopyDialogError(null); setCopyDialogError(null);
}; };
const handleCopyTargetPositionChange = (value: string) => {
setCopyTargetPosition(value);
setCopyDialogError(null);
};
const handleCopyDialogConfirm = (e?: React.FormEvent) => { const handleCopyDialogConfirm = (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (selectedPageIds.length === 0) { if (selectedPageIds.length === 0) {
setCopyDialogError('No pages selected.'); setCopyDialogError("No pages selected.");
return; return;
} }
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setCopyDialogError(null); setCopyDialogError(null);
}; };
useEffect(() => {
if (!copyDialogOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleCopyDialogCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [copyDialogOpen]);
if (!hasPdf) { if (!hasPdf) {
return ( return (
<div className="card"> <div className="card">
@@ -293,554 +274,66 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
); );
} }
const showLeftLine = (visualIndex: number) =>
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
const showRightLine = (visualIndex: number) =>
dropIndex !== null &&
dropIndex === visualIndex + 1 &&
draggingIndex !== null;
const showEndLine = () =>
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
// For highlighting the whole selection while dragging it
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null; const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
const draggingSelectionActive = const draggingSelectionActive =
draggingPage != null && draggingPage != null &&
selectedPageIds.length > 0 && selectedPageIds.length > 0 &&
selectedPageIds.includes(draggingPage.id); selectedPageIds.includes(draggingPage.id);
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb'; const dropIndicatorColor = isCopyDragging ? "#16a34a" : "#2563eb";
return ( return (
<> <>
<div className="card"> <div className="card">
<h2>Pages</h2> <h2>Pages</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}> <p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
Tap/click a page to preview it. Use the checkbox to select pages Tap/click a page to preview it. Use the checkbox to select pages
(Shift for ranges). Drag to reorder; dragging a selected page moves the (Shift for ranges). Drag to reorder; dragging a selected page moves
whole selection. Hold Ctrl/ while dropping to copy instead of move. the whole selection. Hold Ctrl/ while dropping to copy instead of
Shortcuts: Ctrl/+A selects all, Delete removes selected pages, Esc clears move. Shortcuts: Ctrl/+A selects all, Delete removes selected pages,
selection. Esc clears selection.
</p> </p>
<div <PageSelectionToolbar
style={{ selectedCount={selectedPageIds.length}
display: 'flex', onCopySelected={handleCopySelectedClick}
justifyContent: 'space-between', onDeleteSelected={onDeleteSelected}
alignItems: 'center', onSelectAll={onSelectAll}
marginBottom: '0.5rem', onClearSelection={onClearSelection}
fontSize: '0.85rem', />
}}
>
<span>
Selected: <strong>{selectedPageIds.length}</strong>
</span>
<div
style={{
display: 'flex',
gap: '0.4rem',
}}
>
{selectedPageIds.length > 0 && (
<button
type="button"
onClick={handleCopySelectedClick}
disabled={selectedPageIds.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#dcfce7',
color: '#166534',
cursor: 'pointer',
}}
title={'Copy selected pages to another position'}
>
Copy selected
</button>
)}
{selectedPageIds.length > 0 && (
<button
type="button"
onClick={onDeleteSelected}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#fee2e2',
color: '#b91c1c',
cursor: 'pointer',
}}
>
Delete selected
</button>
)}
<button
type="button"
onClick={onSelectAll}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#8dcd8d',
color: '#111827',
cursor: 'pointer',
}}
>
Select all
</button>
<button
type="button"
onClick={onClearSelection}
disabled={selectedPageIds.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#e5e7eb',
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
}}
>
Clear selection
</button>
</div>
</div>
<div <PageGrid
style={{ pages={pages}
display: 'flex', thumbnails={thumbnails}
flexWrap: 'wrap', selectedPageIds={selectedPageIds}
gap: '0.5rem', isBusy={isBusy}
alignItems: 'flex-start', draggingIndex={draggingIndex}
marginBottom: '0.75rem', dropIndex={dropIndex}
}} draggingSelectionActive={draggingSelectionActive}
onDrop={handleDrop} isCopyDragging={isCopyDragging}
> dropIndicatorColor={dropIndicatorColor}
{pages.map((page, visualIndex) => { onDragStart={handleDragStart}
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} onDragEnd={handleDragEnd}
onDragOver={handleCardDragOver(visualIndex)} onCardDragOver={handleCardDragOver}
onClick={handleCardClick(page.id)} onEndSlotDragOver={handleEndSlotDragOver}
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} onDrop={handleDrop}
style={{ onOpenPreview={onOpenPreview}
width: '20px', onToggleSelect={handleCheckboxClick}
height: '120px', onRotateClockwise={onRotateClockwise}
position: 'relative', onRotateCounterclockwise={onRotateCounterclockwise}
alignSelf: 'stretch', onDelete={handleDeleteClick}
}}
>
{showEndLine() && (
<div
style={{
position: 'absolute',
left: '8px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: dropIndicatorColor,
}}
/> />
)}
</div>
)}
</div>
</div> </div>
{copyDialogOpen && ( {copyDialogOpen && (
<div <CopyPagesDialog
role="dialog" selectedCount={selectedPageIds.length}
aria-modal="true" pageCount={pages.length}
aria-labelledby="copy-pages-dialog-title" targetPosition={copyTargetPosition}
onPointerDown={(e) => { error={copyDialogError}
if (e.target === e.currentTarget) { onTargetPositionChange={handleCopyTargetPositionChange}
handleCopyDialogCancel(); onCancel={handleCopyDialogCancel}
} onConfirm={handleCopyDialogConfirm}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<form
onSubmit={handleCopyDialogConfirm}
style={{
width: '100%',
maxWidth: '420px',
background: 'white',
borderRadius: '0.75rem',
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
}}
>
<h2
id="copy-pages-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
Copy selected pages
</h2>
<button
type="button"
onClick={handleCopyDialogCancel}
style={{
border: 'none',
borderRadius: '999px',
width: '1.8rem',
height: '1.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close copy dialog"
>
×
</button>
</div>
<p
style={{
margin: 0,
fontSize: '0.9rem',
color: '#4b5563',
}}
>
Copy{' '}
<strong>
{selectedPageIds.length === 1
? '1 selected page'
: `${selectedPageIds.length} selected pages`}
</strong>{' '}
to a new position.
</p>
<label
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
fontSize: '0.9rem',
}}
>
Insert before position
<input
type="number"
min={1}
max={pages.length + 1}
value={copyTargetPosition}
autoFocus
onChange={(e) => {
setCopyTargetPosition(e.target.value);
setCopyDialogError(null);
}}
style={{
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.95rem',
}}
/> />
</label>
<div
style={{
fontSize: '0.8rem',
color: '#6b7280',
lineHeight: 1.4,
}}
>
<div>1 = before the first page</div>
<div>{pages.length + 1} = after the last page</div>
</div>
{copyDialogError && (
<div
style={{
borderRadius: '0.5rem',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
padding: '0.5rem',
fontSize: '0.85rem',
}}
>
{copyDialogError}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={handleCopyDialogCancel}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Cancel
</button>
<button
type="submit"
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#16a34a',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Copy pages
</button>
</div>
</form>
</div>
)} )}
</> </>
); );

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from "react";
import type { WorkspaceSummary } from '../workspace/workspaceTypes'; import type { WorkspaceSummary } from "../workspace/workspaceTypes";
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands'; import type { WorkspaceCommandRecord } from "../workspace/workspaceCommands";
interface WorkspacePanelProps { interface WorkspacePanelProps {
hasPdf: boolean; hasPdf: boolean;
@@ -54,17 +54,17 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
<div className="card"> <div className="card">
<h2>Workspace</h2> <h2>Workspace</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}> <p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
Save named workspaces in this browser. PDF binaries are stored in Save named workspaces in this browser. PDF binaries are stored in
IndexedDB; nothing is uploaded. IndexedDB; nothing is uploaded.
</p> </p>
<div <div
style={{ style={{
display: 'flex', display: "flex",
gap: '0.5rem', gap: "0.5rem",
flexWrap: 'wrap', flexWrap: "wrap",
alignItems: 'center', alignItems: "center",
}} }}
> >
<input <input
@@ -74,12 +74,12 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
placeholder="Workspace name" placeholder="Workspace name"
disabled={!hasPdf || isBusy} disabled={!hasPdf || isBusy}
style={{ style={{
flex: '1 1 220px', flex: "1 1 220px",
minWidth: 0, minWidth: 0,
padding: '0.45rem 0.55rem', padding: "0.45rem 0.55rem",
borderRadius: '0.5rem', borderRadius: "0.5rem",
border: '1px solid #d1d5db', border: "1px solid #d1d5db",
fontSize: '0.9rem', fontSize: "0.9rem",
}} }}
/> />
@@ -88,7 +88,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
className="secondary" className="secondary"
onClick={onUndo} onClick={onUndo}
disabled={!hasPdf || isBusy || !canUndo} disabled={!hasPdf || isBusy || !canUndo}
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'} title={latestUndo ? `Undo: ${latestUndo.label}` : "Nothing to undo"}
> >
Undo Undo
</button> </button>
@@ -98,7 +98,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
className="secondary" className="secondary"
onClick={onRedo} onClick={onRedo}
disabled={!hasPdf || isBusy || !canRedo} disabled={!hasPdf || isBusy || !canRedo}
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'} title={latestRedo ? `Redo: ${latestRedo.label}` : "Nothing to redo"}
> >
Redo Redo
</button> </button>
@@ -108,9 +108,9 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
className="secondary" className="secondary"
onClick={onSaveWorkspace} onClick={onSaveWorkspace}
disabled={!hasPdf || isBusy} disabled={!hasPdf || isBusy}
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'} title={!hasPdf ? "Open a PDF first" : "Save workspace"}
> >
💾 {activeWorkspaceId ? 'Save' : 'Save as'} 💾 {activeWorkspaceId ? "Save" : "Save as"}
</button> </button>
<button <button
@@ -118,7 +118,9 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
className="secondary" className="secondary"
onClick={onResetWorkspace} onClick={onResetWorkspace}
disabled={!hasPdf || isBusy} disabled={!hasPdf || isBusy}
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'} title={
!hasPdf ? "No active workspace" : "Close the current workspace"
}
> >
Reset workspace Reset workspace
</button> </button>
@@ -136,9 +138,9 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
{workspaceDirty && hasPdf && ( {workspaceDirty && hasPdf && (
<div <div
style={{ style={{
marginTop: '0.5rem', marginTop: "0.5rem",
fontSize: '0.8rem', fontSize: "0.8rem",
color: '#92400e', color: "#92400e",
}} }}
> >
Unsaved workspace changes. Unsaved workspace changes.
@@ -148,9 +150,9 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
{workspaceMessage && ( {workspaceMessage && (
<div <div
style={{ style={{
marginTop: '0.5rem', marginTop: "0.5rem",
fontSize: '0.85rem', fontSize: "0.85rem",
color: '#166534', color: "#166534",
}} }}
> >
{workspaceMessage} {workspaceMessage}
@@ -158,15 +160,15 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
)} )}
{workspaces.length > 0 && ( {workspaces.length > 0 && (
<div style={{ marginTop: '0.75rem' }}> <div style={{ marginTop: "0.75rem" }}>
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong> <strong style={{ fontSize: "0.9rem" }}>Saved workspaces</strong>
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
gap: '0.4rem', gap: "0.4rem",
marginTop: '0.4rem', marginTop: "0.4rem",
}} }}
> >
{workspaces.map((workspace) => { {workspaces.map((workspace) => {
@@ -176,39 +178,39 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
<div <div
key={workspace.id} key={workspace.id}
style={{ style={{
border: '1px solid #e5e7eb', border: "1px solid #e5e7eb",
borderRadius: '0.5rem', borderRadius: "0.5rem",
padding: '0.5rem', padding: "0.5rem",
background: active ? '#eff6ff' : '#f9fafb', background: active ? "#eff6ff" : "#f9fafb",
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
gap: '0.75rem', gap: "0.75rem",
alignItems: 'center', alignItems: "center",
flexWrap: 'wrap', flexWrap: "wrap",
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.9rem' }}> <div style={{ fontSize: "0.9rem" }}>
<strong>{workspace.name}</strong> <strong>{workspace.name}</strong>
{active && ( {active && (
<span style={{ color: '#2563eb' }}> · active</span> <span style={{ color: "#2563eb" }}> · active</span>
)} )}
</div> </div>
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}> <div style={{ fontSize: "0.75rem", color: "#6b7280" }}>
{workspace.pdfName} · source pages:{' '} {workspace.pdfName} · source pages:{" "}
{workspace.sourcePageCount} · workspace pages:{' '} {workspace.sourcePageCount} · workspace pages:{" "}
{workspace.workspacePageCount} · undo:{' '} {workspace.workspacePageCount} · undo:{" "}
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '} {workspace.historyCount} · redo: {workspace.redoCount} ·
{new Date(workspace.updatedAt).toLocaleString()} updated {new Date(workspace.updatedAt).toLocaleString()}
</div> </div>
</div> </div>
<div <div
style={{ style={{
display: 'flex', display: "flex",
gap: '0.35rem', gap: "0.35rem",
flexWrap: 'wrap', flexWrap: "wrap",
}} }}
> >
<button <button
@@ -226,8 +228,8 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
disabled={isBusy} disabled={isBusy}
onClick={() => onDeleteWorkspace(workspace.id)} onClick={() => onDeleteWorkspace(workspace.id)}
style={{ style={{
background: '#fee2e2', background: "#fee2e2",
color: '#991b1b', color: "#991b1b",
}} }}
> >
Delete Delete
@@ -241,36 +243,36 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
)} )}
{(history.length > 0 || redoHistory.length > 0) && ( {(history.length > 0 || redoHistory.length > 0) && (
<details style={{ marginTop: '0.75rem' }} open> <details style={{ marginTop: "0.75rem" }} open>
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}> <summary style={{ cursor: "pointer", fontSize: "0.9rem" }}>
Command history ({history.length} undo / {redoHistory.length} redo) Command history ({history.length} undo / {redoHistory.length} redo)
</summary> </summary>
<div <div
style={{ style={{
marginTop: '0.5rem', marginTop: "0.5rem",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
gap: '0.25rem', gap: "0.25rem",
}} }}
> >
{history.map((entry, index) => ( {history.map((entry, index) => (
<div <div
key={entry.id} key={entry.id}
style={{ style={{
fontSize: '0.8rem', fontSize: "0.8rem",
color: '#374151', color: "#374151",
borderLeft: '3px solid #2563eb', borderLeft: "3px solid #2563eb",
paddingLeft: '0.45rem', paddingLeft: "0.45rem",
paddingTop: '0.2rem', paddingTop: "0.2rem",
paddingBottom: '0.2rem', paddingBottom: "0.2rem",
}} }}
> >
<strong> <strong>
Undo {history.length - index}. {entry.label} Undo {history.length - index}. {entry.label}
</strong> </strong>
<br /> <br />
<span style={{ color: '#6b7280' }}> <span style={{ color: "#6b7280" }}>
{new Date(entry.timestamp).toLocaleString()} {new Date(entry.timestamp).toLocaleString()}
</span> </span>
</div> </div>
@@ -278,19 +280,17 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
<div <div
style={{ style={{
margin: '0.25rem 0', margin: "0.25rem 0",
borderRadius: '999px', borderRadius: "999px",
background: '#ecfdf5', background: "#ecfdf5",
color: '#166534', color: "#166534",
fontSize: '0.8rem', fontSize: "0.8rem",
fontWeight: 600, fontWeight: 600,
alignSelf: 'flex-start', alignSelf: "flex-start",
border: '2px solid #166534', border: "2px solid #166534",
width: '100%', width: "100%",
}} }}
> ></div>
</div>
{redoHistory {redoHistory
.slice() .slice()
@@ -299,12 +299,12 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
<div <div
key={entry.id} key={entry.id}
style={{ style={{
fontSize: '0.8rem', fontSize: "0.8rem",
color: '#9ca3af', color: "#9ca3af",
borderLeft: '3px solid #d1d5db', borderLeft: "3px solid #d1d5db",
paddingLeft: '0.45rem', paddingLeft: "0.45rem",
paddingTop: '0.2rem', paddingTop: "0.2rem",
paddingBottom: '0.2rem', paddingBottom: "0.2rem",
opacity: 0.75, opacity: 0.75,
}} }}
> >
@@ -312,7 +312,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
Redo {index + 1}. {entry.label} Redo {index + 1}. {entry.label}
</strong> </strong>
<br /> <br />
<span style={{ color: '#9ca3af' }}> <span style={{ color: "#9ca3af" }}>
{new Date(entry.timestamp).toLocaleString()} {new Date(entry.timestamp).toLocaleString()}
</span> </span>
</div> </div>

View 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,
};
}

View File

@@ -1,10 +1,10 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import App from './App'; import App from "./App";
import './styles.css'; import "./styles.css";
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
); );

View File

@@ -1,10 +1,20 @@
import { PDFDocument, degrees } from 'pdf-lib'; import { PDFDocument, degrees } from "pdf-lib";
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes'; import type { PdfFile, PageRef, SplitResult, Range } from "./pdfTypes";
function createId() { function createId() {
return Math.random().toString(36).slice(2); return Math.random().toString(36).slice(2);
} }
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function pdfBytesToBlob(bytes: Uint8Array): Blob {
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: "application/pdf" });
}
export async function loadPdfFromFile(file: File): Promise<PdfFile> { export async function loadPdfFromFile(file: File): Promise<PdfFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const doc = await PDFDocument.load(arrayBuffer); const doc = await PDFDocument.load(arrayBuffer);
@@ -21,10 +31,10 @@ export async function loadPdfFromFile(file: File): Promise<PdfFile> {
export async function mergePdfFiles( export async function mergePdfFiles(
basePdf: PdfFile, basePdf: PdfFile,
newPdf: PdfFile, newPdf: PdfFile,
insertAt: number insertAt: number,
): Promise<PdfFile> { ): Promise<PdfFile> {
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer); const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer); const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
const mergedDoc = await PDFDocument.create(); const mergedDoc = await PDFDocument.create();
@@ -35,11 +45,11 @@ export async function mergePdfFiles(
const basePages = await mergedDoc.copyPages( const basePages = await mergedDoc.copyPages(
baseDoc, baseDoc,
Array.from({ length: basePageCount }, (_, i) => i) Array.from({ length: basePageCount }, (_, i) => i),
); );
const newPages = await mergedDoc.copyPages( const newPages = await mergedDoc.copyPages(
newDoc, newDoc,
Array.from({ length: newPageCount }, (_, i) => i) Array.from({ length: newPageCount }, (_, i) => i),
); );
for (let i = 0; i < clampedInsertAt; i += 1) { for (let i = 0; i < clampedInsertAt; i += 1) {
@@ -53,11 +63,10 @@ export async function mergePdfFiles(
} }
const bytes = await mergedDoc.save(); const bytes = await mergedDoc.save();
const buffer = new ArrayBuffer(bytes.byteLength); const buffer = pdfBytesToArrayBuffer(bytes);
new Uint8Array(buffer).set(bytes);
const baseName = basePdf.name.replace(/\.pdf$/i, ''); const baseName = basePdf.name.replace(/\.pdf$/i, "");
const newName = newPdf.name.replace(/\.pdf$/i, ''); const newName = newPdf.name.replace(/\.pdf$/i, "");
return { return {
id: createId(), id: createId(),
@@ -69,7 +78,7 @@ export async function mergePdfFiles(
} }
export async function splitIntoSinglePages( export async function splitIntoSinglePages(
pdf: PdfFile pdf: PdfFile,
): Promise<SplitResult[]> { ): Promise<SplitResult[]> {
const { doc, name } = pdf; const { doc, name } = pdf;
@@ -99,10 +108,10 @@ export async function splitIntoSinglePages(
if (modificationDate) newDoc.setModificationDate(modificationDate); if (modificationDate) newDoc.setModificationDate(modificationDate);
const bytes = await newDoc.save(); const bytes = await newDoc.save();
const blob = new Blob([bytes], { type: 'application/pdf' }); const blob = pdfBytesToBlob(bytes);
const base = name.replace(/\.pdf$/i, ''); const base = name.replace(/\.pdf$/i, "");
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`; const filename = `${base}_page_${String(i + 1).padStart(3, "0")}.pdf`;
results.push({ results.push({
pageIndex: i, pageIndex: i,
@@ -114,10 +123,7 @@ export async function splitIntoSinglePages(
return results; return results;
} }
export async function extractRange( export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
pdf: PdfFile,
range: Range
): Promise<Blob> {
const { doc } = pdf; const { doc } = pdf;
const pageCount = doc.getPageCount(); const pageCount = doc.getPageCount();
@@ -125,7 +131,7 @@ export async function extractRange(
const toIndex = Math.min(pageCount - 1, range.to - 1); const toIndex = Math.min(pageCount - 1, range.to - 1);
if (fromIndex > toIndex) { if (fromIndex > toIndex) {
throw new Error('Invalid range: from > to'); throw new Error("Invalid range: from > to");
} }
const newDoc = await PDFDocument.create(); const newDoc = await PDFDocument.create();
@@ -136,7 +142,7 @@ export async function extractRange(
copiedPages.forEach((p) => newDoc.addPage(p)); copiedPages.forEach((p) => newDoc.addPage(p));
const bytes = await newDoc.save(); const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' }); return pdfBytesToBlob(bytes);
} }
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> { export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
@@ -150,26 +156,26 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
} }
const bytes = await newDoc.save(); const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' }); return pdfBytesToBlob(bytes);
} }
export async function exportPages( export async function exportPages(
pdf: PdfFile, pdf: PdfFile,
pages: PageRef[] pages: PageRef[],
): Promise<Blob> { ): Promise<Blob> {
const { doc } = pdf; const { doc } = pdf;
const pageCount = doc.getPageCount(); const pageCount = doc.getPageCount();
if (pages.length === 0) { if (pages.length === 0) {
throw new Error('Pages must contain at least one page'); throw new Error("Pages must contain at least one page");
} }
if ( if (
pages.some( pages.some(
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount (page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount,
) )
) { ) {
throw new Error('Pages contain invalid source page indices'); throw new Error("Pages contain invalid source page indices");
} }
const newDoc = await PDFDocument.create(); const newDoc = await PDFDocument.create();
@@ -180,7 +186,7 @@ export async function exportPages(
copiedPages.forEach((page, idx) => { copiedPages.forEach((page, idx) => {
const angle = pages[idx].rotation; const angle = pages[idx].rotation;
if (typeof angle === 'number' && angle % 360 !== 0) { if (typeof angle === "number" && angle % 360 !== 0) {
page.setRotation(degrees(angle)); page.setRotation(degrees(angle));
} }
@@ -188,13 +194,13 @@ export async function exportPages(
}); });
const bytes = await newDoc.save(); const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' }); return pdfBytesToBlob(bytes);
} }
export async function exportReordered( export async function exportReordered(
pdf: PdfFile, pdf: PdfFile,
order: number[], order: number[],
rotations?: Record<number, number> rotations?: Record<number, number>,
): Promise<Blob> { ): Promise<Blob> {
return exportPages( return exportPages(
pdf, pdf,
@@ -202,6 +208,6 @@ export async function exportReordered(
id: String(sourcePageIndex), id: String(sourcePageIndex),
sourcePageIndex, sourcePageIndex,
rotation: rotations?.[sourcePageIndex] ?? 0, rotation: rotations?.[sourcePageIndex] ?? 0,
})) })),
); );
} }

View File

@@ -1,5 +1,5 @@
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url'; import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
// pdf.js worker setup for Vite // pdf.js worker setup for Vite
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -37,13 +37,12 @@ interface ThumbnailGenerationOptions {
onThumbnail?: (update: ThumbnailUpdate) => void; onThumbnail?: (update: ThumbnailUpdate) => void;
} }
/** /**
* Unrotated thumbnails used e.g. in the Split/Extract view. * Unrotated thumbnails used e.g. in the Split/Extract view.
*/ */
export async function generateThumbnailsProgressive( export async function generateThumbnailsProgressive(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
options: ThumbnailGenerationOptions = {} options: ThumbnailGenerationOptions = {},
): Promise<string[]> { ): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, {}, options); return generateThumbnailsInternal(arrayBuffer, {}, options);
} }
@@ -54,7 +53,7 @@ export async function generateThumbnailsProgressive(
export async function generateThumbnailsWithRotationsProgressive( export async function generateThumbnailsWithRotationsProgressive(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
rotations: RotationsMap, rotations: RotationsMap,
options: ThumbnailGenerationOptions = {} options: ThumbnailGenerationOptions = {},
): Promise<string[]> { ): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, rotations, options); return generateThumbnailsInternal(arrayBuffer, rotations, options);
} }
@@ -62,7 +61,7 @@ export async function generateThumbnailsWithRotationsProgressive(
async function generateThumbnailsInternal( async function generateThumbnailsInternal(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
rotations: RotationsMap, rotations: RotationsMap,
options: ThumbnailGenerationOptions = {} options: ThumbnailGenerationOptions = {},
): Promise<string[]> { ): Promise<string[]> {
const maxHeight = options.maxHeight ?? 150; const maxHeight = options.maxHeight ?? 150;
const maxWidth = options.maxWidth ?? 140; const maxWidth = options.maxWidth ?? 140;
@@ -73,15 +72,15 @@ async function generateThumbnailsInternal(
const loadingTask = pdfjsLib.getDocument({ data: dataCopy }); const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
const thumbs = Array<string>(pdf.numPages).fill(''); const thumbs = Array<string>(pdf.numPages).fill("");
const pageNums = options.pageIndices const pageNums = options.pageIndices
? Array.from( ? Array.from(
new Set( new Set(
options.pageIndices options.pageIndices
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages) .filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
.map((pageIndex) => pageIndex + 1) .map((pageIndex) => pageIndex + 1),
) ),
) )
: Array.from({ length: pdf.numPages }, (_, index) => index + 1); : Array.from({ length: pdf.numPages }, (_, index) => index + 1);
@@ -99,7 +98,7 @@ async function generateThumbnailsInternal(
pageIndex, pageIndex,
rotations, rotations,
maxHeight, maxHeight,
maxWidth maxWidth,
); );
if (signal?.aborted) return; if (signal?.aborted) return;
@@ -133,11 +132,15 @@ async function generateThumbnailsInternal(
} }
async function renderPageThumbnail( async function renderPageThumbnail(
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>, page: Awaited<
ReturnType<
Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>["getPage"]
>
>,
originalIndex: number, originalIndex: number,
rotations: RotationsMap, rotations: RotationsMap,
maxHeight: number, maxHeight: number,
maxWidth: number maxWidth: number,
): Promise<string> { ): Promise<string> {
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });
const scaleH = maxHeight / viewport.height; const scaleH = maxHeight / viewport.height;
@@ -145,10 +148,10 @@ async function renderPageThumbnail(
const scale = Math.min(scaleH, scaleW); const scale = Math.min(scaleH, scaleW);
const scaledViewport = page.getViewport({ scale }); const scaledViewport = page.getViewport({ scale });
const baseCanvas = document.createElement('canvas'); const baseCanvas = document.createElement("canvas");
const baseCtx = baseCanvas.getContext('2d'); const baseCtx = baseCanvas.getContext("2d");
if (!baseCtx) return ''; if (!baseCtx) return "";
baseCanvas.width = scaledViewport.width; baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height; baseCanvas.height = scaledViewport.height;
@@ -164,14 +167,14 @@ async function renderPageThumbnail(
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
if (rotationDeg === 0) { if (rotationDeg === 0) {
return baseCanvas.toDataURL('image/png'); return baseCanvas.toDataURL("image/png");
} }
const rotatedCanvas = document.createElement('canvas'); const rotatedCanvas = document.createElement("canvas");
const rotatedCtx = rotatedCanvas.getContext('2d'); const rotatedCtx = rotatedCanvas.getContext("2d");
if (!rotatedCtx) { if (!rotatedCtx) {
return baseCanvas.toDataURL('image/png'); return baseCanvas.toDataURL("image/png");
} }
const rad = (rotationDeg * Math.PI) / 180; const rad = (rotationDeg * Math.PI) / 180;
@@ -204,5 +207,5 @@ async function renderPageThumbnail(
rotatedCtx.drawImage(baseCanvas, 0, 0); rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore(); rotatedCtx.restore();
return rotatedCanvas.toDataURL('image/png'); return rotatedCanvas.toDataURL("image/png");
} }

View File

@@ -1,4 +1,4 @@
import type { PDFDocument } from 'pdf-lib'; import type { PDFDocument } from "pdf-lib";
export interface PdfFile { export interface PdfFile {
id: string; id: string;

212
src/pdf/usePdfThumbnails.ts Normal file
View 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,
};
}

View File

@@ -6,7 +6,11 @@
body { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif; sans-serif;
background-color: #f3f4f6; background-color: #f3f4f6;
color: #111827; color: #111827;

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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);
});
});

View 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,
};
}

View 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);
});
});

View File

@@ -1,4 +1,4 @@
import type { PageRef } from '../pdf/pdfTypes'; import type { PageRef } from "../pdf/pdfTypes";
export interface WorkspaceCommandState { export interface WorkspaceCommandState {
pages: PageRef[]; pages: PageRef[];
@@ -26,7 +26,7 @@ export interface WorkspaceCommand extends WorkspaceCommandRecord {
} }
export function cloneCommandState( export function cloneCommandState(
state: WorkspaceCommandState state: WorkspaceCommandState,
): WorkspaceCommandState { ): WorkspaceCommandState {
return { return {
pages: state.pages.map((page) => ({ ...page })), pages: state.pages.map((page) => ({ ...page })),
@@ -58,7 +58,7 @@ export function createSnapshotCommand(params: {
} }
export function reviveWorkspaceCommand( export function reviveWorkspaceCommand(
record: WorkspaceCommandRecord record: WorkspaceCommandRecord,
): WorkspaceCommand { ): WorkspaceCommand {
return { return {
...record, ...record,
@@ -68,7 +68,7 @@ export function reviveWorkspaceCommand(
} }
export function toWorkspaceCommandRecord( export function toWorkspaceCommandRecord(
command: WorkspaceCommand command: WorkspaceCommand,
): WorkspaceCommandRecord { ): WorkspaceCommandRecord {
return { return {
id: command.id, id: command.id,

View File

@@ -2,13 +2,13 @@ import type {
LoadedWorkspace, LoadedWorkspace,
StoredWorkspace, StoredWorkspace,
WorkspaceSummary, WorkspaceSummary,
} from './workspaceTypes'; } from "./workspaceTypes";
const DB_NAME = 'pdf-tools-workspaces'; const DB_NAME = "pdf-tools-workspaces";
const DB_VERSION = 1; const DB_VERSION = 1;
const WORKSPACE_STORE = 'workspaces'; const WORKSPACE_STORE = "workspaces";
const PDF_STORE = 'pdfBinaries'; const PDF_STORE = "pdfBinaries";
interface PdfBinaryRecord { interface PdfBinaryRecord {
pdfId: string; pdfId: string;
@@ -48,21 +48,21 @@ function openWorkspaceDb(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) { if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, { const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
keyPath: 'id', keyPath: "id",
}); });
workspaceStore.createIndex('updatedAt', 'updatedAt', { workspaceStore.createIndex("updatedAt", "updatedAt", {
unique: false, unique: false,
}); });
workspaceStore.createIndex('pdfId', 'pdfId', { workspaceStore.createIndex("pdfId", "pdfId", {
unique: false, unique: false,
}); });
} }
if (!db.objectStoreNames.contains(PDF_STORE)) { if (!db.objectStoreNames.contains(PDF_STORE)) {
db.createObjectStore(PDF_STORE, { db.createObjectStore(PDF_STORE, {
keyPath: 'pdfId', keyPath: "pdfId",
}); });
} }
}; };
@@ -76,7 +76,7 @@ export async function listWorkspaces(): Promise<WorkspaceSummary[]> {
const db = await openWorkspaceDb(); const db = await openWorkspaceDb();
try { try {
const tx = db.transaction(WORKSPACE_STORE, 'readonly'); const tx = db.transaction(WORKSPACE_STORE, "readonly");
const store = tx.objectStore(WORKSPACE_STORE); const store = tx.objectStore(WORKSPACE_STORE);
const records = await requestToPromise<StoredWorkspace[]>(store.getAll()); const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
@@ -113,13 +113,13 @@ export async function saveWorkspaceToIndexedDb({
const pdfRecord: PdfBinaryRecord = { const pdfRecord: PdfBinaryRecord = {
pdfId: workspace.pdfId, pdfId: workspace.pdfId,
name: workspace.pdfName, name: workspace.pdfName,
blob: new Blob([pdfArrayBuffer], { type: 'application/pdf' }), blob: new Blob([pdfArrayBuffer], { type: "application/pdf" }),
size: pdfArrayBuffer.byteLength, size: pdfArrayBuffer.byteLength,
createdAt: workspace.createdAt, createdAt: workspace.createdAt,
updatedAt: now, updatedAt: now,
}; };
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite'); const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readwrite");
tx.objectStore(PDF_STORE).put(pdfRecord); tx.objectStore(PDF_STORE).put(pdfRecord);
tx.objectStore(WORKSPACE_STORE).put(workspace); tx.objectStore(WORKSPACE_STORE).put(workspace);
@@ -131,15 +131,15 @@ export async function saveWorkspaceToIndexedDb({
} }
export async function loadWorkspaceFromIndexedDb( export async function loadWorkspaceFromIndexedDb(
workspaceId: string workspaceId: string,
): Promise<LoadedWorkspace | null> { ): Promise<LoadedWorkspace | null> {
const db = await openWorkspaceDb(); const db = await openWorkspaceDb();
try { try {
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readonly'); const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readonly");
const workspace = await requestToPromise<StoredWorkspace | undefined>( const workspace = await requestToPromise<StoredWorkspace | undefined>(
tx.objectStore(WORKSPACE_STORE).get(workspaceId) tx.objectStore(WORKSPACE_STORE).get(workspaceId),
); );
if (!workspace) { if (!workspace) {
@@ -148,7 +148,7 @@ export async function loadWorkspaceFromIndexedDb(
} }
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>( const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
tx.objectStore(PDF_STORE).get(workspace.pdfId) tx.objectStore(PDF_STORE).get(workspace.pdfId),
); );
await transactionDone(tx); await transactionDone(tx);
@@ -169,20 +169,20 @@ export async function loadWorkspaceFromIndexedDb(
} }
export async function deleteWorkspaceFromIndexedDb( export async function deleteWorkspaceFromIndexedDb(
workspaceId: string workspaceId: string,
): Promise<void> { ): Promise<void> {
const db = await openWorkspaceDb(); const db = await openWorkspaceDb();
try { try {
const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly'); const lookupTx = db.transaction(WORKSPACE_STORE, "readonly");
const workspace = await requestToPromise<StoredWorkspace | undefined>( const workspace = await requestToPromise<StoredWorkspace | undefined>(
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId) lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId),
); );
await transactionDone(lookupTx); await transactionDone(lookupTx);
if (!workspace) return; if (!workspace) return;
const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite'); const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], "readwrite");
deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId); deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId);
await transactionDone(deleteTx); await transactionDone(deleteTx);
@@ -190,14 +190,14 @@ export async function deleteWorkspaceFromIndexedDb(
const remainingWorkspaces = await listWorkspaces(); const remainingWorkspaces = await listWorkspaces();
const pdfStillUsed = remainingWorkspaces.some( const pdfStillUsed = remainingWorkspaces.some(
(summary) => summary.pdfId === workspace.pdfId (summary) => summary.pdfId === workspace.pdfId,
); );
if (!pdfStillUsed) { if (!pdfStillUsed) {
const cleanupDb = await openWorkspaceDb(); const cleanupDb = await openWorkspaceDb();
try { try {
const cleanupTx = cleanupDb.transaction(PDF_STORE, 'readwrite'); const cleanupTx = cleanupDb.transaction(PDF_STORE, "readwrite");
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId); cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
await transactionDone(cleanupTx); await transactionDone(cleanupTx);
} finally { } finally {

View File

@@ -1,5 +1,5 @@
import type { PageRef } from '../pdf/pdfTypes'; import type { PageRef } from "../pdf/pdfTypes";
import type { WorkspaceCommandRecord } from './workspaceCommands'; import type { WorkspaceCommandRecord } from "./workspaceCommands";
export interface StoredWorkspace { export interface StoredWorkspace {
schemaVersion: 1; schemaVersion: 1;

View File

@@ -1,11 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react-swc'; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: true, host: true,
allowedHosts: ['pdftools.add-ideas.de'], // ← ADD THIS allowedHosts: ["pdftools.add-ideas.de"], // ← ADD THIS
}, },
}); });