Compare commits

..

7 Commits

36 changed files with 6161 additions and 2403 deletions

4
.prettierignore 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
}

View File

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

61
README
View File

@@ -4,7 +4,7 @@
Current hosted version: <https://pdftools.add-ideas.de>
Current baseline: **v0.2.0 — Browser-only PDF workspace baseline**. See [`CHANGELOG.md`](CHANGELOG.md) for the release notes and milestone history.
Current release: **v0.3.0 — Selection workspace and maintenance release**. See [`CHANGELOG.md`](CHANGELOG.md) for release notes and milestone history.
The app is a static React/Vite single-page application. There is no backend service, no server-side queue, and no server-side document storage. When hosted correctly, the server only delivers HTML, JavaScript, CSS, and static assets; PDF processing happens in the user's browser.
@@ -70,22 +70,23 @@ This makes the project especially useful for self-hosted environments, public-se
- Export the current reordered/rotated/duplicated/deleted workspace as a new PDF.
- Extract selected pages into a new PDF.
- Open selected pages as a new active workspace for continued editing.
- Split the source PDF into single-page PDFs.
- Merge another PDF by replacing, appending, or inserting it into the current workspace.
### Keyboard shortcuts
| Shortcut | Action |
| --- | --- |
| `F1` / `?` | Open in-app help and tutorial |
| `Ctrl`/`⌘` + `A` | Select all pages |
| `Delete` / `Backspace` | Delete selected pages after confirmation |
| `Esc` | Clear the current selection or close an open dialog |
| `Ctrl`/`⌘` + `Z` | Undo |
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
| `Ctrl`/`⌘` + `Y` | Redo |
| `←` / `→` in preview | Move to previous / next page |
| `Esc` in preview | Close preview |
| Shortcut | Action |
| -------------------------- | --------------------------------------------------- |
| `F1` / `?` | Open in-app help and tutorial |
| `Ctrl`/`⌘` + `A` | Select all pages |
| `Delete` / `Backspace` | Delete selected pages after confirmation |
| `Esc` | Clear the current selection or close an open dialog |
| `Ctrl`/`⌘` + `Z` | Undo |
| `Ctrl`/`⌘` + `Shift` + `Z` | Redo |
| `Ctrl`/`⌘` + `Y` | Redo |
| `←` / `→` in preview | Move to previous / next page |
| `Esc` in preview | Close preview |
Keyboard shortcuts are ignored while typing in form fields.
@@ -115,25 +116,35 @@ browser <-- HTTPS --> static web server / reverse proxy --> built app assets
There is no application server to operate. Administrators only need to host the built files from `dist/`.
### Build from source
### Build and test from source
```bash
npm ci
npm run check
```
`npm run check` runs the main project quality gate:
```bash
npm run typecheck
npm run lint
npm run test
npm run build
```
The production build is written to `dist/`.
To preview the production build locally:
Useful individual development commands:
```bash
npm run preview
```
For development:
```bash
npm run dev
npm run dev # start the Vite development server
npm run preview # preview the production build locally
npm run test # run tests once
npm run test:watch # run tests in watch mode
npm run typecheck # run TypeScript without emitting files
npm run lint # run ESLint
npm run format # format the project with Prettier
npm run format:check # verify Prettier formatting
```
### Static hosting
@@ -216,14 +227,16 @@ The application version shown in the header is defined in `src/version.ts`. The
The current development baseline is:
```text
v0.2.0 — Browser-only PDF workspace baseline
v0.3.0 — Selection workspace and maintenance release
```
This release preserves the browser-only workspace baseline and adds the first post-refactor feature: opening selected pages as a new active workspace. Workspace state, thumbnail handling, generated download URLs, page-grid components, tests, type-checking, linting, and formatting are separated enough to support additional feature work without turning `App.tsx` back into a monolith.
## Project structure
```text
src/
App.tsx Main application state and orchestration
App.tsx Main application orchestration and UI wiring
components/
ActionDialog.tsx Reusable confirmation/action dialog
ActionsPanel.tsx Export, extract, and split actions
@@ -270,7 +283,7 @@ src/
- [x] Add command history as a foundation for undo/redo.
- [x] Add undo/redo.
- [x] Display undo/redo history with redo entries visually separated.
- [ ] Extract selection as a new active workspace.
- [x] Extract selection as a new active workspace.
- [ ] Reduce undo/redo storage footprint if large documents make snapshots too heavy.
- [ ] Add grid/list view toggle.

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

4410
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -190,4 +190,4 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
);
};
export default ActionDialog;
export default ActionDialog;

View File

@@ -1,5 +1,8 @@
import React from 'react';
import type { SplitResult } from '../pdf/pdfTypes';
import type {
PdfDownload,
SplitPdfDownload,
} from '../hooks/usePdfGeneratedOutputs';
interface ActionsPanelProps {
hasPdf: boolean;
@@ -9,13 +12,12 @@ interface ActionsPanelProps {
onSplit: () => void;
onExtractSelected: () => void;
onOpenSelectionAsWorkspace: () => void;
onExportReordered: () => void;
splitResults: SplitResult[];
subsetDownloadUrl: string | null;
subsetFilename: string | null;
exportDownloadUrl: string | null;
exportFilename: string | null;
splitDownloads: SplitPdfDownload[];
subsetDownload: PdfDownload | null;
exportDownload: PdfDownload | null;
}
const ActionsPanel: React.FC<ActionsPanelProps> = ({
@@ -24,12 +26,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
selectedCount,
onSplit,
onExtractSelected,
onOpenSelectionAsWorkspace,
onExportReordered,
splitResults,
subsetDownloadUrl,
subsetFilename,
exportDownloadUrl,
exportFilename,
splitDownloads,
subsetDownload,
exportDownload,
}) => {
const disabled = !hasPdf || isBusy;
@@ -38,6 +39,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
onExtractSelected();
};
const handleOpenSelectionAsWorkspaceClick = () => {
if (selectedCount === 0) return;
onOpenSelectionAsWorkspace();
};
return (
<div className="card">
<h2>Tools</h2>
@@ -73,6 +79,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
📤 Extract selected ({selectedCount})
</button>
<button
className="secondary"
disabled={disabled || selectedCount === 0}
onClick={handleOpenSelectionAsWorkspaceClick}
style={{ flex: '1 1 45%' }}
title={
selectedCount === 0
? 'Select at least one page'
: 'Open selected pages as a new unsaved workspace'
}
>
🧩 Open selection as workspace
</button>
<button
className="secondary"
disabled={disabled}
@@ -83,52 +103,46 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
</button>
</div>
{subsetDownloadUrl && subsetFilename && (
{subsetDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Subset result:</strong>{' '}
<a
className="download-link"
href={subsetDownloadUrl}
download={subsetFilename}
href={subsetDownload.url}
download={subsetDownload.filename}
>
Download {subsetFilename}
Download {subsetDownload.filename}
</a>
</div>
)}
{exportDownloadUrl && exportFilename && (
{exportDownload && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Exported document:</strong>{' '}
<a
className="download-link"
href={exportDownloadUrl}
download={exportFilename}
href={exportDownload.url}
download={exportDownload.filename}
>
Download {exportFilename}
Download {exportDownload.filename}
</a>
</div>
)}
{splitResults.length > 0 && (
{splitDownloads.length > 0 && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
<strong>Single-page PDFs:</strong>
<div>
{splitResults.map((r) => {
const url = URL.createObjectURL(r.blob);
return (
<a
key={r.pageIndex}
className="download-link"
href={url}
download={r.filename}
onClick={() => {
setTimeout(() => URL.revokeObjectURL(url), 5000);
}}
>
{r.filename}
</a>
);
})}
{splitDownloads.map((download) => (
<a
key={download.id}
className="download-link"
href={download.url}
download={download.filename}
>
{download.filename}
</a>
))}
</div>
</div>
)}

View File

@@ -7,13 +7,28 @@ interface HelpDialogProps {
const shortcuts = [
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' },
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' },
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
{
keys: 'Ctrl/⌘ + A',
description: 'Select all pages in the current workspace',
},
{
keys: 'Delete / Backspace',
description: 'Delete the selected pages after confirmation',
},
{
keys: 'Esc',
description: 'Clear the page selection or close an open dialog',
},
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' },
{
keys: 'Ctrl/⌘ + Shift + Z',
description: 'Redo the next workspace command',
},
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
{ keys: '← / → in preview', description: 'Move to the previous or next page in the preview overlay' },
{
keys: '← / → in preview',
description: 'Move to the previous or next page in the preview overlay',
},
];
const tutorialSteps = [
@@ -30,11 +45,15 @@ const tutorialSteps = [
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.',
},
{
title: '4. Save your workspace or export a PDF',
title: '4. Extract selected pages or branch into a new workspace',
body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
},
{
title: '5. Save your workspace or export a PDF',
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
},
{
title: '5. Use history deliberately',
title: '6. Use history deliberately',
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
},
];
@@ -79,8 +98,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h2 id="help-dialog-title">Help & tutorial</h2>
<p>
PDF Workbench is a browser-only page workspace. Use it to quickly
rearrange, split, merge, rotate, duplicate, and export PDFs without
uploading documents to a server.
rearrange, split, merge, rotate, duplicate, and export PDFs
without uploading documents to a server.
</p>
</div>
@@ -119,8 +138,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
))}
</div>
<p className="help-note">
Shortcuts are ignored while typing in text fields or other editable
controls.
Shortcuts are ignored while typing in text fields or other
editable controls.
</p>
</section>

View File

@@ -44,4 +44,4 @@ const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
);
};
export default Layout;
export default Layout;

View File

@@ -10,10 +10,10 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
interface PagePreviewModalProps {
isOpen: boolean;
pdf: PdfFile | null;
pageIndex: number | null; // original page index, 0-based
rotation: number; // degrees
pageIndex: number | null; // original page index, 0-based
rotation: number; // degrees
visualIndex: number | null; // current position in order, 0-based
visualIndex: number | null; // current position in order, 0-based
totalPages: number;
canGoPrevious: boolean;
@@ -134,6 +134,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx,
viewport: scaledViewport,
});
@@ -323,4 +324,4 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
);
};
export default PagePreviewModal;
export default PagePreviewModal;

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 CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
import PageGrid from './PageWorkspace/PageGrid';
import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
interface ReorderPanelProps {
pages: PageRef[];
@@ -53,8 +56,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const dragGhostRef = useRef<HTMLDivElement | null>(null);
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
const cleanupDragGhost = () => {
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
};
const resetDragState = () => {
cleanupDragGhost();
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
};
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
setDraggingIndex(visualIndex);
setDropIndex(visualIndex);
@@ -122,10 +130,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
};
const handleDragEnd = () => {
cleanupDragGhost();
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
resetDragState();
};
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setIsCopyDragging(false);
};
const handleDeleteClick = (pageId: string) => () => {
const handleDeleteClick = (pageId: string) => {
onDelete(pageId);
setDraggingIndex(null);
setDropIndex(null);
};
const handleRotateClickClockwise = (pageId: string) => () => {
onRotateClockwise(pageId);
};
const handleRotateClickCounterclockwise = (pageId: string) => () => {
onRotateCounterclockwise(pageId);
};
const handleCardClick = (pageId: string) => () => {
onOpenPreview(pageId);
};
const handleCheckboxClick =
(pageId: string, visualIndex: number) =>
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // don't trigger preview
onToggleSelect(pageId, visualIndex, e);
};
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onToggleSelect(pageId, visualIndex, e);
};
const handleCopySelectedClick = () => {
if (selectedPageIds.length === 0) return;
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
setCopyTargetPosition(String(pages.length + 1));
setCopyDialogError(null);
setCopyDialogOpen(true);
};
@@ -245,6 +238,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setCopyDialogError(null);
};
const handleCopyTargetPositionChange = (value: string) => {
setCopyTargetPosition(value);
setCopyDialogError(null);
};
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
e?.preventDefault();
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
setCopyDialogError(null);
};
useEffect(() => {
if (!copyDialogOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleCopyDialogCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [copyDialogOpen]);
if (!hasPdf) {
return (
<div className="card">
@@ -293,18 +274,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
);
}
const showLeftLine = (visualIndex: number) =>
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
const showRightLine = (visualIndex: number) =>
dropIndex !== null &&
dropIndex === visualIndex + 1 &&
draggingIndex !== null;
const showEndLine = () =>
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
// For highlighting the whole selection while dragging it
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
const draggingSelectionActive =
draggingPage != null &&
@@ -318,529 +287,53 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
<h2>Pages</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Tap/click a page to preview it. Use the checkbox to select pages
(Shift for ranges). Drag to reorder; dragging a selected page moves the
whole selection. Hold Ctrl/ while dropping to copy instead of move.
Shortcuts: Ctrl/+A selects all, Delete removes selected pages, Esc clears
selection.
(Shift for ranges). Drag to reorder; dragging a selected page moves
the whole selection. Hold Ctrl/ while dropping to copy instead of
move. Shortcuts: Ctrl/+A selects all, Delete removes selected pages,
Esc clears selection.
</p>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
fontSize: '0.85rem',
}}
>
<span>
Selected: <strong>{selectedPageIds.length}</strong>
</span>
<div
style={{
display: 'flex',
gap: '0.4rem',
}}
>
{selectedPageIds.length > 0 && (
<button
type="button"
onClick={handleCopySelectedClick}
disabled={selectedPageIds.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#dcfce7',
color: '#166534',
cursor: 'pointer',
}}
title={'Copy selected pages to another position'}
>
Copy selected
</button>
)}
{selectedPageIds.length > 0 && (
<button
type="button"
onClick={onDeleteSelected}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#fee2e2',
color: '#b91c1c',
cursor: 'pointer',
}}
>
Delete selected
</button>
)}
<button
type="button"
onClick={onSelectAll}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#8dcd8d',
color: '#111827',
cursor: 'pointer',
}}
>
Select all
</button>
<button
type="button"
onClick={onClearSelection}
disabled={selectedPageIds.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#e5e7eb',
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
}}
>
Clear selection
</button>
</div>
</div>
<PageSelectionToolbar
selectedCount={selectedPageIds.length}
onCopySelected={handleCopySelectedClick}
onDeleteSelected={onDeleteSelected}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
/>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
alignItems: 'flex-start',
marginBottom: '0.75rem',
}}
<PageGrid
pages={pages}
thumbnails={thumbnails}
selectedPageIds={selectedPageIds}
isBusy={isBusy}
draggingIndex={draggingIndex}
dropIndex={dropIndex}
draggingSelectionActive={draggingSelectionActive}
isCopyDragging={isCopyDragging}
dropIndicatorColor={dropIndicatorColor}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onCardDragOver={handleCardDragOver}
onEndSlotDragOver={handleEndSlotDragOver}
onDrop={handleDrop}
>
{pages.map((page, visualIndex) => {
const thumb = thumbnails[page.id];
const rotation = page.rotation;
const selected = isSelected(page.id);
const isDraggingCard =
draggingIndex != null &&
((draggingSelectionActive && selected) ||
(!draggingSelectionActive && visualIndex === draggingIndex));
return (
<div
key={page.id}
draggable
onDragStart={handleDragStart(visualIndex)}
onDragEnd={handleDragEnd}
onDragOver={handleCardDragOver(visualIndex)}
onClick={handleCardClick(page.id)}
style={{
position: 'relative',
width: '162px',
padding: '0.4rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
background: isDraggingCard
? isCopyDragging
? '#dcfce7'
: '#dbeafe'
: selected
? '#eff6ff'
: '#f9fafb',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
opacity: isBusy ? 0.7 : 1,
}}
>
{/* selection checkbox */}
<button
type="button"
onClick={handleCheckboxClick(page.id, visualIndex)}
style={{
position: 'absolute',
top: '4px',
left: '4px',
width: '20px',
height: '20px',
borderRadius: '0.4rem',
border: '1px solid #9ca3af',
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
color: selected ? 'white' : 'transparent',
fontSize: '0.8rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
cursor: 'pointer',
}}
title="Select page"
>
</button>
{/* left drop indicator */}
{showLeftLine(visualIndex) && (
<div
style={{
position: 'absolute',
left: '-4px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: dropIndicatorColor,
}}
/>
)}
{/* right drop indicator */}
{showRightLine(visualIndex) && (
<div
style={{
position: 'absolute',
right: '-4px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: dropIndicatorColor,
}}
/>
)}
<div
style={{
width: '110px',
height: '90px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{thumb ? (
<img
src={thumb}
alt={`Page ${page.sourcePageIndex + 1}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: '0.25rem',
border: '1px solid #e5e7eb',
background: 'white',
}}
/>
) : (
<div
style={{
width: '60px',
height: '80px',
borderRadius: '0.25rem',
border: '1px dashed #d1d5db',
background: '#f3f4f6',
}}
/>
)}
</div>
<span style={{ fontSize: '0.8rem' }}>Page {page.sourcePageIndex + 1}</span>
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
Pos {visualIndex + 1} · Rot {rotation}°
</span>
<div
style={{
display: 'flex',
gap: '0.25rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRotateClickClockwise(page.id)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRotateClickCounterclockwise(page.id)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
90°
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(page.id)();
}}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
background: '#fecaca',
color: '#b91c1c',
cursor: 'pointer',
}}
title="Remove this page from the exported PDF"
>
</button>
</div>
</div>
);
})}
{/* end slot for dropping after the last card */}
{pages.length > 0 && (
<div
onDragOver={handleEndSlotDragOver}
onDrop={handleDrop}
style={{
width: '20px',
height: '120px',
position: 'relative',
alignSelf: 'stretch',
}}
>
{showEndLine() && (
<div
style={{
position: 'absolute',
left: '8px',
top: '4px',
bottom: '4px',
width: '3px',
borderRadius: '999px',
background: dropIndicatorColor,
}}
/>
)}
</div>
)}
</div>
onOpenPreview={onOpenPreview}
onToggleSelect={handleCheckboxClick}
onRotateClockwise={onRotateClockwise}
onRotateCounterclockwise={onRotateCounterclockwise}
onDelete={handleDeleteClick}
/>
</div>
{copyDialogOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="copy-pages-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
handleCopyDialogCancel();
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<form
onSubmit={handleCopyDialogConfirm}
style={{
width: '100%',
maxWidth: '420px',
background: 'white',
borderRadius: '0.75rem',
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
}}
>
<h2
id="copy-pages-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
Copy selected pages
</h2>
<button
type="button"
onClick={handleCopyDialogCancel}
style={{
border: 'none',
borderRadius: '999px',
width: '1.8rem',
height: '1.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close copy dialog"
>
×
</button>
</div>
<p
style={{
margin: 0,
fontSize: '0.9rem',
color: '#4b5563',
}}
>
Copy{' '}
<strong>
{selectedPageIds.length === 1
? '1 selected page'
: `${selectedPageIds.length} selected pages`}
</strong>{' '}
to a new position.
</p>
<label
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
fontSize: '0.9rem',
}}
>
Insert before position
<input
type="number"
min={1}
max={pages.length + 1}
value={copyTargetPosition}
autoFocus
onChange={(e) => {
setCopyTargetPosition(e.target.value);
setCopyDialogError(null);
}}
style={{
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.95rem',
}}
/>
</label>
<div
style={{
fontSize: '0.8rem',
color: '#6b7280',
lineHeight: 1.4,
}}
>
<div>1 = before the first page</div>
<div>{pages.length + 1} = after the last page</div>
</div>
{copyDialogError && (
<div
style={{
borderRadius: '0.5rem',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
padding: '0.5rem',
fontSize: '0.85rem',
}}
>
{copyDialogError}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={handleCopyDialogCancel}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Cancel
</button>
<button
type="submit"
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#16a34a',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Copy pages
</button>
</div>
</form>
</div>
<CopyPagesDialog
selectedCount={selectedPageIds.length}
pageCount={pages.length}
targetPosition={copyTargetPosition}
error={copyDialogError}
onTargetPositionChange={handleCopyTargetPositionChange}
onCancel={handleCopyDialogCancel}
onConfirm={handleCopyDialogConfirm}
/>
)}
</>
);

