mostly formatting, dependency fix
This commit is contained in:
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
279
src/App.tsx
279
src/App.tsx
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import type { PdfFile } from "../pdf/pdfTypes";
|
import type { PdfFile } from '../pdf/pdfTypes';
|
||||||
|
|
||||||
interface FileLoaderProps {
|
interface FileLoaderProps {
|
||||||
pdf: PdfFile | null;
|
pdf: PdfFile | null;
|
||||||
@@ -11,7 +11,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
onFileLoaded(file);
|
onFileLoaded(file);
|
||||||
e.target.value = "";
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
|||||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
<input type="file" accept="application/pdf" onChange={handleChange} />
|
||||||
|
|
||||||
{pdf && (
|
{pdf && (
|
||||||
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<strong>Loaded:</strong> {pdf.name}
|
<strong>Loaded:</strong> {pdf.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
interface HelpDialogProps {
|
interface HelpDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -6,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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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() && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
12
src/main.tsx
12
src/main.tsx
@@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
import React from 'react';
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from "./App";
|
import App from './App';
|
||||||
import "./styles.css";
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,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,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.3.0";
|
export const APP_VERSION = '0.3.0';
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user