refactoring, linting, formatting

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceCommandState } from "./workspaceCommands";
import {
cloneCommandState,
createSnapshotCommand,
reviveWorkspaceCommand,
toWorkspaceCommandRecord,
} from "./workspaceCommands";
function makeState(pageIds: string[]): WorkspaceCommandState {
return {
pages: pageIds.map((id, index) => ({
id,
sourcePageIndex: index,
rotation: index * 90,
})),
selectedPageIds: pageIds.slice(0, 1),
lastSelectedVisualIndex: pageIds.length > 0 ? 0 : null,
};
}
describe("workspaceCommands", () => {
it("clones command state deeply enough for page and selection changes", () => {
const original = makeState(["a", "b"]);
const cloned = cloneCommandState(original);
original.pages[0].rotation = 270;
original.selectedPageIds.push("b");
original.lastSelectedVisualIndex = 1;
expect(cloned).toEqual({
pages: [
{ id: "a", sourcePageIndex: 0, rotation: 0 },
{ id: "b", sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ["a"],
lastSelectedVisualIndex: 0,
});
});
it("creates snapshot commands that are stable after source states mutate", () => {
const before = makeState(["a", "b"]);
const after = makeState(["b", "a"]);
after.selectedPageIds = ["b"];
after.lastSelectedVisualIndex = 0;
const command = createSnapshotCommand({
id: "cmd-1",
type: "reorder-pages",
label: "Move page",
timestamp: "2026-05-17T10:00:00.000Z",
before,
after,
details: { moved: 1 },
});
before.pages.length = 0;
after.pages[0].rotation = 180;
after.selectedPageIds.push("a");
expect(command.undo(makeState(["ignored"]))).toEqual({
pages: [
{ id: "a", sourcePageIndex: 0, rotation: 0 },
{ id: "b", sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ["a"],
lastSelectedVisualIndex: 0,
});
expect(command.do(makeState(["ignored"]))).toEqual({
pages: [
{ id: "b", sourcePageIndex: 0, rotation: 0 },
{ id: "a", sourcePageIndex: 1, rotation: 90 },
],
selectedPageIds: ["b"],
lastSelectedVisualIndex: 0,
});
});
it("round-trips commands through serializable records", () => {
const before = makeState(["a", "b", "c"]);
const after: WorkspaceCommandState = {
pages: [before.pages[2], before.pages[0], before.pages[1]],
selectedPageIds: ["c"],
lastSelectedVisualIndex: 0,
};
const command = createSnapshotCommand({
id: "cmd-2",
type: "copy-pages",
label: "Copy pages",
timestamp: "2026-05-17T10:05:00.000Z",
before,
after,
});
const record = toWorkspaceCommandRecord(command);
const revived = reviveWorkspaceCommand(record);
expect(record).not.toHaveProperty("do");
expect(record).not.toHaveProperty("undo");
expect(revived.do(before)).toEqual(after);
expect(revived.undo(after)).toEqual(before);
});
});

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import type { PageRef } from '../pdf/pdfTypes';
import type { WorkspaceCommandRecord } from './workspaceCommands';
import type { PageRef } from "../pdf/pdfTypes";
import type { WorkspaceCommandRecord } from "./workspaceCommands";
export interface StoredWorkspace {
schemaVersion: 1;
@@ -37,4 +37,4 @@ export interface WorkspaceSummary {
export interface LoadedWorkspace {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}
}