View File

@@ -3,325 +3,325 @@ import type { WorkspaceSummary } from '../workspace/workspaceTypes';
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
interface WorkspacePanelProps {
hasPdf: boolean;
isBusy: boolean;
hasPdf: boolean;
isBusy: boolean;
activeWorkspaceId: string | null;
workspaceName: string;
workspaceDirty: boolean;
workspaceMessage: string | null;
activeWorkspaceId: string | null;
workspaceName: string;
workspaceDirty: boolean;
workspaceMessage: string | null;
workspaces: WorkspaceSummary[];
history: WorkspaceCommandRecord[];
redoHistory: WorkspaceCommandRecord[];
workspaces: WorkspaceSummary[];
history: WorkspaceCommandRecord[];
redoHistory: WorkspaceCommandRecord[];
onWorkspaceNameChange: (value: string) => void;
onSaveWorkspace: () => void;
onLoadWorkspace: (workspaceId: string) => void;
onDeleteWorkspace: (workspaceId: string) => void;
onRefreshWorkspaces: () => void;
onResetWorkspace: () => void;
onUndo: () => void;
onRedo: () => void;
onWorkspaceNameChange: (value: string) => void;
onSaveWorkspace: () => void;
onLoadWorkspace: (workspaceId: string) => void;
onDeleteWorkspace: (workspaceId: string) => void;
onRefreshWorkspaces: () => void;
onResetWorkspace: () => void;
onUndo: () => void;
onRedo: () => void;
}
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
hasPdf,
isBusy,
activeWorkspaceId,
workspaceName,
workspaceDirty,
workspaceMessage,
workspaces,
history,
redoHistory,
onWorkspaceNameChange,
onSaveWorkspace,
onLoadWorkspace,
onDeleteWorkspace,
onRefreshWorkspaces,
onResetWorkspace,
onUndo,
onRedo,
hasPdf,
isBusy,
activeWorkspaceId,
workspaceName,
workspaceDirty,
workspaceMessage,
workspaces,
history,
redoHistory,
onWorkspaceNameChange,
onSaveWorkspace,
onLoadWorkspace,
onDeleteWorkspace,
onRefreshWorkspaces,
onResetWorkspace,
onUndo,
onRedo,
}) => {
const canUndo = history.length > 0;
const canRedo = redoHistory.length > 0;
const canUndo = history.length > 0;
const canRedo = redoHistory.length > 0;
const latestUndo = history[history.length - 1];
const latestRedo = redoHistory[redoHistory.length - 1];
const latestUndo = history[history.length - 1];
const latestRedo = redoHistory[redoHistory.length - 1];
return (
<div className="card">
<h2>Workspace</h2>
return (
<div className="card">
<h2>Workspace</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Save named workspaces in this browser. PDF binaries are stored in
IndexedDB; nothing is uploaded.
</p>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Save named workspaces in this browser. PDF binaries are stored in
IndexedDB; nothing is uploaded.
</p>
<div
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<input
type="text"
value={workspaceName}
onChange={(e) => onWorkspaceNameChange(e.target.value)}
placeholder="Workspace name"
disabled={!hasPdf || isBusy}
style={{
flex: '1 1 220px',
minWidth: 0,
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.9rem',
}}
/>
<button
type="button"
className="secondary"
onClick={onUndo}
disabled={!hasPdf || isBusy || !canUndo}
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
>
Undo
</button>
<button
type="button"
className="secondary"
onClick={onRedo}
disabled={!hasPdf || isBusy || !canRedo}
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
>
Redo
</button>
<button
type="button"
className="secondary"
onClick={onSaveWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
>
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
</button>
<button
type="button"
className="secondary"
onClick={onResetWorkspace}
disabled={!hasPdf || isBusy}
title={
!hasPdf ? 'No active workspace' : 'Close the current workspace'
}
>
Reset workspace
</button>
<button
type="button"
className="secondary"
onClick={onRefreshWorkspaces}
disabled={isBusy}
>
Refresh
</button>
</div>
{workspaceDirty && hasPdf && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.8rem',
color: '#92400e',
}}
>
Unsaved workspace changes.
</div>
)}
{workspaceMessage && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.85rem',
color: '#166534',
}}
>
{workspaceMessage}
</div>
)}
{workspaces.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.4rem',
}}
>
{workspaces.map((workspace) => {
const active = workspace.id === activeWorkspaceId;
return (
<div
key={workspace.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
padding: '0.5rem',
background: active ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.9rem' }}>
<strong>{workspace.name}</strong>
{active && (
<span style={{ color: '#2563eb' }}> · active</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
{workspace.pdfName} · source pages:{' '}
{workspace.sourcePageCount} · workspace pages:{' '}
{workspace.workspacePageCount} · undo:{' '}
{workspace.historyCount} · redo: {workspace.redoCount} ·
updated {new Date(workspace.updatedAt).toLocaleString()}
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.35rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onLoadWorkspace(workspace.id)}
>
Load
</button>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onDeleteWorkspace(workspace.id)}
style={{
background: '#fee2e2',
color: '#991b1b',
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(history.length > 0 || redoHistory.length > 0) && (
<details style={{ marginTop: '0.75rem' }} open>
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Command history ({history.length} undo / {redoHistory.length} redo)
</summary>
<div
style={{
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
{history.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#374151',
borderLeft: '3px solid #2563eb',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
}}
>
<strong>
Undo {history.length - index}. {entry.label}
</strong>
<br />
<span style={{ color: '#6b7280' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
<div
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<input
type="text"
value={workspaceName}
onChange={(e) => onWorkspaceNameChange(e.target.value)}
placeholder="Workspace name"
disabled={!hasPdf || isBusy}
style={{
flex: '1 1 220px',
minWidth: 0,
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.9rem',
}}
/>
style={{
margin: '0.25rem 0',
borderRadius: '999px',
background: '#ecfdf5',
color: '#166534',
fontSize: '0.8rem',
fontWeight: 600,
alignSelf: 'flex-start',
border: '2px solid #166534',
width: '100%',
}}
></div>
<button
type="button"
className="secondary"
onClick={onUndo}
disabled={!hasPdf || isBusy || !canUndo}
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
>
Undo
</button>
<button
type="button"
className="secondary"
onClick={onRedo}
disabled={!hasPdf || isBusy || !canRedo}
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
>
Redo
</button>
<button
type="button"
className="secondary"
onClick={onSaveWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
>
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
</button>
<button
type="button"
className="secondary"
onClick={onResetWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'}
>
Reset workspace
</button>
<button
type="button"
className="secondary"
onClick={onRefreshWorkspaces}
disabled={isBusy}
>
Refresh
</button>
</div>
{workspaceDirty && hasPdf && (
{redoHistory
.slice()
.reverse()
.map((entry, index) => (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.8rem',
color: '#92400e',
}}
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#9ca3af',
borderLeft: '3px solid #d1d5db',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
opacity: 0.75,
}}
>
Unsaved workspace changes.
<strong>
Redo {index + 1}. {entry.label}
</strong>
<br />
<span style={{ color: '#9ca3af' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
)}
{workspaceMessage && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.85rem',
color: '#166534',
}}
>
{workspaceMessage}
</div>
)}
{workspaces.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.4rem',
}}
>
{workspaces.map((workspace) => {
const active = workspace.id === activeWorkspaceId;
return (
<div
key={workspace.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
padding: '0.5rem',
background: active ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.9rem' }}>
<strong>{workspace.name}</strong>
{active && (
<span style={{ color: '#2563eb' }}> · active</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
{workspace.pdfName} · source pages:{' '}
{workspace.sourcePageCount} · workspace pages:{' '}
{workspace.workspacePageCount} · undo:{' '}
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
{new Date(workspace.updatedAt).toLocaleString()}
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.35rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onLoadWorkspace(workspace.id)}
>
Load
</button>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onDeleteWorkspace(workspace.id)}
style={{
background: '#fee2e2',
color: '#991b1b',
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(history.length > 0 || redoHistory.length > 0) && (
<details style={{ marginTop: '0.75rem' }} open>
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Command history ({history.length} undo / {redoHistory.length} redo)
</summary>
<div
style={{
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
{history.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#374151',
borderLeft: '3px solid #2563eb',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
}}
>
<strong>
Undo {history.length - index}. {entry.label}
</strong>
<br />
<span style={{ color: '#6b7280' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
<div
style={{
margin: '0.25rem 0',
borderRadius: '999px',
background: '#ecfdf5',
color: '#166534',
fontSize: '0.8rem',
fontWeight: 600,
alignSelf: 'flex-start',
border: '2px solid #166534',
width: '100%',
}}
>
</div>
{redoHistory
.slice()
.reverse()
.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#9ca3af',
borderLeft: '3px solid #d1d5db',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
opacity: 0.75,
}}
>
<strong>
Redo {index + 1}. {entry.label}
</strong>
<br />
<span style={{ color: '#9ca3af' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
</div>
</details>
)}
</div>
);
))}
</div>
</details>
)}
</div>
);
};
export default WorkspacePanel;
export default WorkspacePanel;

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

@@ -5,6 +5,16 @@ function createId() {
return Math.random().toString(36).slice(2);
}
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function pdfBytesToBlob(bytes: Uint8Array): Blob {
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: 'application/pdf' });
}
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFDocument.load(arrayBuffer);
@@ -23,8 +33,8 @@ export async function mergePdfFiles(
newPdf: PdfFile,
insertAt: number
): Promise<PdfFile> {
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
const mergedDoc = await PDFDocument.create();
@@ -53,8 +63,7 @@ export async function mergePdfFiles(
}
const bytes = await mergedDoc.save();
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const buffer = pdfBytesToArrayBuffer(bytes);
const baseName = basePdf.name.replace(/\.pdf$/i, '');
const newName = newPdf.name.replace(/\.pdf$/i, '');
@@ -99,7 +108,7 @@ export async function splitIntoSinglePages(
if (modificationDate) newDoc.setModificationDate(modificationDate);
const bytes = await newDoc.save();
const blob = new Blob([bytes], { type: 'application/pdf' });
const blob = pdfBytesToBlob(bytes);
const base = name.replace(/\.pdf$/i, '');
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
@@ -114,10 +123,7 @@ export async function splitIntoSinglePages(
return results;
}
export async function extractRange(
pdf: PdfFile,
range: Range
): Promise<Blob> {
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
@@ -136,7 +142,7 @@ export async function extractRange(
copiedPages.forEach((p) => newDoc.addPage(p));
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
@@ -150,7 +156,7 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
}
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportPages(
@@ -188,7 +194,7 @@ export async function exportPages(
});
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportReordered(
@@ -204,4 +210,4 @@ export async function exportReordered(
rotation: rotations?.[sourcePageIndex] ?? 0,
}))
);
}
}

View File

@@ -37,7 +37,6 @@ interface ThumbnailGenerationOptions {
onThumbnail?: (update: ThumbnailUpdate) => void;
}
/**
* Unrotated thumbnails used e.g. in the Split/Extract view.
*/
@@ -112,7 +111,7 @@ async function generateThumbnailsInternal(
while (!signal?.aborted) {
const pageNum = pageNums[nextPageIndex];
nextPageIndex += 1;
if (pageNum == null) return;
await renderOne(pageNum);
@@ -133,7 +132,11 @@ async function generateThumbnailsInternal(
}
async function renderPageThumbnail(
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
page: Awaited<
ReturnType<
Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']
>
>,
originalIndex: number,
rotations: RotationsMap,
maxHeight: number,
@@ -154,6 +157,7 @@ async function renderPageThumbnail(
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx,
viewport: scaledViewport,
});
@@ -205,4 +209,4 @@ async function renderPageThumbnail(
rotatedCtx.restore();
return rotatedCanvas.toDataURL('image/png');
}
}

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 {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background-color: #f3f4f6;
color: #111827;

View File

@@ -1 +1 @@
export const APP_VERSION = '0.2.0';
export const APP_VERSION = '0.3.0';

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,250 @@
import { useCallback, useRef, useState } from 'react';
import type { PageRef } from '../pdf/pdfTypes';
import type {
WorkspaceCommand,
WorkspaceCommandRecord,
WorkspaceCommandState,
} from './workspaceCommands';
import {
createSnapshotCommand,
reviveWorkspaceCommand,
toWorkspaceCommandRecord,
} from './workspaceCommands';
function createId(prefix: string): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
export function createWorkspaceId(): string {
return createId('workspace');
}
export function createPdfId(): string {
return createId('pdf');
}
export function defaultWorkspaceNameFromPdfName(pdfName: string): string {
return pdfName.replace(/\.pdf$/i, '') || 'Untitled workspace';
}
export function createPageRefId(): string {
return createId('page');
}
export function createInitialPageRefs(pageCount: number): PageRef[] {
return Array.from({ length: pageCount }, (_, sourcePageIndex) => ({
id: createPageRefId(),
sourcePageIndex,
rotation: 0,
}));
}
export function normalizeRotation(rotation: number | undefined): number {
return (((rotation ?? 0) % 360) + 360) % 360;
}
type SetStateAction<T> = T | ((previous: T) => T);
interface UseWorkspaceStateOptions {
onContentChanged?: () => void;
}
interface ReplaceWorkspaceStateOptions {
pages?: PageRef[];
selectedPageIds?: string[];
lastSelectedVisualIndex?: number | null;
history?: WorkspaceCommandRecord[];
redoHistory?: WorkspaceCommandRecord[];
dirty?: boolean;
message?: string | null;
}
export function useWorkspaceState({
onContentChanged,
}: UseWorkspaceStateOptions = {}) {
const [pages, setPagesState] = useState<PageRef[]>([]);
const [selectedPageIds, setSelectedPageIdsState] = useState<string[]>([]);
const [lastSelectedVisualIndex, setLastSelectedVisualIndexState] = useState<
number | null
>(null);
const [workspaceDirty, setWorkspaceDirty] = useState(false);
const [workspaceMessage, setWorkspaceMessage] = useState<string | null>(null);
const [workspaceHistory, setWorkspaceHistory] = useState<
WorkspaceCommandRecord[]
>([]);
const [redoHistory, setRedoHistory] = useState<WorkspaceCommandRecord[]>([]);
const latestPagesRef = useRef<PageRef[]>([]);
const selectedPageIdsRef = useRef<string[]>([]);
const lastSelectedVisualIndexRef = useRef<number | null>(null);
const setPages = useCallback((action: SetStateAction<PageRef[]>) => {
setPagesState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
latestPagesRef.current = next;
return next;
});
}, []);
const setSelectedPageIds = useCallback((action: SetStateAction<string[]>) => {
setSelectedPageIdsState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
selectedPageIdsRef.current = next;
return next;
});
}, []);
const setLastSelectedVisualIndex = useCallback(
(action: SetStateAction<number | null>) => {
setLastSelectedVisualIndexState((previous) => {
const next = typeof action === 'function' ? action(previous) : action;
lastSelectedVisualIndexRef.current = next;
return next;
});
},
[]
);
const getCurrentCommandState = useCallback(
(): WorkspaceCommandState => ({
pages: latestPagesRef.current,
selectedPageIds: selectedPageIdsRef.current,
lastSelectedVisualIndex: lastSelectedVisualIndexRef.current,
}),
[]
);
const applyCommandState = useCallback(
(state: WorkspaceCommandState) => {
setPages(state.pages);
setSelectedPageIds(state.selectedPageIds);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex);
},
[setLastSelectedVisualIndex, setPages, setSelectedPageIds]
);
const markWorkspaceChanged = useCallback(() => {
setWorkspaceDirty(true);
setWorkspaceMessage(null);
onContentChanged?.();
}, [onContentChanged]);
const createWorkspaceCommand = useCallback(
(params: {
type: string;
label: string;
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}): WorkspaceCommand =>
createSnapshotCommand({
id: createId('command'),
type: params.type,
label: params.label,
before: params.before,
after: params.after,
details: params.details,
}),
[]
);
const executeWorkspaceCommand = useCallback(
(command: WorkspaceCommand) => {
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setWorkspaceHistory((previous) => [
...previous,
toWorkspaceCommandRecord(command),
]);
setRedoHistory([]);
markWorkspaceChanged();
},
[applyCommandState, getCurrentCommandState, markWorkspaceChanged]
);
const handleUndo = useCallback(() => {
const record = workspaceHistory[workspaceHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const previousState = command.undo(getCurrentCommandState());
applyCommandState(previousState);
setWorkspaceHistory((previous) => previous.slice(0, -1));
setRedoHistory((previous) => [...previous, record]);
markWorkspaceChanged();
}, [
applyCommandState,
getCurrentCommandState,
markWorkspaceChanged,
workspaceHistory,
]);
const handleRedo = useCallback(() => {
const record = redoHistory[redoHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setRedoHistory((previous) => previous.slice(0, -1));
setWorkspaceHistory((previous) => [...previous, record]);
markWorkspaceChanged();
}, [
applyCommandState,
getCurrentCommandState,
markWorkspaceChanged,
redoHistory,
]);
const replaceWorkspaceState = useCallback(
(state: ReplaceWorkspaceStateOptions = {}) => {
setPages(state.pages ?? []);
setSelectedPageIds(state.selectedPageIds ?? []);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex ?? null);
setWorkspaceHistory(state.history ?? []);
setRedoHistory(state.redoHistory ?? []);
setWorkspaceDirty(state.dirty ?? false);
setWorkspaceMessage(state.message ?? null);
},
[setLastSelectedVisualIndex, setPages, setSelectedPageIds]
);
const resetWorkspaceState = useCallback(() => {
replaceWorkspaceState();
}, [replaceWorkspaceState]);
return {
pages,
setPages,
selectedPageIds,
setSelectedPageIds,
lastSelectedVisualIndex,
setLastSelectedVisualIndex,
latestPagesRef,
workspaceDirty,
setWorkspaceDirty,
workspaceMessage,
setWorkspaceMessage,
workspaceHistory,
setWorkspaceHistory,
redoHistory,
setRedoHistory,
getCurrentCommandState,
applyCommandState,
createWorkspaceCommand,
executeWorkspaceCommand,
handleUndo,
handleRedo,
replaceWorkspaceState,
resetWorkspaceState,
};
}

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

@@ -81,4 +81,4 @@ export function toWorkspaceCommandRecord(
details: command.payload.details,
},
};
}
}

View File

@@ -190,7 +190,7 @@ export async function deleteWorkspaceFromIndexedDb(
const remainingWorkspaces = await listWorkspaces();
const pdfStillUsed = remainingWorkspaces.some(
(summary) => summary.pdfId === workspace.pdfId
(summary) => summary.pdfId === workspace.pdfId
);
if (!pdfStillUsed) {
@@ -207,4 +207,4 @@ export async function deleteWorkspaceFromIndexedDb(
} finally {
db.close();
}
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { PageRef } from '../pdf/pdfTypes';
import {
createSelectionPdfName,
createSelectionWorkspaceName,
getSelectedPagesInVisualOrder,
} from './workspaceSelection';
function page(id: string, sourcePageIndex: number): PageRef {
return { id, sourcePageIndex, rotation: 0 };
}
describe('workspaceSelection', () => {
it('returns selected pages in current visual order', () => {
const pages = [page('page-3', 2), page('page-1', 0), page('page-2', 1)];
expect(getSelectedPagesInVisualOrder(pages, ['page-2', 'page-3'])).toEqual([
pages[0],
pages[2],
]);
});
it('ignores selected ids that are no longer present', () => {
const pages = [page('page-1', 0), page('page-2', 1)];
expect(getSelectedPagesInVisualOrder(pages, ['missing', 'page-2'])).toEqual(
[pages[1]]
);
});
it('creates readable derived workspace and PDF names', () => {
expect(createSelectionWorkspaceName('contract.pdf', 3)).toBe(
'contract - 3-page-selection'
);
expect(createSelectionPdfName('contract.pdf', 1)).toBe(
'contract - 1-page-selection.pdf'
);
});
});

View File

@@ -0,0 +1,31 @@
import type { PageRef } from '../pdf/pdfTypes';
export function getSelectedPagesInVisualOrder(
pages: PageRef[],
selectedPageIds: string[]
): PageRef[] {
if (pages.length === 0 || selectedPageIds.length === 0) return [];
const selectedSet = new Set(selectedPageIds);
return pages.filter((page) => selectedSet.has(page.id));
}
export function createSelectionWorkspaceName(
pdfName: string,
selectedPageCount: number
): string {
const baseName = pdfName.replace(/\.pdf$/i, '') || 'Untitled';
const suffix =
selectedPageCount === 1
? '1-page-selection'
: `${selectedPageCount}-page-selection`;
return `${baseName} - ${suffix}`;
}
export function createSelectionPdfName(
pdfName: string,
selectedPageCount: number
): string {
return `${createSelectionWorkspaceName(pdfName, selectedPageCount)}.pdf`;
}

View File

@@ -37,4 +37,4 @@ export interface WorkspaceSummary {
export interface LoadedWorkspace {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}
}

View File

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