mostly formatting, dependency fix

This commit is contained in:
2026-05-17 02:39:32 +02:00
parent a5dc70aabf
commit cf9a0dd0b7
32 changed files with 837 additions and 836 deletions

View File

@@ -1,49 +1,49 @@
import js from "@eslint/js"; import js from '@eslint/js';
import eslintConfigPrettier from "eslint-config-prettier"; import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from 'eslint-plugin-react-refresh';
import globals from "globals"; import globals from 'globals';
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ {
ignores: ["dist", "coverage", "node_modules"], ignores: ['dist', 'coverage', 'node_modules'],
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
{ {
files: ["**/*.{ts,tsx}"], files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
sourceType: "module", sourceType: 'module',
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.es2022, ...globals.es2022,
}, },
}, },
plugins: { plugins: {
"react-hooks": reactHooks, 'react-hooks': reactHooks,
"react-refresh": reactRefresh, 'react-refresh': reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [ 'react-refresh/only-export-components': [
"warn", 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
"react-hooks/set-state-in-effect": "off", 'react-hooks/set-state-in-effect': 'off',
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-unused-vars': [
"warn", 'warn',
{ {
argsIgnorePattern: "^_", argsIgnorePattern: '^_',
varsIgnorePattern: "^_", varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: "^_", caughtErrorsIgnorePattern: '^_',
}, },
], ],
}, },
}, },
{ {
files: ["*.config.{js,ts}", "eslint.config.js"], files: ['*.config.{js,ts}', 'eslint.config.js'],
languageOptions: { languageOptions: {
globals: { globals: {
...globals.node, ...globals.node,
@@ -51,5 +51,5 @@ export default tseslint.config(
}, },
}, },
}, },
eslintConfigPrettier, eslintConfigPrettier
); );

View File

