From afeb46a210bc9affdbb6ceddcae10d97961de039 Mon Sep 17 00:00:00 2001 From: zemion Date: Sat, 16 May 2026 18:41:56 +0200 Subject: [PATCH] undo / redo behaviour, workspace concept --- README | 8 +- src/App.tsx | 673 +++++++++++++++++++++++++++-- src/components/ActionDialog.tsx | 193 +++++++++ src/components/WorkspacePanel.tsx | 327 ++++++++++++++ src/version.ts | 2 +- src/workspace/workspaceCommands.ts | 84 ++++ src/workspace/workspaceDb.ts | 210 +++++++++ src/workspace/workspaceTypes.ts | 40 ++ 8 files changed, 1492 insertions(+), 45 deletions(-) create mode 100644 src/components/ActionDialog.tsx create mode 100644 src/components/WorkspacePanel.tsx create mode 100644 src/workspace/workspaceCommands.ts create mode 100644 src/workspace/workspaceDb.ts create mode 100644 src/workspace/workspaceTypes.ts diff --git a/README b/README index 782d16d..8bccd70 100644 --- a/README +++ b/README @@ -48,9 +48,13 @@ The project is currently optimized around page-level PDF work: split, merge, reo - [x] Introduce stable page references instead of only original page indices - [x] Support duplicate selected pages +- [x] Save / reload the last state from storage +- [x] Support workspaces +- [x] Reset workspace - [ ] Extract selection as a new active workspace -- [ ] Add command history as a foundation for undo/redo -- [ ] Add undo/redo +- [x] Add command history as a foundation for undo/redo +- [x] Add undo/redo +- [ ] maybe smaller undo / redo footprint - [ ] Add grid/list view toggle ### Milestone 3: Better merge and mobile handling diff --git a/src/App.tsx b/src/App.tsx index 2d717fd..46a1c0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); + const [workspaces, setWorkspaces] = useState([]); + const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); + const [workspaceName, setWorkspaceName] = useState(''); + const [workspaceDirty, setWorkspaceDirty] = useState(false); + const [workspaceMessage, setWorkspaceMessage] = useState(null); + const [workspaceHistory, setWorkspaceHistory] = useState([]); + const [redoHistory, setRedoHistory] = useState([]); + const [pages, setPages] = useState([]); const [reorderThumbnails, setReorderThumbnails] = useState>({}); @@ -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; + }): 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 => { + 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: ( + <> +

+ This workspace has unsaved changes. +

+

+ Do you want to save it before resetting? +

