undo / redo behaviour, workspace concept

This commit is contained in:
2026-05-16 18:41:56 +02:00
parent 3ba993277b
commit afeb46a210
8 changed files with 1492 additions and 45 deletions

View File

@@ -0,0 +1,84 @@
import type { PageRef } from '../pdf/pdfTypes';
export interface WorkspaceCommandState {
pages: PageRef[];
selectedPageIds: string[];
lastSelectedVisualIndex: number | null;
}
export interface WorkspaceCommandPayload {
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}
export interface WorkspaceCommandRecord {
id: string;
type: string;
label: string;
timestamp: string;
payload: WorkspaceCommandPayload;
}
export interface WorkspaceCommand extends WorkspaceCommandRecord {
do: (state: WorkspaceCommandState) => WorkspaceCommandState;
undo: (state: WorkspaceCommandState) => WorkspaceCommandState;
}
export function cloneCommandState(
state: WorkspaceCommandState
): WorkspaceCommandState {
return {
pages: state.pages.map((page) => ({ ...page })),
selectedPageIds: [...state.selectedPageIds],
lastSelectedVisualIndex: state.lastSelectedVisualIndex,
};
}
export function createSnapshotCommand(params: {
id: string;
type: string;
label: string;
timestamp?: string;
before: WorkspaceCommandState;
after: WorkspaceCommandState;
details?: Record<string, unknown>;
}): WorkspaceCommand {
return reviveWorkspaceCommand({
id: params.id,
type: params.type,
label: params.label,
timestamp: params.timestamp ?? new Date().toISOString(),
payload: {
before: cloneCommandState(params.before),
after: cloneCommandState(params.after),
details: params.details,
},
});
}
export function reviveWorkspaceCommand(
record: WorkspaceCommandRecord
): WorkspaceCommand {
return {
...record,
do: () => cloneCommandState(record.payload.after),
undo: () => cloneCommandState(record.payload.before),
};
}
export function toWorkspaceCommandRecord(
command: WorkspaceCommand
): WorkspaceCommandRecord {
return {
id: command.id,
type: command.type,
label: command.label,
timestamp: command.timestamp,
payload: {
before: cloneCommandState(command.payload.before),
after: cloneCommandState(command.payload.after),
details: command.payload.details,
},
};
}

View File

@@ -0,0 +1,210 @@
import type {
LoadedWorkspace,
StoredWorkspace,
WorkspaceSummary,
} from './workspaceTypes';
const DB_NAME = 'pdf-tools-workspaces';
const DB_VERSION = 1;
const WORKSPACE_STORE = 'workspaces';
const PDF_STORE = 'pdfBinaries';
interface PdfBinaryRecord {
pdfId: string;
name: string;
blob: Blob;
size: number;
createdAt: string;
updatedAt: string;
}
interface SaveWorkspaceInput {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
function openWorkspaceDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(WORKSPACE_STORE)) {
const workspaceStore = db.createObjectStore(WORKSPACE_STORE, {
keyPath: 'id',
});
workspaceStore.createIndex('updatedAt', 'updatedAt', {
unique: false,
});
workspaceStore.createIndex('pdfId', 'pdfId', {
unique: false,
});
}
if (!db.objectStoreNames.contains(PDF_STORE)) {
db.createObjectStore(PDF_STORE, {
keyPath: 'pdfId',
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function listWorkspaces(): Promise<WorkspaceSummary[]> {
const db = await openWorkspaceDb();
try {
const tx = db.transaction(WORKSPACE_STORE, 'readonly');
const store = tx.objectStore(WORKSPACE_STORE);
const records = await requestToPromise<StoredWorkspace[]>(store.getAll());
await transactionDone(tx);
return records
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
pdfId: workspace.pdfId,
pdfName: workspace.pdfName,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
sourcePageCount: workspace.sourcePageCount,
workspacePageCount: workspace.pages.length,
historyCount: workspace.history.length,
redoCount: workspace.redoHistory?.length ?? 0,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} finally {
db.close();
}
}
export async function saveWorkspaceToIndexedDb({
workspace,
pdfArrayBuffer,
}: SaveWorkspaceInput): Promise<void> {
const db = await openWorkspaceDb();
try {
const now = new Date().toISOString();
const pdfRecord: PdfBinaryRecord = {
pdfId: workspace.pdfId,
name: workspace.pdfName,
blob: new Blob([pdfArrayBuffer], { type: 'application/pdf' }),
size: pdfArrayBuffer.byteLength,
createdAt: workspace.createdAt,
updatedAt: now,
};
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
tx.objectStore(PDF_STORE).put(pdfRecord);
tx.objectStore(WORKSPACE_STORE).put(workspace);
await transactionDone(tx);
} finally {
db.close();
}
}
export async function loadWorkspaceFromIndexedDb(
workspaceId: string
): Promise<LoadedWorkspace | null> {
const db = await openWorkspaceDb();
try {
const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readonly');
const workspace = await requestToPromise<StoredWorkspace | undefined>(
tx.objectStore(WORKSPACE_STORE).get(workspaceId)
);
if (!workspace) {
await transactionDone(tx);
return null;
}
const pdfRecord = await requestToPromise<PdfBinaryRecord | undefined>(
tx.objectStore(PDF_STORE).get(workspace.pdfId)
);
await transactionDone(tx);
if (!pdfRecord) {
throw new Error(`Missing PDF binary for workspace ${workspaceId}`);
}
const pdfArrayBuffer = await pdfRecord.blob.arrayBuffer();
return {
workspace,
pdfArrayBuffer,
};
} finally {
db.close();
}
}
export async function deleteWorkspaceFromIndexedDb(
workspaceId: string
): Promise<void> {
const db = await openWorkspaceDb();
try {
const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly');
const workspace = await requestToPromise<StoredWorkspace | undefined>(
lookupTx.objectStore(WORKSPACE_STORE).get(workspaceId)
);
await transactionDone(lookupTx);
if (!workspace) return;
const deleteTx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readwrite');
deleteTx.objectStore(WORKSPACE_STORE).delete(workspaceId);
await transactionDone(deleteTx);
// Clean up PDF binary if no remaining workspace references it.
const remainingWorkspaces = await listWorkspaces();
const pdfStillUsed = remainingWorkspaces.some(
(summary) => summary.pdfId === workspace.pdfId
);
if (!pdfStillUsed) {
const cleanupDb = await openWorkspaceDb();
try {
const cleanupTx = cleanupDb.transaction(PDF_STORE, 'readwrite');
cleanupTx.objectStore(PDF_STORE).delete(workspace.pdfId);
await transactionDone(cleanupTx);
} finally {
cleanupDb.close();
}
}
} finally {
db.close();
}
}

View File

@@ -0,0 +1,40 @@
import type { PageRef } from '../pdf/pdfTypes';
import type { WorkspaceCommandRecord } from './workspaceCommands';
export interface StoredWorkspace {
schemaVersion: 1;
id: string;
name: string;
createdAt: string;
updatedAt: string;
pdfId: string;
pdfName: string;
sourcePageCount: number;
pages: PageRef[];
selectedPageIds: string[];
history: WorkspaceCommandRecord[];
redoHistory?: WorkspaceCommandRecord[];
}
export interface WorkspaceSummary {
id: string;
name: string;
pdfId: string;
pdfName: string;
createdAt: string;
updatedAt: string;
sourcePageCount: number;
workspacePageCount: number;
historyCount: number;
redoCount: number;
}
export interface LoadedWorkspace {
workspace: StoredWorkspace;
pdfArrayBuffer: ArrayBuffer;
}