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>
);
};

View File

@@ -0,0 +1,193 @@
import React, { useEffect } from 'react';
export interface ActionDialogAction {
label: string;
onClick: () => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
autoFocus?: boolean;
title?: string;
}
interface ActionDialogProps {
open: boolean;
title: string;
children: React.ReactNode;
actions: ActionDialogAction[];
onClose: () => void;
}
const backgroundByVariant: Record<
NonNullable<ActionDialogAction['variant']>,
string
> = {
primary: '#2563eb',
secondary: '#e5e7eb',
danger: '#dc2626',
};
const colorByVariant: Record<
NonNullable<ActionDialogAction['variant']>,
string
> = {
primary: 'white',
secondary: '#111827',
danger: 'white',
};
const ActionDialog: React.FC<ActionDialogProps> = ({
open,
title,
children,
actions,
onClose,
}) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="action-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 70,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<div
style={{
width: '100%',
maxWidth: '440px',
background: 'white',
borderRadius: '0.75rem',
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
}}
>
<h2
id="action-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
{title}
</h2>
<button
type="button"
onClick={onClose}
style={{
border: 'none',
borderRadius: '999px',
width: '1.8rem',
height: '1.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close dialog"
>
×
</button>
</div>
<div
style={{
fontSize: '0.9rem',
color: '#4b5563',
lineHeight: 1.45,
}}
>
{children}
</div>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
flexWrap: 'wrap',
marginTop: '0.25rem',
}}
>
{actions.map((action) => {
const variant = action.variant ?? 'secondary';
return (
<button
key={action.label}
type="button"
onClick={() => {
void action.onClick();
}}
disabled={action.disabled}
autoFocus={action.autoFocus}
title={action.title}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: action.disabled
? '#e5e7eb'
: backgroundByVariant[variant],
color: action.disabled ? '#6b7280' : colorByVariant[variant],
cursor: action.disabled ? 'default' : 'pointer',
fontSize: '0.9rem',
}}
>
{action.label}
</button>
);
})}
</div>
</div>
</div>
);
};
export default ActionDialog;

View File

@@ -0,0 +1,327 @@
import React from 'react';
import type { WorkspaceSummary } from '../workspace/workspaceTypes';
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
interface WorkspacePanelProps {
hasPdf: boolean;
isBusy: boolean;
activeWorkspaceId: string | null;
workspaceName: string;
workspaceDirty: boolean;
workspaceMessage: string | null;
workspaces: WorkspaceSummary[];
history: WorkspaceCommandRecord[];
redoHistory: WorkspaceCommandRecord[];
onWorkspaceNameChange: (value: string) => void;
onSaveWorkspace: () => void;
onLoadWorkspace: (workspaceId: string) => void;
onDeleteWorkspace: (workspaceId: string) => void;
onRefreshWorkspaces: () => void;
onResetWorkspace: () => void;
onUndo: () => void;
onRedo: () => void;
}
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
hasPdf,
isBusy,
activeWorkspaceId,
workspaceName,
workspaceDirty,
workspaceMessage,
workspaces,
history,
redoHistory,
onWorkspaceNameChange,
onSaveWorkspace,
onLoadWorkspace,
onDeleteWorkspace,
onRefreshWorkspaces,
onResetWorkspace,
onUndo,
onRedo,
}) => {
const canUndo = history.length > 0;
const canRedo = redoHistory.length > 0;
const latestUndo = history[history.length - 1];
const latestRedo = redoHistory[redoHistory.length - 1];
return (
<div className="card">
<h2>Workspace</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Save named workspaces in this browser. PDF binaries are stored in
IndexedDB; nothing is uploaded.
</p>
<div
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<input
type="text"
value={workspaceName}
onChange={(e) => onWorkspaceNameChange(e.target.value)}
placeholder="Workspace name"
disabled={!hasPdf || isBusy}
style={{
flex: '1 1 220px',
minWidth: 0,
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.9rem',
}}
/>
<button
type="button"
className="secondary"
onClick={onUndo}
disabled={!hasPdf || isBusy || !canUndo}
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
>
Undo
</button>
<button
type="button"
className="secondary"
onClick={onRedo}
disabled={!hasPdf || isBusy || !canRedo}
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
>
Redo
</button>
<button
type="button"
className="secondary"
onClick={onSaveWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
>
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
</button>
<button
type="button"
className="secondary"
onClick={onResetWorkspace}
disabled={!hasPdf || isBusy}
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'}
>
Reset workspace
</button>
<button
type="button"
className="secondary"
onClick={onRefreshWorkspaces}
disabled={isBusy}
>
Refresh
</button>
</div>
{workspaceDirty && hasPdf && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.8rem',
color: '#92400e',
}}
>
Unsaved workspace changes.
</div>
)}
{workspaceMessage && (
<div
style={{
marginTop: '0.5rem',
fontSize: '0.85rem',
color: '#166534',
}}
>
{workspaceMessage}
</div>
)}
{workspaces.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.4rem',
}}
>
{workspaces.map((workspace) => {
const active = workspace.id === activeWorkspaceId;
return (
<div
key={workspace.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
padding: '0.5rem',
background: active ? '#eff6ff' : '#f9fafb',
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.9rem' }}>
<strong>{workspace.name}</strong>
{active && (
<span style={{ color: '#2563eb' }}> · active</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
{workspace.pdfName} · source pages:{' '}
{workspace.sourcePageCount} · workspace pages:{' '}
{workspace.workspacePageCount} · undo:{' '}
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
{new Date(workspace.updatedAt).toLocaleString()}
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.35rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onLoadWorkspace(workspace.id)}
>
Load
</button>
<button
type="button"
className="secondary"
disabled={isBusy}
onClick={() => onDeleteWorkspace(workspace.id)}
style={{
background: '#fee2e2',
color: '#991b1b',
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(history.length > 0 || redoHistory.length > 0) && (
<details style={{ marginTop: '0.75rem' }} open>
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Command history ({history.length} undo / {redoHistory.length} redo)
</summary>
<div
style={{
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
{history.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#374151',
borderLeft: '3px solid #2563eb',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
}}
>
<strong>
Undo {history.length - index}. {entry.label}
</strong>
<br />
<span style={{ color: '#6b7280' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
<div
style={{
margin: '0.25rem 0',
borderRadius: '999px',
background: '#ecfdf5',
color: '#166534',
fontSize: '0.8rem',
fontWeight: 600,
alignSelf: 'flex-start',
border: '2px solid #166534',
width: '100%',
}}
>
</div>
{redoHistory
.slice()
.reverse()
.map((entry, index) => (
<div
key={entry.id}
style={{
fontSize: '0.8rem',
color: '#9ca3af',
borderLeft: '3px solid #d1d5db',
paddingLeft: '0.45rem',
paddingTop: '0.2rem',
paddingBottom: '0.2rem',
opacity: 0.75,
}}
>
<strong>
Redo {index + 1}. {entry.label}
</strong>
<br />
<span style={{ color: '#9ca3af' }}>
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
</div>
</details>
)}
</div>
);
};
export default WorkspacePanel;

View File

@@ -1 +1 @@
export const APP_VERSION = '0.1.2';
export const APP_VERSION = '0.1.3';

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;
}