+ + ), + 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: ( + <> +

+ Delete the saved workspace {name} from this browser? +

+

+ The currently open in-memory document will not be closed, but the saved + workspace entry will be removed. +

+ + ), + 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: ( +

+ Delete {pageLabel} from the current workspace? +

+ ), + 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: ( +

+ Delete{' '} + + {idsToDelete.length === 1 + ? '1 selected page' + : `${idsToDelete.length} selected pages`} + {' '} + from the current workspace? +

+ ), + 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 = {}; 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 = () => { + { + setWorkspaceName(value); + setWorkspaceDirty(true); + }} + onSaveWorkspace={handleSaveWorkspace} + onLoadWorkspace={handleLoadWorkspace} + onDeleteWorkspace={handleDeleteWorkspace} + onRefreshWorkspaces={refreshWorkspaces} + onResetWorkspace={handleResetWorkspace} + onUndo={handleUndo} + onRedo={handleRedo} + /> + {showMergeOptions && pendingFile && pdf && pages.length > 0 && (
{ onNext={handlePreviewNext} onClose={handleClosePreview} /> + + + {actionDialog?.content} + ); }; diff --git a/src/components/ActionDialog.tsx b/src/components/ActionDialog.tsx new file mode 100644 index 0000000..b4a399c --- /dev/null +++ b/src/components/ActionDialog.tsx @@ -0,0 +1,193 @@ +import React, { useEffect } from 'react'; + +export interface ActionDialogAction { + label: string; + onClick: () => void | Promise; + 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, + string +> = { + primary: '#2563eb', + secondary: '#e5e7eb', + danger: '#dc2626', +}; + +const colorByVariant: Record< + NonNullable, + string +> = { + primary: 'white', + secondary: '#111827', + danger: 'white', +}; + +const ActionDialog: React.FC = ({ + 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 ( +
{ + 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', + }} + > +
+
+

+ {title} +

+ + +
+ +
+ {children} +
+ +
+ {actions.map((action) => { + const variant = action.variant ?? 'secondary'; + + return ( + + ); + })} +
+
+
+ ); +}; + +export default ActionDialog; \ No newline at end of file diff --git a/src/components/WorkspacePanel.tsx b/src/components/WorkspacePanel.tsx new file mode 100644 index 0000000..3759c23 --- /dev/null +++ b/src/components/WorkspacePanel.tsx @@ -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 = ({ + 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 ( +
+

Workspace

+ +

+ Save named workspaces in this browser. PDF binaries are stored in + IndexedDB; nothing is uploaded. +

+ +
+ 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', + }} + /> + + + + + + + + + + +
+ + {workspaceDirty && hasPdf && ( +
+ Unsaved workspace changes. +
+ )} + + {workspaceMessage && ( +
+ {workspaceMessage} +
+ )} + + {workspaces.length > 0 && ( +
+ Saved workspaces + +
+ {workspaces.map((workspace) => { + const active = workspace.id === activeWorkspaceId; + + return ( +
+
+
+ {workspace.name} + {active && ( + · active + )} +
+ +
+ {workspace.pdfName} · source pages:{' '} + {workspace.sourcePageCount} · workspace pages:{' '} + {workspace.workspacePageCount} · undo:{' '} + {workspace.historyCount} · redo: {workspace.redoCount} · updated{' '} + {new Date(workspace.updatedAt).toLocaleString()} +
+
+ +
+ + + +
+
+ ); + })} +
+
+ )} + + {(history.length > 0 || redoHistory.length > 0) && ( +
+ + Command history ({history.length} undo / {redoHistory.length} redo) + + +
+ {history.map((entry, index) => ( +
+ + Undo {history.length - index}. {entry.label} + +
+ + {new Date(entry.timestamp).toLocaleString()} + +
+ ))} + +
+ +
+ + {redoHistory + .slice() + .reverse() + .map((entry, index) => ( +
+ + Redo {index + 1}. {entry.label} + +
+ + {new Date(entry.timestamp).toLocaleString()} + +
+ ))} +
+
+ )} +
+ ); +}; + +export default WorkspacePanel; \ No newline at end of file diff --git a/src/version.ts b/src/version.ts index a1813ea..3ae6c25 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = '0.1.2'; \ No newline at end of file +export const APP_VERSION = '0.1.3'; \ No newline at end of file diff --git a/src/workspace/workspaceCommands.ts b/src/workspace/workspaceCommands.ts new file mode 100644 index 0000000..be024a5 --- /dev/null +++ b/src/workspace/workspaceCommands.ts @@ -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; +} + +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; +}): 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, + }, + }; +} \ No newline at end of file diff --git a/src/workspace/workspaceDb.ts b/src/workspace/workspaceDb.ts new file mode 100644 index 0000000..34e122a --- /dev/null +++ b/src/workspace/workspaceDb.ts @@ -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(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +function openWorkspaceDb(): Promise { + 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 { + const db = await openWorkspaceDb(); + + try { + const tx = db.transaction(WORKSPACE_STORE, 'readonly'); + const store = tx.objectStore(WORKSPACE_STORE); + + const records = await requestToPromise(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 { + 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 { + const db = await openWorkspaceDb(); + + try { + const tx = db.transaction([WORKSPACE_STORE, PDF_STORE], 'readonly'); + + const workspace = await requestToPromise( + tx.objectStore(WORKSPACE_STORE).get(workspaceId) + ); + + if (!workspace) { + await transactionDone(tx); + return null; + } + + const pdfRecord = await requestToPromise( + 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 { + const db = await openWorkspaceDb(); + + try { + const lookupTx = db.transaction(WORKSPACE_STORE, 'readonly'); + const workspace = await requestToPromise( + 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(); + } +} \ No newline at end of file diff --git a/src/workspace/workspaceTypes.ts b/src/workspace/workspaceTypes.ts new file mode 100644 index 0000000..2c08077 --- /dev/null +++ b/src/workspace/workspaceTypes.ts @@ -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; +} \ No newline at end of file