undo / redo behaviour, workspace concept
This commit is contained in:
8
README
8
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
|
||||
|
||||
673
src/App.tsx
673
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<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>
|
||||
);
|
||||
};
|
||||
|
||||
193
src/components/ActionDialog.tsx
Normal file
193
src/components/ActionDialog.tsx
Normal 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;
|
||||
327
src/components/WorkspacePanel.tsx
Normal file
327
src/components/WorkspacePanel.tsx
Normal 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;
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.1.2';
|
||||
export const APP_VERSION = '0.1.3';
|
||||
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