undo / redo behaviour, workspace concept
This commit is contained in:
84
src/workspace/workspaceCommands.ts
Normal file
84
src/workspace/workspaceCommands.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
210
src/workspace/workspaceDb.ts
Normal file
210
src/workspace/workspaceDb.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
40
src/workspace/workspaceTypes.ts
Normal file
40
src/workspace/workspaceTypes.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user