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

@@ -4,7 +4,31 @@ import FileLoader from './components/FileLoader';
import ReorderPanel from './components/ReorderPanel';
import ActionsPanel from './components/ActionsPanel';
import PagePreviewModal from './components/PagePreviewModal';
import WorkspacePanel from './components/WorkspacePanel';
import ActionDialog, {
type ActionDialogAction,
} from './components/ActionDialog';
import { PDFDocument } from 'pdf-lib';
import type {
StoredWorkspace,
WorkspaceSummary,
} from './workspace/workspaceTypes';
import type {
WorkspaceCommand,
WorkspaceCommandRecord,
WorkspaceCommandState,
} from './workspace/workspaceCommands';
import {
createSnapshotCommand,
reviveWorkspaceCommand,
toWorkspaceCommandRecord,
} from './workspace/workspaceCommands';
import {
deleteWorkspaceFromIndexedDb,
listWorkspaces,
loadWorkspaceFromIndexedDb,
saveWorkspaceToIndexedDb,
} from './workspace/workspaceDb';
import type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes';
import {
loadPdfFromFile,
@@ -21,8 +45,20 @@ const THUMBNAIL_MAX_HEIGHT = 150;
const THUMBNAIL_MAX_WIDTH = 140;
const THUMBNAIL_CONCURRENCY = 3;
function createId(prefix: string): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function defaultWorkspaceNameFromPdfName(pdfName: string): string {
return pdfName.replace(/\.pdf$/i, '') || 'Untitled workspace';
}
function createPageRefId(): string {
return Math.random().toString(36).slice(2);
return createId('page');
}
function createInitialPageRefs(pageCount: number): PageRef[] {
@@ -64,10 +100,24 @@ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
}
const App: React.FC = () => {
const [actionDialog, setActionDialog] = useState<{
title: string;
content: React.ReactNode;
actions: ActionDialogAction[];
} | null>(null);
const [pdf, setPdf] = useState<PdfFile | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
const [workspaceName, setWorkspaceName] = useState('');
const [workspaceDirty, setWorkspaceDirty] = useState(false);
const [workspaceMessage, setWorkspaceMessage] = useState<string | null>(null);
const [workspaceHistory, setWorkspaceHistory] = useState<WorkspaceCommandRecord[]>([]);
const [redoHistory, setRedoHistory] = useState<WorkspaceCommandRecord[]>([]);
const [pages, setPages] = useState<PageRef[]>([]);
const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
@@ -105,10 +155,30 @@ const App: React.FC = () => {
}
};
const refreshWorkspaces = async () => {
try {
const summaries = await listWorkspaces();
setWorkspaces(summaries);
} catch (e) {
console.error(e);
setError('Failed to read saved workspaces from browser storage.');
}
};
useEffect(() => {
void refreshWorkspaces();
}, []);
const resetWorkspaceState = () => {
setPdf(null);
setActiveWorkspaceId(null);
setWorkspaceName('');
setWorkspaceDirty(false);
setWorkspaceMessage(null);
setWorkspaceHistory([]);
setSplitResults([]);
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
setLastSelectedVisualIndex(null);
resetGeneratedUrls();
setReorderThumbnails({});
thumbnailCacheRef.current.clear();
@@ -116,6 +186,296 @@ const App: React.FC = () => {
latestPagesRef.current = [];
setPages([]);
setPreviewPageId(null);
setWorkspaceHistory([]);
setRedoHistory([]);
};
const getCurrentCommandState = (): WorkspaceCommandState => ({
pages,
selectedPageIds,
lastSelectedVisualIndex,
});
const applyCommandState = (state: WorkspaceCommandState) => {
setPages(state.pages);
latestPagesRef.current = state.pages;
setSelectedPageIds(state.selectedPageIds);
setLastSelectedVisualIndex(state.lastSelectedVisualIndex);
};
const invalidateWorkspaceOutputs = () => {
setSplitResults([]);
resetGeneratedUrls();
setWorkspaceDirty(true);
setWorkspaceMessage(null);
};
const executeWorkspaceCommand = (command: WorkspaceCommand) => {
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setWorkspaceHistory((prev) => [...prev, toWorkspaceCommandRecord(command)]);
setRedoHistory([]);
invalidateWorkspaceOutputs();
};
const createWorkspaceCommand = (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 handleUndo = () => {
const record = workspaceHistory[workspaceHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const previousState = command.undo(getCurrentCommandState());
applyCommandState(previousState);
setWorkspaceHistory((prev) => prev.slice(0, -1));
setRedoHistory((prev) => [...prev, record]);
invalidateWorkspaceOutputs();
};
const handleRedo = () => {
const record = redoHistory[redoHistory.length - 1];
if (!record) return;
const command = reviveWorkspaceCommand(record);
const nextState = command.do(getCurrentCommandState());
applyCommandState(nextState);
setRedoHistory((prev) => prev.slice(0, -1));
setWorkspaceHistory((prev) => [...prev, record]);
invalidateWorkspaceOutputs();
};
const handleSaveWorkspace = async (): Promise<boolean> => {
if (!pdf || pages.length === 0) return false;
setError(null);
const now = new Date().toISOString();
const name = workspaceName.trim() || defaultWorkspaceNameFromPdfName(pdf.name);
const workspaceId = activeWorkspaceId ?? createId('workspace');
const existing = workspaces.find((workspace) => workspace.id === workspaceId);
const workspace: StoredWorkspace = {
schemaVersion: 1,
id: workspaceId,
name,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
pdfId: pdf.id,
pdfName: pdf.name,
sourcePageCount: pdf.pageCount,
pages,
selectedPageIds,
history: workspaceHistory,
redoHistory,
};
setIsBusy(true);
try {
await saveWorkspaceToIndexedDb({
workspace,
pdfArrayBuffer: pdf.arrayBuffer,
});
setActiveWorkspaceId(workspaceId);
setWorkspaceName(name);
setWorkspaceDirty(false);
setWorkspaceMessage(`Workspace "${name}" saved.`);
await refreshWorkspaces();
return true;
} catch (e) {
console.error(e);
setError(
'Failed to save workspace. The browser storage quota may be full.'
);
return false;
} finally {
setIsBusy(false);
}
};
const performResetWorkspace = () => {
resetWorkspaceState();
};
const handleResetWorkspace = () => {
if (!pdf) return;
if (!workspaceDirty) {
performResetWorkspace();
return;
}
openActionDialog({
title: 'Reset workspace?',
content: (
<>
<p style={{ marginTop: 0 }}>
This workspace has unsaved changes.
</p>
<p style={{ marginBottom: 0 }}>
Do you want to save it before resetting?
</p>
</>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Reset without saving',
variant: 'danger',
onClick: () => {
closeActionDialog();
performResetWorkspace();
},
},
{
label: 'Save and reset',
variant: 'primary',
autoFocus: true,
onClick: async () => {
closeActionDialog();
const saved = await handleSaveWorkspace();
if (saved) {
performResetWorkspace();
}
},
},
],
});
};
const handleLoadWorkspace = async (workspaceId: string) => {
setError(null);
setIsBusy(true);
try {
const loaded = await loadWorkspaceFromIndexedDb(workspaceId);
if (!loaded) {
setError('Workspace not found.');
await refreshWorkspaces();
return;
}
resetGeneratedUrls();
const doc = await PDFDocument.load(loaded.pdfArrayBuffer);
const loadedPdf: PdfFile = {
id: loaded.workspace.pdfId,
name: loaded.workspace.pdfName,
pageCount: doc.getPageCount(),
arrayBuffer: loaded.pdfArrayBuffer,
doc,
};
setPdf(loadedPdf);
setPages(loaded.workspace.pages);
latestPagesRef.current = loaded.workspace.pages;
setSelectedPageIds(loaded.workspace.selectedPageIds ?? []);
setLastSelectedVisualIndex(null);
setSplitResults([]);
setPreviewPageId(null);
setReorderThumbnails({});
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
setActiveWorkspaceId(loaded.workspace.id);
setWorkspaceName(loaded.workspace.name);
setWorkspaceHistory(loaded.workspace.history ?? []);
setRedoHistory(loaded.workspace.redoHistory ?? []);
setWorkspaceDirty(false);
setWorkspaceMessage(`Workspace "${loaded.workspace.name}" loaded.`);
} catch (e) {
console.error(e);
setError('Failed to load workspace from browser storage.');
} finally {
setIsBusy(false);
}
};
const handleDeleteWorkspace = (workspaceId: string) => {
const workspace = workspaces.find((item) => item.id === workspaceId);
const name = workspace?.name ?? 'this workspace';
openActionDialog({
title: 'Delete workspace?',
content: (
<>
<p style={{ marginTop: 0 }}>
Delete the saved workspace <strong>{name}</strong> from this browser?
</p>
<p style={{ marginBottom: 0 }}>
The currently open in-memory document will not be closed, but the saved
workspace entry will be removed.
</p>
</>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Delete workspace',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
void performDeleteWorkspace(workspaceId);
},
},
],
});
};
const performDeleteWorkspace = async (workspaceId: string) => {
setError(null);
try {
await deleteWorkspaceFromIndexedDb(workspaceId);
if (activeWorkspaceId === workspaceId) {
setActiveWorkspaceId(null);
setWorkspaceDirty(true);
setWorkspaceMessage(
'Saved workspace deleted. Current in-memory document remains open.'
);
}
await refreshWorkspaces();
} catch (e) {
console.error(e);
setError('Failed to delete workspace.');
}
};
const loadFileAsNew = async (file: File) => {
@@ -130,6 +490,12 @@ const App: React.FC = () => {
setPdf(loaded);
setPages(initialPages);
latestPagesRef.current = initialPages;
setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name));
setWorkspaceHistory([]);
setRedoHistory([]);
setWorkspaceDirty(true);
setWorkspaceMessage(null);
} catch (e) {
console.error(e);
setError('Failed to load PDF (see console).');
@@ -211,6 +577,12 @@ const App: React.FC = () => {
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
setPreviewPageId(null);
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
setWorkspaceHistory([]);
setRedoHistory([]);
setWorkspaceDirty(true);
setActiveWorkspaceId(null);
setWorkspaceMessage(null);
} catch (e) {
console.error(e);
setError('Failed to merge PDF (see console).');
@@ -414,34 +786,125 @@ const App: React.FC = () => {
const hasPdf = !!pdf;
// === UI interactions ===
const handleRotatePageClockwise = (pageId: string) => {
setPages((prev) =>
prev.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
: page
)
const before = getCurrentCommandState();
const afterPages = pages.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
: page
);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.rotate',
label: 'Rotated page clockwise',
before,
after: {
...before,
pages: afterPages,
},
details: {
pageId,
degrees: 90,
},
})
);
};
const handleRotatePageCounterclockwise = (pageId: string) => {
setPages((prev) =>
prev.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
: page
)
const before = getCurrentCommandState();
const afterPages = pages.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
: page
);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.rotate',
label: 'Rotated page counterclockwise',
before,
after: {
...before,
pages: afterPages,
},
details: {
pageId,
degrees: -90,
},
})
);
};
const handleDeletePage = (pageId: string) => {
setPages((prev) => prev.filter((page) => page.id !== pageId));
setSelectedPageIds((prev) => prev.filter((id) => id !== pageId));
const page = pages.find((item) => item.id === pageId);
const visualIndex = page ? pages.indexOf(page) : -1;
const pageLabel =
visualIndex >= 0 ? `page at position ${visualIndex + 1}` : 'this page';
openActionDialog({
title: 'Delete page?',
content: (
<p style={{ margin: 0 }}>
Delete <strong>{pageLabel}</strong> from the current workspace?
</p>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Delete page',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
performDeletePage(pageId);
},
},
],
});
};
const performDeletePage = (pageId: string) => {
const before = getCurrentCommandState();
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.delete',
label: 'Deleted page',
before,
after: {
pages: pages.filter((page) => page.id !== pageId),
selectedPageIds: selectedPageIds.filter((id) => id !== pageId),
lastSelectedVisualIndex: null,
},
details: {
pageId,
},
})
);
};
const handleReorder = (newPages: PageRef[]) => {
setPages(newPages);
const before = getCurrentCommandState();
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.reorder',
label: 'Reordered pages',
before,
after: {
...before,
pages: newPages,
},
details: {
pageCount: newPages.length,
},
})
);
};
const handleToggleSelect = (
@@ -482,10 +945,69 @@ const App: React.FC = () => {
const handleDeleteSelected = () => {
if (selectedPageIds.length === 0) return;
const selectedSet = new Set(selectedPageIds);
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
const idsToDelete = [...selectedPageIds];
openActionDialog({
title:
idsToDelete.length === 1
? 'Delete selected page?'
: 'Delete selected pages?',
content: (
<p style={{ margin: 0 }}>
Delete{' '}
<strong>
{idsToDelete.length === 1
? '1 selected page'
: `${idsToDelete.length} selected pages`}
</strong>{' '}
from the current workspace?
</p>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label:
idsToDelete.length === 1 ? 'Delete page' : 'Delete pages',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
performDeleteSelected(idsToDelete);
},
},
],
});
};
const performDeleteSelected = (pageIdsToDelete: string[]) => {
if (pageIdsToDelete.length === 0) return;
const before = getCurrentCommandState();
const selectedSet = new Set(pageIdsToDelete);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.delete',
label:
pageIdsToDelete.length === 1
? 'Deleted selected page'
: `Deleted ${pageIdsToDelete.length} selected pages`,
before,
after: {
pages: pages.filter((page) => !selectedSet.has(page.id)),
selectedPageIds: [],
lastSelectedVisualIndex: null,
},
details: {
count: pageIdsToDelete.length,
},
})
);
};
const handleCopyPagesToSlot = (pageIds: string[], insertSlot: number) => {
@@ -505,6 +1027,12 @@ const App: React.FC = () => {
const clampedSlot = Math.min(Math.max(insertSlot, 0), pages.length);
const afterPages = [
...pages.slice(0, clampedSlot),
...copiedPages,
...pages.slice(clampedSlot),
];
const thumbnailUpdates: Record<string, string> = {};
sourcePages.forEach((sourcePage, index) => {
@@ -525,19 +1053,27 @@ const App: React.FC = () => {
}
});
setPages((prev) => {
const slot = Math.min(Math.max(clampedSlot, 0), prev.length);
const before = getCurrentCommandState();
return [
...prev.slice(0, slot),
...copiedPages,
...prev.slice(slot),
];
});
// Select the newly created copies.
setSelectedPageIds(copiedPages.map((page) => page.id));
setLastSelectedVisualIndex(null);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.copy',
label:
copiedPages.length === 1
? 'Copied page'
: `Copied ${copiedPages.length} pages`,
before,
after: {
pages: afterPages,
selectedPageIds: copiedPages.map((page) => page.id),
lastSelectedVisualIndex: null,
},
details: {
count: copiedPages.length,
insertSlot: clampedSlot,
},
})
);
if (Object.keys(thumbnailUpdates).length > 0) {
setReorderThumbnails((prev) => ({
@@ -545,10 +1081,18 @@ const App: React.FC = () => {
...thumbnailUpdates,
}));
}
};
// Existing generated outputs no longer represent the current workspace.
setSplitResults([]);
resetGeneratedUrls();
const closeActionDialog = () => {
setActionDialog(null);
};
const openActionDialog = (dialog: {
title: string;
content: React.ReactNode;
actions: ActionDialogAction[];
}) => {
setActionDialog(dialog);
};
const handleOpenPreview = (pageId: string) => {
@@ -589,6 +1133,22 @@ const App: React.FC = () => {
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'y') {
e.preventDefault();
handleRedo();
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'a') {
e.preventDefault();
setSelectedPageIds(pages.map((page) => page.id));
@@ -598,10 +1158,7 @@ const App: React.FC = () => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPageIds.length > 0) {
e.preventDefault();
const selectedSet = new Set(selectedPageIds);
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
handleDeleteSelected();
return;
}
@@ -617,7 +1174,7 @@ const App: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [hasPdf, previewPageId, pages, selectedPageIds]);
}, [hasPdf, previewPageId, pages, selectedPageIds, workspaceHistory, redoHistory]);
const handleSplit = async () => {
if (!pdf) return;
@@ -703,6 +1260,29 @@ const App: React.FC = () => {
<Layout>
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
<WorkspacePanel
hasPdf={hasPdf}
isBusy={isBusy}
activeWorkspaceId={activeWorkspaceId}
workspaceName={workspaceName}
workspaceDirty={workspaceDirty}
workspaceMessage={workspaceMessage}
workspaces={workspaces}
history={workspaceHistory}
redoHistory={redoHistory}
onWorkspaceNameChange={(value) => {
setWorkspaceName(value);
setWorkspaceDirty(true);
}}
onSaveWorkspace={handleSaveWorkspace}
onLoadWorkspace={handleLoadWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
onRefreshWorkspaces={refreshWorkspaces}
onResetWorkspace={handleResetWorkspace}
onUndo={handleUndo}
onRedo={handleRedo}
/>
{showMergeOptions && pendingFile && pdf && pages.length > 0 && (
<div
className="card"
@@ -857,6 +1437,15 @@ const App: React.FC = () => {
onNext={handlePreviewNext}
onClose={handleClosePreview}
/>
<ActionDialog
open={actionDialog !== null}
title={actionDialog?.title ?? ''}
actions={actionDialog?.actions ?? []}
onClose={closeActionDialog}
>
{actionDialog?.content}
</ActionDialog>
</Layout>
);
};