@@ -1,19 +1,19 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from 'react';
import Layout from "./components/Layout"; import Layout from './components/Layout';
import FileLoader from "./components/FileLoader"; import FileLoader from './components/FileLoader';
import ReorderPanel from "./components/ReorderPanel"; import ReorderPanel from './components/ReorderPanel';
import ActionsPanel from "./components/ActionsPanel"; import ActionsPanel from './components/ActionsPanel';
import PagePreviewModal from "./components/PagePreviewModal"; import PagePreviewModal from './components/PagePreviewModal';
import WorkspacePanel from "./components/WorkspacePanel"; import WorkspacePanel from './components/WorkspacePanel';
import ActionDialog, { import ActionDialog, {
type ActionDialogAction, type ActionDialogAction,
} from "./components/ActionDialog"; } from './components/ActionDialog';
import HelpDialog from "./components/HelpDialog"; import HelpDialog from './components/HelpDialog';
import { PDFDocument } from "pdf-lib"; import { PDFDocument } from 'pdf-lib';
import type { import type {
StoredWorkspace, StoredWorkspace,
WorkspaceSummary, WorkspaceSummary,
} from "./workspace/workspaceTypes"; } from './workspace/workspaceTypes';
import { import {
createInitialPageRefs, createInitialPageRefs,
createPageRefId, createPageRefId,
@@ -22,22 +22,22 @@ import {
defaultWorkspaceNameFromPdfName, defaultWorkspaceNameFromPdfName,
normalizeRotation, normalizeRotation,
useWorkspaceState, useWorkspaceState,
} from "./workspace/useWorkspaceState"; } from './workspace/useWorkspaceState';
import { import {
deleteWorkspaceFromIndexedDb, deleteWorkspaceFromIndexedDb,
listWorkspaces, listWorkspaces,
loadWorkspaceFromIndexedDb, loadWorkspaceFromIndexedDb,
saveWorkspaceToIndexedDb, saveWorkspaceToIndexedDb,
} from "./workspace/workspaceDb"; } from './workspace/workspaceDb';
import type { PageRef, PdfFile } from "./pdf/pdfTypes"; import type { PageRef, PdfFile } from './pdf/pdfTypes';
import { import {
loadPdfFromFile, loadPdfFromFile,
mergePdfFiles, mergePdfFiles,
splitIntoSinglePages, splitIntoSinglePages,
exportPages, exportPages,
} from "./pdf/pdfService"; } from './pdf/pdfService';
import { usePdfThumbnails } from "./pdf/usePdfThumbnails"; import { usePdfThumbnails } from './pdf/usePdfThumbnails';
import { usePdfGeneratedOutputs } from "./hooks/usePdfGeneratedOutputs"; import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
import { import {
createSelectionPdfName, createSelectionPdfName,
createSelectionWorkspaceName, createSelectionWorkspaceName,
@@ -50,9 +50,9 @@ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
const tagName = target.tagName.toLowerCase(); const tagName = target.tagName.toLowerCase();
return ( return (
target.isContentEditable || target.isContentEditable ||
tagName === "input" || tagName === 'input' ||
tagName === "textarea" || tagName === 'textarea' ||
tagName === "select" tagName === 'select'
); );
} }
@@ -70,18 +70,18 @@ const App: React.FC = () => {
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]); const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>( const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(
null, null
); );
const [workspaceName, setWorkspaceName] = useState(""); const [workspaceName, setWorkspaceName] = useState('');
const [previewPageId, setPreviewPageId] = useState<string | null>(null); const [previewPageId, setPreviewPageId] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFile, setPendingFile] = useState<File | null>(null);
const [showMergeOptions, setShowMergeOptions] = useState(false); const [showMergeOptions, setShowMergeOptions] = useState(false);
const [mergeMode, setMergeMode] = useState< const [mergeMode, setMergeMode] = useState<
"overwrite" | "append" | "insertAt" 'overwrite' | 'append' | 'insertAt'
>("append"); >('append');
const [mergeInsertAt, setMergeInsertAt] = useState<string>(""); const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
const { const {
splitDownloads, splitDownloads,
@@ -123,7 +123,7 @@ const App: React.FC = () => {
console.error(thrown); console.error(thrown);
setError(message); setError(message);
}, },
[], []
); );
const { thumbnails: reorderThumbnails, clearThumbnailCache } = const { thumbnails: reorderThumbnails, clearThumbnailCache } =
@@ -154,7 +154,7 @@ const App: React.FC = () => {
setWorkspaces(summaries); setWorkspaces(summaries);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to read saved workspaces from browser storage."); setError('Failed to read saved workspaces from browser storage.');
} }
}; };
@@ -165,7 +165,7 @@ const App: React.FC = () => {
const resetWorkspaceState = () => { const resetWorkspaceState = () => {
setPdf(null); setPdf(null);
setActiveWorkspaceId(null); setActiveWorkspaceId(null);
setWorkspaceName(""); setWorkspaceName('');
resetWorkspaceCommandState(); resetWorkspaceCommandState();
clearGeneratedOutputs(); clearGeneratedOutputs();
clearThumbnailCache(); clearThumbnailCache();
@@ -183,7 +183,7 @@ const App: React.FC = () => {
const workspaceId = activeWorkspaceId ?? createWorkspaceId(); const workspaceId = activeWorkspaceId ?? createWorkspaceId();
const existing = workspaces.find( const existing = workspaces.find(
(workspace) => workspace.id === workspaceId, (workspace) => workspace.id === workspaceId
); );
const workspace: StoredWorkspace = { const workspace: StoredWorkspace = {
@@ -221,7 +221,7 @@ const App: React.FC = () => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError( setError(
"Failed to save workspace. The browser storage quota may be full.", 'Failed to save workspace. The browser storage quota may be full.'
); );
return false; return false;
} finally { } finally {
@@ -242,7 +242,7 @@ const App: React.FC = () => {
} }
openActionDialog({ openActionDialog({
title: "Reset workspace?", title: 'Reset workspace?',
content: ( content: (
<> <>
<p style={{ marginTop: 0 }}>This workspace has unsaved changes.</p> <p style={{ marginTop: 0 }}>This workspace has unsaved changes.</p>
@@ -253,21 +253,21 @@ const App: React.FC = () => {
), ),
actions: [ actions: [
{ {
label: "Cancel", label: 'Cancel',
variant: "secondary", variant: 'secondary',
onClick: closeActionDialog, onClick: closeActionDialog,
}, },
{ {
label: "Reset without saving", label: 'Reset without saving',
variant: "danger", variant: 'danger',
onClick: () => { onClick: () => {
closeActionDialog(); closeActionDialog();
performResetWorkspace(); performResetWorkspace();
}, },
}, },
{ {
label: "Save and reset", label: 'Save and reset',
variant: "primary", variant: 'primary',
autoFocus: true, autoFocus: true,
onClick: async () => { onClick: async () => {
closeActionDialog(); closeActionDialog();
@@ -289,7 +289,7 @@ const App: React.FC = () => {
const loaded = await loadWorkspaceFromIndexedDb(workspaceId); const loaded = await loadWorkspaceFromIndexedDb(workspaceId);
if (!loaded) { if (!loaded) {
setError("Workspace not found."); setError('Workspace not found.');
await refreshWorkspaces(); await refreshWorkspaces();
return; return;
} }
@@ -324,7 +324,7 @@ const App: React.FC = () => {
setWorkspaceName(loaded.workspace.name); setWorkspaceName(loaded.workspace.name);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to load workspace from browser storage."); setError('Failed to load workspace from browser storage.');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
@@ -332,10 +332,10 @@ const App: React.FC = () => {
const handleDeleteWorkspace = (workspaceId: string) => { const handleDeleteWorkspace = (workspaceId: string) => {
const workspace = workspaces.find((item) => item.id === workspaceId); const workspace = workspaces.find((item) => item.id === workspaceId);
const name = workspace?.name ?? "this workspace"; const name = workspace?.name ?? 'this workspace';
openActionDialog({ openActionDialog({
title: "Delete workspace?", title: 'Delete workspace?',
content: ( content: (
<> <>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
@@ -350,13 +350,13 @@ const App: React.FC = () => {
), ),
actions: [ actions: [
{ {
label: "Cancel", label: 'Cancel',
variant: "secondary", variant: 'secondary',
onClick: closeActionDialog, onClick: closeActionDialog,
}, },
{ {
label: "Delete workspace", label: 'Delete workspace',
variant: "danger", variant: 'danger',
autoFocus: true, autoFocus: true,
onClick: () => { onClick: () => {
closeActionDialog(); closeActionDialog();
@@ -377,14 +377,14 @@ const App: React.FC = () => {
setActiveWorkspaceId(null); setActiveWorkspaceId(null);
setWorkspaceDirty(true); setWorkspaceDirty(true);
setWorkspaceMessage( setWorkspaceMessage(
"Saved workspace deleted. Current in-memory document remains open.", 'Saved workspace deleted. Current in-memory document remains open.'
); );
} }
await refreshWorkspaces(); await refreshWorkspaces();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to delete workspace."); setError('Failed to delete workspace.');
} }
}; };
@@ -411,7 +411,7 @@ const App: React.FC = () => {
setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name)); setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to load PDF (see console)."); setError('Failed to load PDF (see console).');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
@@ -423,7 +423,7 @@ const App: React.FC = () => {
} else { } else {
setPendingFile(file); setPendingFile(file);
setShowMergeOptions(true); setShowMergeOptions(true);
setMergeMode("append"); setMergeMode('append');
setMergeInsertAt(String(pages.length + 1)); setMergeInsertAt(String(pages.length + 1));
} }
}; };
@@ -436,7 +436,7 @@ const App: React.FC = () => {
const handleMergeConfirm = async () => { const handleMergeConfirm = async () => {
if (!pendingFile) return; if (!pendingFile) return;
if (!pdf || mergeMode === "overwrite") { if (!pdf || mergeMode === 'overwrite') {
await loadFileAsNew(pendingFile); await loadFileAsNew(pendingFile);
setPendingFile(null); setPendingFile(null);
setShowMergeOptions(false); setShowMergeOptions(false);
@@ -464,12 +464,12 @@ const App: React.FC = () => {
// 3) Determine insert position (0-based) // 3) Determine insert position (0-based)
let insertAt = pages.length; // default: append at end let insertAt = pages.length; // default: append at end
if (mergeMode === "insertAt") { if (mergeMode === 'insertAt') {
const parsed = parseInt(mergeInsertAt, 10); const parsed = parseInt(mergeInsertAt, 10);
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
insertAt = Math.min(Math.max(parsed - 1, 0), pages.length); insertAt = Math.min(Math.max(parsed - 1, 0), pages.length);
} }
} else if (mergeMode === "append") { } else if (mergeMode === 'append') {
insertAt = pages.length; insertAt = pages.length;
} }
@@ -495,7 +495,7 @@ const App: React.FC = () => {
setActiveWorkspaceId(null); setActiveWorkspaceId(null);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to merge PDF (see console)."); setError('Failed to merge PDF (see console).');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
setPendingFile(null); setPendingFile(null);
@@ -518,16 +518,16 @@ const App: React.FC = () => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableKeyboardTarget(e.target)) return; if (isEditableKeyboardTarget(e.target)) return;
if (e.key === "F1" || e.key === "?") { if (e.key === 'F1' || e.key === '?') {
e.preventDefault(); e.preventDefault();
setHelpOpen(true); setHelpOpen(true);
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, []); }, []);
@@ -537,13 +537,13 @@ const App: React.FC = () => {
const afterPages = pages.map((page) => const afterPages = pages.map((page) =>
page.id === pageId page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 } ? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
: page, : page
); );
executeWorkspaceCommand( executeWorkspaceCommand(
createWorkspaceCommand({ createWorkspaceCommand({
type: "page.rotate", type: 'page.rotate',
label: "Rotated page clockwise", label: 'Rotated page clockwise',
before, before,
after: { after: {
...before, ...before,
@@ -553,7 +553,7 @@ const App: React.FC = () => {
pageId, pageId,
degrees: 90, degrees: 90,
}, },
}), })
); );
}; };
@@ -562,13 +562,13 @@ const App: React.FC = () => {
const afterPages = pages.map((page) => const afterPages = pages.map((page) =>
page.id === pageId page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 } ? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
: page, : page
); );
executeWorkspaceCommand( executeWorkspaceCommand(
createWorkspaceCommand({ createWorkspaceCommand({
type: "page.rotate", type: 'page.rotate',
label: "Rotated page counterclockwise", label: 'Rotated page counterclockwise',
before, before,
after: { after: {
...before, ...before,
@@ -578,7 +578,7 @@ const App: React.FC = () => {
pageId, pageId,
degrees: -90, degrees: -90,
}, },
}), })
); );
}; };
@@ -586,10 +586,10 @@ const App: React.FC = () => {
const page = pages.find((item) => item.id === pageId); const page = pages.find((item) => item.id === pageId);
const visualIndex = page ? pages.indexOf(page) : -1; const visualIndex = page ? pages.indexOf(page) : -1;
const pageLabel = const pageLabel =
visualIndex >= 0 ? `page at position ${visualIndex + 1}` : "this page"; visualIndex >= 0 ? `page at position ${visualIndex + 1}` : 'this page';
openActionDialog({ openActionDialog({
title: "Delete page?", title: 'Delete page?',
content: ( content: (
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
Delete <strong>{pageLabel}</strong> from the current workspace? Delete <strong>{pageLabel}</strong> from the current workspace?
@@ -597,13 +597,13 @@ const App: React.FC = () => {
), ),
actions: [ actions: [
{ {
label: "Cancel", label: 'Cancel',
variant: "secondary", variant: 'secondary',
onClick: closeActionDialog, onClick: closeActionDialog,
}, },
{ {
label: "Delete page", label: 'Delete page',
variant: "danger", variant: 'danger',
autoFocus: true, autoFocus: true,
onClick: () => { onClick: () => {
closeActionDialog(); closeActionDialog();
@@ -619,8 +619,8 @@ const App: React.FC = () => {
executeWorkspaceCommand( executeWorkspaceCommand(
createWorkspaceCommand({ createWorkspaceCommand({
type: "page.delete", type: 'page.delete',
label: "Deleted page", label: 'Deleted page',
before, before,
after: { after: {
pages: pages.filter((page) => page.id !== pageId), pages: pages.filter((page) => page.id !== pageId),
@@ -630,7 +630,7 @@ const App: React.FC = () => {
details: { details: {
pageId, pageId,
}, },
}), })
); );
}; };
@@ -639,8 +639,8 @@ const App: React.FC = () => {
executeWorkspaceCommand( executeWorkspaceCommand(
createWorkspaceCommand({ createWorkspaceCommand({
type: "pages.reorder", type: 'pages.reorder',
label: "Reordered pages", label: 'Reordered pages',
before, before,
after: { after: {
...before, ...before,
@@ -649,14 +649,14 @@ const App: React.FC = () => {
details: { details: {
pageCount: newPages.length, pageCount: newPages.length,
}, },
}), })
); );
}; };
const handleToggleSelect = ( const handleToggleSelect = (
pageId: string, pageId: string,
visualIndex: number, visualIndex: number,
e: React.MouseEvent<HTMLButtonElement>, e: React.MouseEvent<HTMLButtonElement>
) => { ) => {
setSelectedPageIds((prev) => { setSelectedPageIds((prev) => {
if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) { if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) {
@@ -731,28 +731,28 @@ const App: React.FC = () => {
openActionDialog({ openActionDialog({
title: title:
idsToDelete.length === 1 idsToDelete.length === 1
? "Delete selected page?" ? 'Delete selected page?'
: "Delete selected pages?", : 'Delete selected pages?',
content: ( content: (
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
Delete{" "} Delete{' '}
<strong> <strong>
{idsToDelete.length === 1 {idsToDelete.length === 1
? "1 selected page" ? '1 selected page'
: `${idsToDelete.length} selected pages`} : `${idsToDelete.length} selected pages`}
</strong>{" "} </strong>{' '}
from the current workspace? from the current workspace?
</p> </p>
), ),
actions: [ actions: [
{ {
label: "Cancel", label: 'Cancel',
variant: "secondary", variant: 'secondary',
onClick: closeActionDialog, onClick: closeActionDialog,
}, },
{ {
label: idsToDelete.length === 1 ? "Delete page" : "Delete pages", label: idsToDelete.length === 1 ? 'Delete page' : 'Delete pages',
variant: "danger", variant: 'danger',
autoFocus: true, autoFocus: true,
onClick: () => { onClick: () => {
closeActionDialog(); closeActionDialog();
@@ -795,10 +795,10 @@ const App: React.FC = () => {
executeWorkspaceCommand( executeWorkspaceCommand(
createWorkspaceCommand({ createWorkspaceCommand({
type: "pages.copy", type: 'pages.copy',
label: label:
copiedPages.length === 1 copiedPages.length === 1
? "Copied page" ? 'Copied page'
: `Copied ${copiedPages.length} pages`, : `Copied ${copiedPages.length} pages`,
before, before,
after: { after: {
@@ -810,7 +810,7 @@ const App: React.FC = () => {
count: copiedPages.length, count: copiedPages.length,
insertSlot: clampedSlot, insertSlot: clampedSlot,
}, },
}), })
); );
}; };
@@ -852,7 +852,7 @@ const App: React.FC = () => {
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === "z") { if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
handleRedo(); handleRedo();
@@ -862,13 +862,13 @@ const App: React.FC = () => {
return; return;
} }
if ((e.ctrlKey || e.metaKey) && key === "y") { if ((e.ctrlKey || e.metaKey) && key === 'y') {
e.preventDefault(); e.preventDefault();
handleRedo(); handleRedo();
return; return;
} }
if ((e.ctrlKey || e.metaKey) && key === "a") { if ((e.ctrlKey || e.metaKey) && key === 'a') {
e.preventDefault(); e.preventDefault();
setSelectedPageIds(pages.map((page) => page.id)); setSelectedPageIds(pages.map((page) => page.id));
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
@@ -876,7 +876,7 @@ const App: React.FC = () => {
} }
if ( if (
(e.key === "Delete" || e.key === "Backspace") && (e.key === 'Delete' || e.key === 'Backspace') &&
selectedPageIds.length > 0 selectedPageIds.length > 0
) { ) {
e.preventDefault(); e.preventDefault();
@@ -884,17 +884,17 @@ const App: React.FC = () => {
return; return;
} }
if (e.key === "Escape" && selectedPageIds.length > 0) { if (e.key === 'Escape' && selectedPageIds.length > 0) {
e.preventDefault(); e.preventDefault();
setSelectedPageIds([]); setSelectedPageIds([]);
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [ }, [
hasPdf, hasPdf,
@@ -919,7 +919,7 @@ const App: React.FC = () => {
replaceSplitResults(result); replaceSplitResults(result);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Error while splitting PDF (see console)."); setError('Error while splitting PDF (see console).');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
@@ -939,18 +939,17 @@ const App: React.FC = () => {
if (selectedPages.length === 0) return; if (selectedPages.length === 0) return;
const blob = await exportPages(pdf, selectedPages); const blob = await exportPages(pdf, selectedPages);
const base = pdf.name.replace(/\.pdf$/i, ""); const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_selected.pdf`; const filename = `${base}_selected.pdf`;
replaceSubsetResult(blob, filename); replaceSubsetResult(blob, filename);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Error while extracting selected pages (see console)."); setError('Error while extracting selected pages (see console).');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
}; };
const performOpenSelectionAsWorkspace = async () => { const performOpenSelectionAsWorkspace = async () => {
if (!pdf || selectedPageIds.length === 0) return; if (!pdf || selectedPageIds.length === 0) return;
@@ -988,7 +987,7 @@ const App: React.FC = () => {
redoHistory: [], redoHistory: [],
dirty: true, dirty: true,
message: `Created a new workspace from ${selectedPageCount} selected ${ message: `Created a new workspace from ${selectedPageCount} selected ${
selectedPageCount === 1 ? "page" : "pages" selectedPageCount === 1 ? 'page' : 'pages'
}.`, }.`,
}); });
@@ -999,7 +998,7 @@ const App: React.FC = () => {
clearThumbnailCache(); clearThumbnailCache();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Error while opening selection as a new workspace."); setError('Error while opening selection as a new workspace.');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
@@ -1017,13 +1016,13 @@ const App: React.FC = () => {
} }
openActionDialog({ openActionDialog({
title: "Open selection as new workspace?", title: 'Open selection as new workspace?',
content: ( content: (
<> <>
<p style={{ marginTop: 0 }}> <p style={{ marginTop: 0 }}>
This will replace the current in-memory workspace with a new This will replace the current in-memory workspace with a new
workspace built from {selectedPages.length}{' '} workspace built from {selectedPages.length}{' '}
{selectedPages.length === 1 ? "selected page" : "selected pages"}. {selectedPages.length === 1 ? 'selected page' : 'selected pages'}.
</p> </p>
<p style={{ marginBottom: 0 }}> <p style={{ marginBottom: 0 }}>
The current workspace has unsaved changes. Do you want to save it The current workspace has unsaved changes. Do you want to save it
@@ -1033,21 +1032,21 @@ const App: React.FC = () => {
), ),
actions: [ actions: [
{ {
label: "Cancel", label: 'Cancel',
variant: "secondary", variant: 'secondary',
onClick: closeActionDialog, onClick: closeActionDialog,
}, },
{ {
label: "Open without saving", label: 'Open without saving',
variant: "danger", variant: 'danger',
onClick: () => { onClick: () => {
closeActionDialog(); closeActionDialog();
void performOpenSelectionAsWorkspace(); void performOpenSelectionAsWorkspace();
}, },
}, },
{ {
label: "Save and open", label: 'Save and open',
variant: "primary", variant: 'primary',
autoFocus: true, autoFocus: true,
onClick: async () => { onClick: async () => {
closeActionDialog(); closeActionDialog();
@@ -1068,12 +1067,12 @@ const App: React.FC = () => {
try { try {
const blob = await exportPages(pdf, pages); const blob = await exportPages(pdf, pages);
const base = pdf.name.replace(/\.pdf$/i, ""); const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_reordered.pdf`; const filename = `${base}_reordered.pdf`;
replaceExportResult(blob, filename); replaceExportResult(blob, filename);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Error while exporting reordered PDF (see console)."); setError('Error while exporting reordered PDF (see console).');
} finally { } finally {
setIsBusy(false); setIsBusy(false);
} }
@@ -1120,62 +1119,62 @@ const App: React.FC = () => {
{showMergeOptions && pendingFile && pdf && pages.length > 0 && ( {showMergeOptions && pendingFile && pdf && pages.length > 0 && (
<div <div
className="card" className="card"
style={{ border: "1px solid #bfdbfe", background: "#eff6ff" }} style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
> >
<h2>Open file: merge or replace?</h2> <h2>Open file: merge or replace?</h2>
<p style={{ fontSize: "0.85rem", color: "#374151" }}> <p style={{ fontSize: '0.85rem', color: '#374151' }}>
You already have <strong>{pdf.name}</strong> with {pages.length}{" "} You already have <strong>{pdf.name}</strong> with {pages.length}{' '}
pages open. What should happen with{" "} pages open. What should happen with{' '}
<strong>{pendingFile.name}</strong>? <strong>{pendingFile.name}</strong>?
</p> </p>
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
gap: "0.5rem", gap: '0.5rem',
marginTop: "0.5rem", marginTop: '0.5rem',
fontSize: "0.9rem", fontSize: '0.9rem',
}} }}
> >
<label <label
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
> >
<input <input
type="radio" type="radio"
name="mergeMode" name="mergeMode"
value="overwrite" value="overwrite"
checked={mergeMode === "overwrite"} checked={mergeMode === 'overwrite'}
onChange={() => setMergeMode("overwrite")} onChange={() => setMergeMode('overwrite')}
/> />
<span>Replace current document</span> <span>Replace current document</span>
</label> </label>
<label <label
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
> >
<input <input
type="radio" type="radio"
name="mergeMode" name="mergeMode"
value="append" value="append"
checked={mergeMode === "append"} checked={mergeMode === 'append'}
onChange={() => setMergeMode("append")} onChange={() => setMergeMode('append')}
/> />
<span>Merge and append pages at the end</span> <span>Merge and append pages at the end</span>
</label> </label>
<label <label
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
> >
<input <input
type="radio" type="radio"
name="mergeMode" name="mergeMode"
value="insertAt" value="insertAt"
checked={mergeMode === "insertAt"} checked={mergeMode === 'insertAt'}
onChange={() => setMergeMode("insertAt")} onChange={() => setMergeMode('insertAt')}
/> />
<span> <span>
Merge and insert starting at position{" "} Merge and insert starting at position{' '}
<input <input
type="number" type="number"
min={1} min={1}
@@ -1183,19 +1182,19 @@ const App: React.FC = () => {
value={mergeInsertAt} value={mergeInsertAt}
onChange={(e) => setMergeInsertAt(e.target.value)} onChange={(e) => setMergeInsertAt(e.target.value)}
style={{ style={{
width: "4rem", width: '4rem',
padding: "0.15rem 0.3rem", padding: '0.15rem 0.3rem',
fontSize: "0.85rem", fontSize: '0.85rem',
}} }}
/>{" "} />{' '}
<span style={{ color: "#6b7280" }}> <span style={{ color: '#6b7280' }}>
(1 = before first page, {pages.length + 1} = after last page) (1 = before first page, {pages.length + 1} = after last page)
</span> </span>
</span> </span>
</label> </label>
</div> </div>
<div className="button-row" style={{ marginTop: "0.75rem" }}> <div className="button-row" style={{ marginTop: '0.75rem' }}>
<button <button
className="secondary" className="secondary"
type="button" type="button"
@@ -1210,7 +1209,7 @@ const App: React.FC = () => {
onClick={handleMergeConfirm} onClick={handleMergeConfirm}
disabled={isBusy} disabled={isBusy}
> >
{isBusy ? "Working…" : "Continue"} {isBusy ? 'Working…' : 'Continue'}
</button> </button>
</div> </div>
</div> </div>
@@ -1250,7 +1249,7 @@ const App: React.FC = () => {
{error && ( {error && (
<div <div
className="card" className="card"
style={{ border: "1px solid #fecaca", background: "#fef2f2" }} style={{ border: '1px solid #fecaca', background: '#fef2f2' }}
> >
<strong>Error:</strong> {error} <strong>Error:</strong> {error}
</div> </div>
@@ -1272,7 +1271,7 @@ const App: React.FC = () => {
<ActionDialog <ActionDialog
open={actionDialog !== null} open={actionDialog !== null}
title={actionDialog?.title ?? ""} title={actionDialog?.title ?? ''}
actions={actionDialog?.actions ?? []} actions={actionDialog?.actions ?? []}
onClose={closeActionDialog} onClose={closeActionDialog}
> >

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,8 +1,8 @@
import React from "react"; import React from 'react';
import type { import type {
PdfDownload, PdfDownload,
SplitPdfDownload, SplitPdfDownload,
} from "../hooks/usePdfGeneratedOutputs"; } from '../hooks/usePdfGeneratedOutputs';
interface ActionsPanelProps { interface ActionsPanelProps {
hasPdf: boolean; hasPdf: boolean;
@@ -47,20 +47,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>
@@ -69,11 +69,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})
@@ -97,15 +97,15 @@ 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>
{subsetDownload && ( {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={subsetDownload.url} href={subsetDownload.url}
@@ -117,8 +117,8 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
)} )}
{exportDownload && ( {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={exportDownload.url} href={exportDownload.url}
@@ -130,7 +130,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
)} )}
{splitDownloads.length > 0 && ( {splitDownloads.length > 0 && (
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}> <div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
<strong>Single-page PDFs:</strong> <strong>Single-page PDFs:</strong>
<div> <div>
{splitDownloads.map((download) => ( {splitDownloads.map((download) => (

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,55 +6,55 @@ 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", keys: 'Ctrl/⌘ + A',
description: "Select all pages in the current workspace", description: 'Select all pages in the current workspace',
}, },
{ {
keys: "Delete / Backspace", keys: 'Delete / Backspace',
description: "Delete the selected pages after confirmation", description: 'Delete the selected pages after confirmation',
}, },
{ {
keys: "Esc", keys: 'Esc',
description: "Clear the page selection or close an open dialog", description: 'Clear the page selection or close an open dialog',
}, },
{ keys: "Ctrl/⌘ + Z", description: "Undo the latest workspace command" }, { keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
{ {
keys: "Ctrl/⌘ + Shift + Z", keys: 'Ctrl/⌘ + Shift + Z',
description: "Redo the next workspace command", description: 'Redo the next workspace command',
}, },
{ keys: "Ctrl/⌘ + Y", description: "Redo the next workspace command" }, { keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
{ {
keys: "← / → in preview", keys: '← / → in preview',
description: "Move to the previous or next page in the preview overlay", 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. Extract selected pages or branch into a new workspace", 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.", body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
}, },
{ {
title: "5. Save your workspace or export a PDF", title: '5. Save your workspace or export a PDF',
body: "Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.", body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
}, },
{ {
title: "6. Use history deliberately", title: '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.", body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
}, },
]; ];
@@ -63,7 +63,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();
@@ -71,10 +71,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]);

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,14 +126,15 @@ 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;
baseCanvas.height = scaledViewport.height; baseCanvas.height = scaledViewport.height;
const renderTask = page.render({ const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx, canvasContext: baseCtx,
viewport: scaledViewport, viewport: scaledViewport,
}); });
@@ -161,7 +162,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 +182,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 +217,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 +250,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 +282,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 +309,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

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect } from 'react';
interface CopyPagesDialogProps { interface CopyPagesDialogProps {
selectedCount: number; selectedCount: number;
@@ -21,16 +21,16 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
}) => { }) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [onCancel]); }, [onCancel]);
@@ -45,43 +45,43 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
} }
}} }}
style={{ style={{
position: "fixed", position: 'fixed',
inset: 0, inset: 0,
zIndex: 60, zIndex: 60,
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',
}} }}
> >
<form <form
onSubmit={onConfirm} onSubmit={onConfirm}
style={{ style={{
width: "100%", width: '100%',
maxWidth: "420px", maxWidth: '420px',
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="copy-pages-dialog-title" id="copy-pages-dialog-title"
style={{ style={{
margin: 0, margin: 0,
fontSize: "1rem", fontSize: '1rem',
}} }}
> >
Copy selected pages Copy selected pages
@@ -91,18 +91,18 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
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 copy dialog" aria-label="Close copy dialog"
> >
@@ -113,25 +113,25 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
<p <p
style={{ style={{
margin: 0, margin: 0,
fontSize: "0.9rem", fontSize: '0.9rem',
color: "#4b5563", color: '#4b5563',
}} }}
> >
Copy{" "} Copy{' '}
<strong> <strong>
{selectedCount === 1 {selectedCount === 1
? "1 selected page" ? '1 selected page'
: `${selectedCount} selected pages`} : `${selectedCount} selected pages`}
</strong>{" "} </strong>{' '}
to a new position. to a new position.
</p> </p>
<label <label
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
gap: "0.25rem", gap: '0.25rem',
fontSize: "0.9rem", fontSize: '0.9rem',
}} }}
> >
Insert before position Insert before position
@@ -143,18 +143,18 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
autoFocus autoFocus
onChange={(e) => onTargetPositionChange(e.target.value)} onChange={(e) => onTargetPositionChange(e.target.value)}
style={{ style={{
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.95rem", fontSize: '0.95rem',
}} }}
/> />
</label> </label>
<div <div
style={{ style={{
fontSize: "0.8rem", fontSize: '0.8rem',
color: "#6b7280", color: '#6b7280',
lineHeight: 1.4, lineHeight: 1.4,
}} }}
> >
@@ -165,12 +165,12 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
{error && ( {error && (
<div <div
style={{ style={{
borderRadius: "0.5rem", borderRadius: '0.5rem',
background: "#fef2f2", background: '#fef2f2',
border: "1px solid #fecaca", border: '1px solid #fecaca',
color: "#b91c1c", color: '#b91c1c',
padding: "0.5rem", padding: '0.5rem',
fontSize: "0.85rem", fontSize: '0.85rem',
}} }}
> >
{error} {error}
@@ -179,23 +179,23 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
<div <div
style={{ style={{
display: "flex", display: 'flex',
justifyContent: "flex-end", justifyContent: 'flex-end',
gap: "0.5rem", gap: '0.5rem',
marginTop: "0.25rem", marginTop: '0.25rem',
}} }}
> >
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
style={{ style={{
border: "none", border: 'none',
borderRadius: "0.5rem", borderRadius: '0.5rem',
padding: "0.45rem 0.8rem", padding: '0.45rem 0.8rem',
background: "#e5e7eb", background: '#e5e7eb',
color: "#111827", color: '#111827',
cursor: "pointer", cursor: 'pointer',
fontSize: "0.9rem", fontSize: '0.9rem',
}} }}
> >
Cancel Cancel
@@ -204,13 +204,13 @@ const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
<button <button
type="submit" type="submit"
style={{ style={{
border: "none", border: 'none',
borderRadius: "0.5rem", borderRadius: '0.5rem',
padding: "0.45rem 0.8rem", padding: '0.45rem 0.8rem',
background: "#16a34a", background: '#16a34a',
color: "white", color: 'white',
cursor: "pointer", cursor: 'pointer',
fontSize: "0.9rem", fontSize: '0.9rem',
}} }}
> >
Copy pages Copy pages

View File

@@ -1,23 +1,23 @@
import React from "react"; import React from 'react';
interface DropIndicatorProps { interface DropIndicatorProps {
side: "left" | "right" | "end"; side: 'left' | 'right' | 'end';
color: string; color: string;
} }
const DropIndicator: React.FC<DropIndicatorProps> = ({ side, color }) => { const DropIndicator: React.FC<DropIndicatorProps> = ({ side, color }) => {
const isEnd = side === "end"; const isEnd = side === 'end';
return ( return (
<div <div
style={{ style={{
position: "absolute", position: 'absolute',
left: side === "left" ? "-4px" : isEnd ? "8px" : undefined, left: side === 'left' ? '-4px' : isEnd ? '8px' : undefined,
right: side === "right" ? "-4px" : undefined, right: side === 'right' ? '-4px' : undefined,
top: "4px", top: '4px',
bottom: "4px", bottom: '4px',
width: "3px", width: '3px',
borderRadius: "999px", borderRadius: '999px',
background: color, background: color,
}} }}
/> />

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from 'react';
import type { PageRef } from "../../pdf/pdfTypes"; import type { PageRef } from '../../pdf/pdfTypes';
import DropIndicator from "./DropIndicator"; import DropIndicator from './DropIndicator';
interface PageCardProps { interface PageCardProps {
page: PageRef; page: PageRef;
@@ -24,11 +24,11 @@ interface PageCardProps {
} }
const pageActionButtonStyle: React.CSSProperties = { const pageActionButtonStyle: React.CSSProperties = {
border: "none", border: 'none',
borderRadius: "999px", borderRadius: '999px',
padding: "0.15rem 0.4rem", padding: '0.15rem 0.4rem',
fontSize: "0.75rem", fontSize: '0.75rem',
cursor: "pointer", cursor: 'pointer',
}; };
const PageCard: React.FC<PageCardProps> = ({ const PageCard: React.FC<PageCardProps> = ({
@@ -53,11 +53,11 @@ const PageCard: React.FC<PageCardProps> = ({
}) => { }) => {
const background = isDraggingCard const background = isDraggingCard
? isCopyDragging ? isCopyDragging
? "#dcfce7" ? '#dcfce7'
: "#dbeafe" : '#dbeafe'
: selected : selected
? "#eff6ff" ? '#eff6ff'
: "#f9fafb"; : '#f9fafb';
return ( return (
<div <div
@@ -67,17 +67,17 @@ const PageCard: React.FC<PageCardProps> = ({
onDragOver={onDragOver} onDragOver={onDragOver}
onClick={onOpenPreview} onClick={onOpenPreview}
style={{ style={{
position: "relative", position: 'relative',
width: "162px", width: '162px',
padding: "0.4rem", padding: '0.4rem',
borderRadius: "0.5rem", borderRadius: '0.5rem',
border: "1px solid #e5e7eb", border: '1px solid #e5e7eb',
background, background,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
gap: "0.25rem", gap: '0.25rem',
cursor: isBusy ? "default" : isCopyDragging ? "copy" : "grab", cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
opacity: isBusy ? 0.7 : 1, opacity: isBusy ? 0.7 : 1,
}} }}
> >
@@ -85,21 +85,21 @@ const PageCard: React.FC<PageCardProps> = ({
type="button" type="button"
onClick={onToggleSelect} onClick={onToggleSelect}
style={{ style={{
position: "absolute", position: 'absolute',
top: "4px", top: '4px',
left: "4px", left: '4px',
width: "20px", width: '20px',
height: "20px", height: '20px',
borderRadius: "0.4rem", borderRadius: '0.4rem',
border: "1px solid #9ca3af", border: '1px solid #9ca3af',
background: selected ? "#2563eb" : "rgba(255,255,255,0.9)", background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
color: selected ? "white" : "transparent", color: selected ? 'white' : 'transparent',
fontSize: "0.8rem", fontSize: '0.8rem',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
padding: 0, padding: 0,
cursor: "pointer", cursor: 'pointer',
}} }}
title="Select page" title="Select page"
> >
@@ -113,11 +113,11 @@ const PageCard: React.FC<PageCardProps> = ({
<div <div
style={{ style={{
width: "110px", width: '110px',
height: "90px", height: '90px',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}} }}
> >
{thumbnail ? ( {thumbnail ? (
@@ -125,41 +125,41 @@ const PageCard: React.FC<PageCardProps> = ({
src={thumbnail} src={thumbnail}
alt={`Page ${page.sourcePageIndex + 1}`} alt={`Page ${page.sourcePageIndex + 1}`}
style={{ style={{
maxWidth: "100%", maxWidth: '100%',
maxHeight: "100%", maxHeight: '100%',
width: "auto", width: 'auto',
height: "auto", height: 'auto',
objectFit: "contain", objectFit: 'contain',
borderRadius: "0.25rem", borderRadius: '0.25rem',
border: "1px solid #e5e7eb", border: '1px solid #e5e7eb',
background: "white", background: 'white',
}} }}
/> />
) : ( ) : (
<div <div
style={{ style={{
width: "60px", width: '60px',
height: "80px", height: '80px',
borderRadius: "0.25rem", borderRadius: '0.25rem',
border: "1px dashed #d1d5db", border: '1px dashed #d1d5db',
background: "#f3f4f6", background: '#f3f4f6',
}} }}
/> />
)} )}
</div> </div>
<span style={{ fontSize: "0.8rem" }}> <span style={{ fontSize: '0.8rem' }}>
Page {page.sourcePageIndex + 1} Page {page.sourcePageIndex + 1}
</span> </span>
<span style={{ fontSize: "0.7rem", color: "#6b7280" }}> <span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
Pos {visualIndex + 1} · Rot {page.rotation}° Pos {visualIndex + 1} · Rot {page.rotation}°
</span> </span>
<div <div
style={{ style={{
display: "flex", display: 'flex',
gap: "0.25rem", gap: '0.25rem',
marginTop: "0.25rem", marginTop: '0.25rem',
}} }}
> >
<button <button
@@ -170,7 +170,7 @@ const PageCard: React.FC<PageCardProps> = ({
}} }}
style={{ style={{
...pageActionButtonStyle, ...pageActionButtonStyle,
background: "#e5e7eb", background: '#e5e7eb',
}} }}
> >
90° 90°
@@ -184,7 +184,7 @@ const PageCard: React.FC<PageCardProps> = ({
}} }}
style={{ style={{
...pageActionButtonStyle, ...pageActionButtonStyle,
background: "#e5e7eb", background: '#e5e7eb',
}} }}
> >
90° 90°
@@ -198,8 +198,8 @@ const PageCard: React.FC<PageCardProps> = ({
}} }}
style={{ style={{
...pageActionButtonStyle, ...pageActionButtonStyle,
background: "#fecaca", background: '#fecaca',
color: "#b91c1c", color: '#b91c1c',
}} }}
title="Remove this page from the exported PDF" title="Remove this page from the exported PDF"
> >

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from 'react';
import type { PageRef } from "../../pdf/pdfTypes"; import type { PageRef } from '../../pdf/pdfTypes';
import DropIndicator from "./DropIndicator"; import DropIndicator from './DropIndicator';
import PageCard from "./PageCard"; import PageCard from './PageCard';
interface PageGridProps { interface PageGridProps {
pages: PageRef[]; pages: PageRef[];
@@ -16,14 +16,14 @@ interface PageGridProps {
onDragStart: (visualIndex: number) => React.DragEventHandler<HTMLDivElement>; onDragStart: (visualIndex: number) => React.DragEventHandler<HTMLDivElement>;
onDragEnd: React.DragEventHandler<HTMLDivElement>; onDragEnd: React.DragEventHandler<HTMLDivElement>;
onCardDragOver: ( onCardDragOver: (
visualIndex: number, visualIndex: number
) => React.DragEventHandler<HTMLDivElement>; ) => React.DragEventHandler<HTMLDivElement>;
onEndSlotDragOver: React.DragEventHandler<HTMLDivElement>; onEndSlotDragOver: React.DragEventHandler<HTMLDivElement>;
onDrop: React.DragEventHandler<HTMLDivElement>; onDrop: React.DragEventHandler<HTMLDivElement>;
onOpenPreview: (pageId: string) => void; onOpenPreview: (pageId: string) => void;
onToggleSelect: ( onToggleSelect: (
pageId: string, pageId: string,
visualIndex: number, visualIndex: number
) => React.MouseEventHandler<HTMLButtonElement>; ) => React.MouseEventHandler<HTMLButtonElement>;
onRotateClockwise: (pageId: string) => void; onRotateClockwise: (pageId: string) => void;
onRotateCounterclockwise: (pageId: string) => void; onRotateCounterclockwise: (pageId: string) => void;
@@ -67,11 +67,11 @@ const PageGrid: React.FC<PageGridProps> = ({
return ( return (
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexWrap: "wrap", flexWrap: 'wrap',
gap: "0.5rem", gap: '0.5rem',
alignItems: "flex-start", alignItems: 'flex-start',
marginBottom: "0.75rem", marginBottom: '0.75rem',
}} }}
onDrop={onDrop} onDrop={onDrop}
> >
@@ -112,10 +112,10 @@ const PageGrid: React.FC<PageGridProps> = ({
onDragOver={onEndSlotDragOver} onDragOver={onEndSlotDragOver}
onDrop={onDrop} onDrop={onDrop}
style={{ style={{
width: "20px", width: '20px',
height: "120px", height: '120px',
position: "relative", position: 'relative',
alignSelf: "stretch", alignSelf: 'stretch',
}} }}
> >
{showEndLine() && ( {showEndLine() && (

View File

@@ -1,4 +1,4 @@
import React from "react"; import React from 'react';
interface PageSelectionToolbarProps { interface PageSelectionToolbarProps {
selectedCount: number; selectedCount: number;
@@ -9,10 +9,10 @@ interface PageSelectionToolbarProps {
} }
const pillButtonStyle: React.CSSProperties = { const pillButtonStyle: React.CSSProperties = {
border: "none", border: 'none',
borderRadius: "999px", borderRadius: '999px',
padding: "0.15rem 0.6rem", padding: '0.15rem 0.6rem',
fontSize: "0.8rem", fontSize: '0.8rem',
}; };
const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
@@ -27,11 +27,11 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
return ( return (
<div <div
style={{ style={{
display: "flex", display: 'flex',
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
marginBottom: "0.5rem", marginBottom: '0.5rem',
fontSize: "0.85rem", fontSize: '0.85rem',
}} }}
> >
<span> <span>
@@ -40,10 +40,10 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
<div <div
style={{ style={{
display: "flex", display: 'flex',
gap: "0.4rem", gap: '0.4rem',
flexWrap: "wrap", flexWrap: 'wrap',
justifyContent: "flex-end", justifyContent: 'flex-end',
}} }}
> >
{hasSelection && ( {hasSelection && (
@@ -53,9 +53,9 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
disabled={!hasSelection} disabled={!hasSelection}
style={{ style={{
...pillButtonStyle, ...pillButtonStyle,
background: "#dcfce7", background: '#dcfce7',
color: "#166534", color: '#166534',
cursor: "pointer", cursor: 'pointer',
}} }}
title="Copy selected pages to another position" title="Copy selected pages to another position"
> >
@@ -69,9 +69,9 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
onClick={onDeleteSelected} onClick={onDeleteSelected}
style={{ style={{
...pillButtonStyle, ...pillButtonStyle,
background: "#fee2e2", background: '#fee2e2',
color: "#b91c1c", color: '#b91c1c',
cursor: "pointer", cursor: 'pointer',
}} }}
> >
Delete selected Delete selected
@@ -83,9 +83,9 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
onClick={onSelectAll} onClick={onSelectAll}
style={{ style={{
...pillButtonStyle, ...pillButtonStyle,
background: "#8dcd8d", background: '#8dcd8d',
color: "#111827", color: '#111827',
cursor: "pointer", cursor: 'pointer',
}} }}
> >
Select all Select all
@@ -97,9 +97,9 @@ const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
disabled={!hasSelection} disabled={!hasSelection}
style={{ style={{
...pillButtonStyle, ...pillButtonStyle,
background: "#e5e7eb", background: '#e5e7eb',
color: hasSelection ? "#111827" : "#6b7280", color: hasSelection ? '#111827' : '#6b7280',
cursor: hasSelection ? "pointer" : "default", cursor: hasSelection ? 'pointer' : 'default',
}} }}
> >
Clear selection Clear selection

View File

@@ -1,8 +1,8 @@
import React, { useRef, useState } 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 CopyPagesDialog from './PageWorkspace/CopyPagesDialog';
import PageGrid from "./PageWorkspace/PageGrid"; import PageGrid from './PageWorkspace/PageGrid';
import PageSelectionToolbar from "./PageWorkspace/PageSelectionToolbar"; import PageSelectionToolbar from './PageWorkspace/PageSelectionToolbar';
interface ReorderPanelProps { interface ReorderPanelProps {
pages: PageRef[]; pages: PageRef[];
@@ -20,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;
@@ -51,7 +51,7 @@ 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);
@@ -72,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 =
@@ -85,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;
@@ -121,9 +121,9 @@ 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);
@@ -141,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;
@@ -158,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);
}; };
@@ -177,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);
@@ -247,7 +247,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
e?.preventDefault(); e?.preventDefault();
if (selectedPageIds.length === 0) { if (selectedPageIds.length === 0) {
setCopyDialogError("No pages selected."); setCopyDialogError('No pages selected.');
return; return;
} }
@@ -279,13 +279,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
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 (Shift for ranges). Drag to reorder; dragging a selected page moves
the whole selection. Hold Ctrl/ while dropping to copy instead of the whole selection. Hold Ctrl/ while dropping to copy instead of

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
@@ -119,7 +119,7 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
onClick={onResetWorkspace} onClick={onResetWorkspace}
disabled={!hasPdf || isBusy} disabled={!hasPdf || isBusy}
title={ title={
!hasPdf ? "No active workspace" : "Close the current workspace" !hasPdf ? 'No active workspace' : 'Close the current workspace'
} }
> >
Reset workspace Reset workspace
@@ -138,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.
@@ -150,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}
@@ -160,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) => {
@@ -178,29 +178,29 @@ 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} · {workspace.historyCount} · redo: {workspace.redoCount} ·
updated {new Date(workspace.updatedAt).toLocaleString()} updated {new Date(workspace.updatedAt).toLocaleString()}
</div> </div>
@@ -208,9 +208,9 @@ const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
<div <div
style={{ style={{
display: "flex", display: 'flex',
gap: "0.35rem", gap: '0.35rem',
flexWrap: "wrap", flexWrap: 'wrap',
}} }}
> >
<button <button
@@ -228,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
@@ -243,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>
@@ -280,15 +280,15 @@ 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>
@@ -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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react';
import type { SplitResult } from "../pdf/pdfTypes"; import type { SplitResult } from '../pdf/pdfTypes';
export interface PdfDownload { export interface PdfDownload {
id: string; id: string;
@@ -32,10 +32,10 @@ function createDownload(id: string, filename: string, blob: Blob): PdfDownload {
export function usePdfGeneratedOutputs() { export function usePdfGeneratedOutputs() {
const [splitDownloads, setSplitDownloads] = useState<SplitPdfDownload[]>([]); const [splitDownloads, setSplitDownloads] = useState<SplitPdfDownload[]>([]);
const [subsetDownload, setSubsetDownload] = useState<PdfDownload | null>( const [subsetDownload, setSubsetDownload] = useState<PdfDownload | null>(
null, null
); );
const [exportDownload, setExportDownload] = useState<PdfDownload | null>( const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
null, null
); );
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]); const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
@@ -47,7 +47,7 @@ export function usePdfGeneratedOutputs() {
...createDownload( ...createDownload(
`split-${result.pageIndex}-${result.filename}`, `split-${result.pageIndex}-${result.filename}`,
result.filename, result.filename,
result.blob, result.blob
), ),
pageIndex: result.pageIndex, pageIndex: result.pageIndex,
})); }));
@@ -64,7 +64,7 @@ export function usePdfGeneratedOutputs() {
}, []); }, []);
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => { const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
const nextDownload = createDownload("subset", filename, blob); const nextDownload = createDownload('subset', filename, blob);
revokeDownload(subsetDownloadRef.current); revokeDownload(subsetDownloadRef.current);
subsetDownloadRef.current = nextDownload; subsetDownloadRef.current = nextDownload;
@@ -78,7 +78,7 @@ export function usePdfGeneratedOutputs() {
}, []); }, []);
const replaceExportResult = useCallback((blob: Blob, filename: string) => { const replaceExportResult = useCallback((blob: Blob, filename: string) => {
const nextDownload = createDownload("export", filename, blob); const nextDownload = createDownload('export', filename, blob);
revokeDownload(exportDownloadRef.current); revokeDownload(exportDownloadRef.current);
exportDownloadRef.current = nextDownload; exportDownloadRef.current = nextDownload;

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,5 +1,5 @@
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);
@@ -12,7 +12,7 @@ function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
} }
function pdfBytesToBlob(bytes: Uint8Array): Blob { function pdfBytesToBlob(bytes: Uint8Array): Blob {
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: "application/pdf" }); return new Blob([pdfBytesToArrayBuffer(bytes)], { type: 'application/pdf' });
} }
export async function loadPdfFromFile(file: File): Promise<PdfFile> { export async function loadPdfFromFile(file: File): Promise<PdfFile> {
@@ -31,7 +31,7 @@ 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));
@@ -45,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) {
@@ -65,8 +65,8 @@ export async function mergePdfFiles(
const bytes = await mergedDoc.save(); const bytes = await mergedDoc.save();
const buffer = pdfBytesToArrayBuffer(bytes); const buffer = pdfBytesToArrayBuffer(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(),
@@ -78,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;
@@ -110,8 +110,8 @@ export async function splitIntoSinglePages(
const bytes = await newDoc.save(); const bytes = await newDoc.save();
const blob = pdfBytesToBlob(bytes); 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,
@@ -131,7 +131,7 @@ export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
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();
@@ -161,21 +161,21 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
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();
@@ -186,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));
} }
@@ -200,7 +200,7 @@ export async function exportPages(
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,
@@ -208,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
@@ -42,7 +42,7 @@ interface ThumbnailGenerationOptions {
*/ */
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);
} }
@@ -53,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);
} }
@@ -61,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;
@@ -72,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);
@@ -98,7 +98,7 @@ async function generateThumbnailsInternal(
pageIndex, pageIndex,
rotations, rotations,
maxHeight, maxHeight,
maxWidth, maxWidth
); );
if (signal?.aborted) return; if (signal?.aborted) return;
@@ -134,13 +134,13 @@ async function generateThumbnailsInternal(
async function renderPageThumbnail( async function renderPageThumbnail(
page: Awaited< page: Awaited<
ReturnType< ReturnType<
Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>["getPage"] 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;
@@ -148,15 +148,16 @@ 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;
const renderTask = page.render({ const renderTask = page.render({
canvas: baseCanvas,
canvasContext: baseCtx, canvasContext: baseCtx,
viewport: scaledViewport, viewport: scaledViewport,
}); });
@@ -167,14 +168,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;
@@ -207,5 +208,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;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react';
import type { PageRef, PdfFile } from "./pdfTypes"; import type { PageRef, PdfFile } from './pdfTypes';
import { generateThumbnailsWithRotationsProgressive } from "./pdfThumbnailService"; import { generateThumbnailsWithRotationsProgressive } from './pdfThumbnailService';
import { normalizeRotation } from "../workspace/useWorkspaceState"; import { normalizeRotation } from '../workspace/useWorkspaceState';
const DEFAULT_MAX_HEIGHT = 150; const DEFAULT_MAX_HEIGHT = 150;
const DEFAULT_MAX_WIDTH = 140; const DEFAULT_MAX_WIDTH = 140;
@@ -27,7 +27,7 @@ function thumbnailCacheKey(
sourcePageIndex: number, sourcePageIndex: number,
rotation: number, rotation: number,
maxWidth: number, maxWidth: number,
maxHeight: number, maxHeight: number
): string { ): string {
return [ return [
pdfId, pdfId,
@@ -35,13 +35,13 @@ function thumbnailCacheKey(
normalizeRotation(rotation), normalizeRotation(rotation),
maxWidth, maxWidth,
maxHeight, maxHeight,
].join(":"); ].join(':');
} }
function pruneAndMergeThumbnails( function pruneAndMergeThumbnails(
previous: Record<string, string>, previous: Record<string, string>,
pages: PageRef[], pages: PageRef[],
updates: Record<string, string>, updates: Record<string, string>
): Record<string, string> { ): Record<string, string> {
const pageIds = new Set(pages.map((page) => page.id)); const pageIds = new Set(pages.map((page) => page.id));
const next: Record<string, string> = {}; const next: Record<string, string> = {};
@@ -113,7 +113,7 @@ export function usePdfThumbnails({
page.sourcePageIndex, page.sourcePageIndex,
rotation, rotation,
maxWidth, maxWidth,
maxHeight, maxHeight
); );
const cached = thumbnailCacheRef.current.get(cacheKey); const cached = thumbnailCacheRef.current.get(cacheKey);
@@ -128,7 +128,7 @@ export function usePdfThumbnails({
} }
setThumbnails((previous) => setThumbnails((previous) =>
pruneAndMergeThumbnails(previous, pages, cachedUpdates), pruneAndMergeThumbnails(previous, pages, cachedUpdates)
); );
if (renderGroups.size === 0) return; if (renderGroups.size === 0) return;
@@ -162,9 +162,9 @@ export function usePdfThumbnails({
pageIndex, pageIndex,
rotation, rotation,
maxWidth, maxWidth,
maxHeight, maxHeight
), ),
dataUrl, dataUrl
); );
const updates: Record<string, string> = {}; const updates: Record<string, string> = {};
@@ -184,18 +184,18 @@ export function usePdfThumbnails({
pruneAndMergeThumbnails( pruneAndMergeThumbnails(
previous, previous,
latestPagesRef.current, latestPagesRef.current,
updates, updates
), )
); );
}, },
}, }
); );
} }
}; };
void renderMissingThumbnails().catch((error) => { void renderMissingThumbnails().catch((error) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
onError?.("Failed to generate thumbnails (see console).", error); onError?.('Failed to generate thumbnails (see console).', error);
} }
}); });

View File

@@ -10,7 +10,7 @@ body {
system-ui, system-ui,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", '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.3.0"; export const APP_VERSION = '0.3.0';

View File

@@ -1,12 +1,12 @@
import React, { forwardRef, useImperativeHandle } from "react"; import React, { forwardRef, useImperativeHandle } from 'react';
import { act, render } from "@testing-library/react"; import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from 'vitest';
import type { PageRef } from "../pdf/pdfTypes"; import type { PageRef } from '../pdf/pdfTypes';
import type { import type {
WorkspaceCommandRecord, WorkspaceCommandRecord,
WorkspaceCommandState, WorkspaceCommandState,
} from "./workspaceCommands"; } from './workspaceCommands';
import { useWorkspaceState } from "./useWorkspaceState"; import { useWorkspaceState } from './useWorkspaceState';
function page(id: string, sourcePageIndex: number, rotation = 0): PageRef { function page(id: string, sourcePageIndex: number, rotation = 0): PageRef {
return { id, sourcePageIndex, rotation }; return { id, sourcePageIndex, rotation };
@@ -15,7 +15,7 @@ function page(id: string, sourcePageIndex: number, rotation = 0): PageRef {
function state( function state(
pages: PageRef[], pages: PageRef[],
selectedPageIds: string[] = [], selectedPageIds: string[] = [],
lastSelectedVisualIndex: number | null = null, lastSelectedVisualIndex: number | null = null
): WorkspaceCommandState { ): WorkspaceCommandState {
return { pages, selectedPageIds, lastSelectedVisualIndex }; return { pages, selectedPageIds, lastSelectedVisualIndex };
} }
@@ -32,18 +32,18 @@ interface HarnessRef {
}; };
replaceWorkspaceState: ReturnType< replaceWorkspaceState: ReturnType<
typeof useWorkspaceState typeof useWorkspaceState
>["replaceWorkspaceState"]; >['replaceWorkspaceState'];
getCurrentCommandState: ReturnType< getCurrentCommandState: ReturnType<
typeof useWorkspaceState typeof useWorkspaceState
>["getCurrentCommandState"]; >['getCurrentCommandState'];
createWorkspaceCommand: ReturnType< createWorkspaceCommand: ReturnType<
typeof useWorkspaceState typeof useWorkspaceState
>["createWorkspaceCommand"]; >['createWorkspaceCommand'];
executeWorkspaceCommand: ReturnType< executeWorkspaceCommand: ReturnType<
typeof useWorkspaceState typeof useWorkspaceState
>["executeWorkspaceCommand"]; >['executeWorkspaceCommand'];
handleUndo: ReturnType<typeof useWorkspaceState>["handleUndo"]; handleUndo: ReturnType<typeof useWorkspaceState>['handleUndo'];
handleRedo: ReturnType<typeof useWorkspaceState>["handleRedo"]; handleRedo: ReturnType<typeof useWorkspaceState>['handleRedo'];
} }
const Harness = forwardRef<HarnessRef, { onContentChanged?: () => void }>( const Harness = forwardRef<HarnessRef, { onContentChanged?: () => void }>(
@@ -69,7 +69,7 @@ const Harness = forwardRef<HarnessRef, { onContentChanged?: () => void }>(
})); }));
return null; return null;
}, }
); );
function renderHarness(onContentChanged = vi.fn()) { function renderHarness(onContentChanged = vi.fn()) {
@@ -77,54 +77,54 @@ function renderHarness(onContentChanged = vi.fn()) {
render(<Harness ref={ref} onContentChanged={onContentChanged} />); render(<Harness ref={ref} onContentChanged={onContentChanged} />);
if (!ref.current) { if (!ref.current) {
throw new Error("Harness ref was not initialized"); throw new Error('Harness ref was not initialized');
} }
return { ref, onContentChanged }; return { ref, onContentChanged };
} }
describe("useWorkspaceState", () => { describe('useWorkspaceState', () => {
it("replaces workspace state from loaded data without marking it dirty", () => { it('replaces workspace state from loaded data without marking it dirty', () => {
const { ref } = renderHarness(); const { ref } = renderHarness();
const loadedPages = [page("p1", 0), page("p2", 1, 90)]; const loadedPages = [page('p1', 0), page('p2', 1, 90)];
act(() => { act(() => {
ref.current?.replaceWorkspaceState({ ref.current?.replaceWorkspaceState({
pages: loadedPages, pages: loadedPages,
selectedPageIds: ["p2"], selectedPageIds: ['p2'],
lastSelectedVisualIndex: 1, lastSelectedVisualIndex: 1,
history: [], history: [],
redoHistory: [], redoHistory: [],
dirty: false, dirty: false,
message: "Workspace loaded.", message: 'Workspace loaded.',
}); });
}); });
expect(ref.current?.snapshot()).toMatchObject({ expect(ref.current?.snapshot()).toMatchObject({
pages: loadedPages, pages: loadedPages,
selectedPageIds: ["p2"], selectedPageIds: ['p2'],
lastSelectedVisualIndex: 1, lastSelectedVisualIndex: 1,
workspaceDirty: false, workspaceDirty: false,
workspaceMessage: "Workspace loaded.", workspaceMessage: 'Workspace loaded.',
workspaceHistory: [], workspaceHistory: [],
redoHistory: [], redoHistory: [],
}); });
}); });
it("executes commands, stores history, clears redo, and marks content changed", () => { it('executes commands, stores history, clears redo, and marks content changed', () => {
const { ref, onContentChanged } = renderHarness(); const { ref, onContentChanged } = renderHarness();
const before = state([page("p1", 0), page("p2", 1)], ["p1"], 0); const before = state([page('p1', 0), page('p2', 1)], ['p1'], 0);
const after = state([page("p2", 1), page("p1", 0)], ["p2"], 0); const after = state([page('p2', 1), page('p1', 0)], ['p2'], 0);
act(() => { act(() => {
ref.current?.replaceWorkspaceState({ ref.current?.replaceWorkspaceState({
...before, ...before,
redoHistory: [ redoHistory: [
{ {
id: "redo-record", id: 'redo-record',
type: "old-redo", type: 'old-redo',
label: "Old redo", label: 'Old redo',
timestamp: "2026-05-17T10:00:00.000Z", timestamp: '2026-05-17T10:00:00.000Z',
payload: { before, after }, payload: { before, after },
}, },
], ],
@@ -133,34 +133,34 @@ describe("useWorkspaceState", () => {
act(() => { act(() => {
const command = ref.current?.createWorkspaceCommand({ const command = ref.current?.createWorkspaceCommand({
type: "reorder-pages", type: 'reorder-pages',
label: "Move page 2 before page 1", label: 'Move page 2 before page 1',
before, before,
after, after,
}); });
if (!command) throw new Error("Command was not created"); if (!command) throw new Error('Command was not created');
ref.current?.executeWorkspaceCommand(command); ref.current?.executeWorkspaceCommand(command);
}); });
const snapshot = ref.current?.snapshot(); const snapshot = ref.current?.snapshot();
expect(snapshot?.pages).toEqual(after.pages); expect(snapshot?.pages).toEqual(after.pages);
expect(snapshot?.selectedPageIds).toEqual(["p2"]); expect(snapshot?.selectedPageIds).toEqual(['p2']);
expect(snapshot?.workspaceDirty).toBe(true); expect(snapshot?.workspaceDirty).toBe(true);
expect(snapshot?.workspaceMessage).toBeNull(); expect(snapshot?.workspaceMessage).toBeNull();
expect(snapshot?.workspaceHistory).toHaveLength(1); expect(snapshot?.workspaceHistory).toHaveLength(1);
expect(snapshot?.workspaceHistory[0]).toMatchObject({ expect(snapshot?.workspaceHistory[0]).toMatchObject({
type: "reorder-pages", type: 'reorder-pages',
label: "Move page 2 before page 1", label: 'Move page 2 before page 1',
}); });
expect(snapshot?.redoHistory).toHaveLength(0); expect(snapshot?.redoHistory).toHaveLength(0);
expect(onContentChanged).toHaveBeenCalledTimes(1); expect(onContentChanged).toHaveBeenCalledTimes(1);
}); });
it("undoes and redoes command records in stack order", () => { it('undoes and redoes command records in stack order', () => {
const { ref, onContentChanged } = renderHarness(); const { ref, onContentChanged } = renderHarness();
const initial = state([page("p1", 0), page("p2", 1)], ["p1"], 0); const initial = state([page('p1', 0), page('p2', 1)], ['p1'], 0);
const reordered = state([page("p2", 1), page("p1", 0)], ["p2"], 0); const reordered = state([page('p2', 1), page('p1', 0)], ['p2'], 0);
act(() => { act(() => {
ref.current?.replaceWorkspaceState(initial); ref.current?.replaceWorkspaceState(initial);
@@ -168,13 +168,13 @@ describe("useWorkspaceState", () => {
act(() => { act(() => {
const command = ref.current?.createWorkspaceCommand({ const command = ref.current?.createWorkspaceCommand({
type: "reorder-pages", type: 'reorder-pages',
label: "Move page", label: 'Move page',
before: initial, before: initial,
after: reordered, after: reordered,
}); });
if (!command) throw new Error("Command was not created"); if (!command) throw new Error('Command was not created');
ref.current?.executeWorkspaceCommand(command); ref.current?.executeWorkspaceCommand(command);
}); });

View File

@@ -1,18 +1,18 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from 'react';
import type { PageRef } from "../pdf/pdfTypes"; import type { PageRef } from '../pdf/pdfTypes';
import type { import type {
WorkspaceCommand, WorkspaceCommand,
WorkspaceCommandRecord, WorkspaceCommandRecord,
WorkspaceCommandState, WorkspaceCommandState,
} from "./workspaceCommands"; } from './workspaceCommands';
import { import {
createSnapshotCommand, createSnapshotCommand,
reviveWorkspaceCommand, reviveWorkspaceCommand,
toWorkspaceCommandRecord, toWorkspaceCommandRecord,
} from "./workspaceCommands"; } from './workspaceCommands';
function createId(prefix: string): string { function createId(prefix: string): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) { if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID(); return crypto.randomUUID();
} }
@@ -20,7 +20,7 @@ function createId(prefix: string): string {
} }
export function createWorkspaceId(): string { export function createWorkspaceId(): string {
return createId("workspace"); return createId('workspace');
} }
export function createPdfId(): string { export function createPdfId(): string {
@@ -28,11 +28,11 @@ export function createPdfId(): string {
} }
export function defaultWorkspaceNameFromPdfName(pdfName: string): string { export function defaultWorkspaceNameFromPdfName(pdfName: string): string {
return pdfName.replace(/\.pdf$/i, "") || "Untitled workspace"; return pdfName.replace(/\.pdf$/i, '') || 'Untitled workspace';
} }
export function createPageRefId(): string { export function createPageRefId(): string {
return createId("page"); return createId('page');
} }
export function createInitialPageRefs(pageCount: number): PageRef[] { export function createInitialPageRefs(pageCount: number): PageRef[] {
@@ -84,7 +84,7 @@ export function useWorkspaceState({
const setPages = useCallback((action: SetStateAction<PageRef[]>) => { const setPages = useCallback((action: SetStateAction<PageRef[]>) => {
setPagesState((previous) => { setPagesState((previous) => {
const next = typeof action === "function" ? action(previous) : action; const next = typeof action === 'function' ? action(previous) : action;
latestPagesRef.current = next; latestPagesRef.current = next;
return next; return next;
}); });
@@ -92,7 +92,7 @@ export function useWorkspaceState({
const setSelectedPageIds = useCallback((action: SetStateAction<string[]>) => { const setSelectedPageIds = useCallback((action: SetStateAction<string[]>) => {
setSelectedPageIdsState((previous) => { setSelectedPageIdsState((previous) => {
const next = typeof action === "function" ? action(previous) : action; const next = typeof action === 'function' ? action(previous) : action;
selectedPageIdsRef.current = next; selectedPageIdsRef.current = next;
return next; return next;
}); });
@@ -101,12 +101,12 @@ export function useWorkspaceState({
const setLastSelectedVisualIndex = useCallback( const setLastSelectedVisualIndex = useCallback(
(action: SetStateAction<number | null>) => { (action: SetStateAction<number | null>) => {
setLastSelectedVisualIndexState((previous) => { setLastSelectedVisualIndexState((previous) => {
const next = typeof action === "function" ? action(previous) : action; const next = typeof action === 'function' ? action(previous) : action;
lastSelectedVisualIndexRef.current = next; lastSelectedVisualIndexRef.current = next;
return next; return next;
}); });
}, },
[], []
); );
const getCurrentCommandState = useCallback( const getCurrentCommandState = useCallback(
@@ -115,7 +115,7 @@ export function useWorkspaceState({
selectedPageIds: selectedPageIdsRef.current, selectedPageIds: selectedPageIdsRef.current,
lastSelectedVisualIndex: lastSelectedVisualIndexRef.current, lastSelectedVisualIndex: lastSelectedVisualIndexRef.current,
}), }),
[], []
); );
const applyCommandState = useCallback( const applyCommandState = useCallback(
@@ -124,7 +124,7 @@ export function useWorkspaceState({
setSelectedPageIds(state.selectedPageIds); setSelectedPageIds(state.selectedPageIds);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex); setLastSelectedVisualIndex(state.lastSelectedVisualIndex);
}, },
[setLastSelectedVisualIndex, setPages, setSelectedPageIds], [setLastSelectedVisualIndex, setPages, setSelectedPageIds]
); );
const markWorkspaceChanged = useCallback(() => { const markWorkspaceChanged = useCallback(() => {
@@ -142,14 +142,14 @@ export function useWorkspaceState({
details?: Record<string, unknown>; details?: Record<string, unknown>;
}): WorkspaceCommand => }): WorkspaceCommand =>
createSnapshotCommand({ createSnapshotCommand({
id: createId("command"), id: createId('command'),
type: params.type, type: params.type,
label: params.label, label: params.label,
before: params.before, before: params.before,
after: params.after, after: params.after,
details: params.details, details: params.details,
}), }),
[], []
); );
const executeWorkspaceCommand = useCallback( const executeWorkspaceCommand = useCallback(
@@ -164,7 +164,7 @@ export function useWorkspaceState({
setRedoHistory([]); setRedoHistory([]);
markWorkspaceChanged(); markWorkspaceChanged();
}, },
[applyCommandState, getCurrentCommandState, markWorkspaceChanged], [applyCommandState, getCurrentCommandState, markWorkspaceChanged]
); );
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
@@ -213,7 +213,7 @@ export function useWorkspaceState({
setWorkspaceDirty(state.dirty ?? false); setWorkspaceDirty(state.dirty ?? false);
setWorkspaceMessage(state.message ?? null); setWorkspaceMessage(state.message ?? null);
}, },
[setLastSelectedVisualIndex, setPages, setSelectedPageIds], [setLastSelectedVisualIndex, setPages, setSelectedPageIds]
); );
const resetWorkspaceState = useCallback(() => { const resetWorkspaceState = useCallback(() => {

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest';
import type { WorkspaceCommandState } from "./workspaceCommands"; import type { WorkspaceCommandState } from './workspaceCommands';
import { import {
cloneCommandState, cloneCommandState,
createSnapshotCommand, createSnapshotCommand,
reviveWorkspaceCommand, reviveWorkspaceCommand,
toWorkspaceCommandRecord, toWorkspaceCommandRecord,
} from "./workspaceCommands"; } from './workspaceCommands';
function makeState(pageIds: string[]): WorkspaceCommandState { function makeState(pageIds: string[]): WorkspaceCommandState {
return { return {
@@ -19,36 +19,36 @@ function makeState(pageIds: string[]): WorkspaceCommandState {
}; };
} }
describe("workspaceCommands", () => { describe('workspaceCommands', () => {
it("clones command state deeply enough for page and selection changes", () => { it('clones command state deeply enough for page and selection changes', () => {
const original = makeState(["a", "b"]); const original = makeState(['a', 'b']);
const cloned = cloneCommandState(original); const cloned = cloneCommandState(original);
original.pages[0].rotation = 270; original.pages[0].rotation = 270;
original.selectedPageIds.push("b"); original.selectedPageIds.push('b');
original.lastSelectedVisualIndex = 1; original.lastSelectedVisualIndex = 1;
expect(cloned).toEqual({ expect(cloned).toEqual({
pages: [ pages: [
{ id: "a", sourcePageIndex: 0, rotation: 0 }, { id: 'a', sourcePageIndex: 0, rotation: 0 },
{ id: "b", sourcePageIndex: 1, rotation: 90 }, { id: 'b', sourcePageIndex: 1, rotation: 90 },
], ],
selectedPageIds: ["a"], selectedPageIds: ['a'],
lastSelectedVisualIndex: 0, lastSelectedVisualIndex: 0,
}); });
}); });
it("creates snapshot commands that are stable after source states mutate", () => { it('creates snapshot commands that are stable after source states mutate', () => {
const before = makeState(["a", "b"]); const before = makeState(['a', 'b']);
const after = makeState(["b", "a"]); const after = makeState(['b', 'a']);
after.selectedPageIds = ["b"]; after.selectedPageIds = ['b'];
after.lastSelectedVisualIndex = 0; after.lastSelectedVisualIndex = 0;
const command = createSnapshotCommand({ const command = createSnapshotCommand({
id: "cmd-1", id: 'cmd-1',
type: "reorder-pages", type: 'reorder-pages',
label: "Move page", label: 'Move page',
timestamp: "2026-05-17T10:00:00.000Z", timestamp: '2026-05-17T10:00:00.000Z',
before, before,
after, after,
details: { moved: 1 }, details: { moved: 1 },
@@ -56,40 +56,40 @@ describe("workspaceCommands", () => {
before.pages.length = 0; before.pages.length = 0;
after.pages[0].rotation = 180; after.pages[0].rotation = 180;
after.selectedPageIds.push("a"); after.selectedPageIds.push('a');
expect(command.undo(makeState(["ignored"]))).toEqual({ expect(command.undo(makeState(['ignored']))).toEqual({
pages: [ pages: [
{ id: "a", sourcePageIndex: 0, rotation: 0 }, { id: 'a', sourcePageIndex: 0, rotation: 0 },
{ id: "b", sourcePageIndex: 1, rotation: 90 }, { id: 'b', sourcePageIndex: 1, rotation: 90 },
], ],
selectedPageIds: ["a"], selectedPageIds: ['a'],
lastSelectedVisualIndex: 0, lastSelectedVisualIndex: 0,
}); });
expect(command.do(makeState(["ignored"]))).toEqual({ expect(command.do(makeState(['ignored']))).toEqual({
pages: [ pages: [
{ id: "b", sourcePageIndex: 0, rotation: 0 }, { id: 'b', sourcePageIndex: 0, rotation: 0 },
{ id: "a", sourcePageIndex: 1, rotation: 90 }, { id: 'a', sourcePageIndex: 1, rotation: 90 },
], ],
selectedPageIds: ["b"], selectedPageIds: ['b'],
lastSelectedVisualIndex: 0, lastSelectedVisualIndex: 0,
}); });
}); });
it("round-trips commands through serializable records", () => { it('round-trips commands through serializable records', () => {
const before = makeState(["a", "b", "c"]); const before = makeState(['a', 'b', 'c']);
const after: WorkspaceCommandState = { const after: WorkspaceCommandState = {
pages: [before.pages[2], before.pages[0], before.pages[1]], pages: [before.pages[2], before.pages[0], before.pages[1]],
selectedPageIds: ["c"], selectedPageIds: ['c'],
lastSelectedVisualIndex: 0, lastSelectedVisualIndex: 0,
}; };
const command = createSnapshotCommand({ const command = createSnapshotCommand({
id: "cmd-2", id: 'cmd-2',
type: "copy-pages", type: 'copy-pages',
label: "Copy pages", label: 'Copy pages',
timestamp: "2026-05-17T10:05:00.000Z", timestamp: '2026-05-17T10:05:00.000Z',
before, before,
after, after,
}); });
@@ -97,8 +97,8 @@ describe("workspaceCommands", () => {
const record = toWorkspaceCommandRecord(command); const record = toWorkspaceCommandRecord(command);
const revived = reviveWorkspaceCommand(record); const revived = reviveWorkspaceCommand(record);
expect(record).not.toHaveProperty("do"); expect(record).not.toHaveProperty('do');
expect(record).not.toHaveProperty("undo"); expect(record).not.toHaveProperty('undo');
expect(revived.do(before)).toEqual(after); expect(revived.do(before)).toEqual(after);
expect(revived.undo(after)).toEqual(before); 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,10 +1,10 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
import react from "@vitejs/plugin-react"; 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
}, },
}); });