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] Introduce stable page references instead of only original page indices
|
||||||
- [x] Support duplicate selected pages
|
- [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
|
- [ ] Extract selection as a new active workspace
|
||||||
- [ ] Add command history as a foundation for undo/redo
|
- [x] Add command history as a foundation for undo/redo
|
||||||
- [ ] Add undo/redo
|
- [x] Add undo/redo
|
||||||
|
- [ ] maybe smaller undo / redo footprint
|
||||||
- [ ] Add grid/list view toggle
|
- [ ] Add grid/list view toggle
|
||||||
|
|
||||||
### Milestone 3: Better merge and mobile handling
|
### Milestone 3: Better merge and mobile handling
|
||||||
|
|||||||
659
src/App.tsx
659
src/App.tsx
@@ -4,7 +4,31 @@ import FileLoader from './components/FileLoader';
|
|||||||
import ReorderPanel from './components/ReorderPanel';
|
import ReorderPanel from './components/ReorderPanel';
|
||||||
import ActionsPanel from './components/ActionsPanel';
|
import ActionsPanel from './components/ActionsPanel';
|
||||||
import PagePreviewModal from './components/PagePreviewModal';
|
import PagePreviewModal from './components/PagePreviewModal';
|
||||||
|
import WorkspacePanel from './components/WorkspacePanel';
|
||||||
|
import ActionDialog, {
|
||||||
|
type ActionDialogAction,
|
||||||
|
} from './components/ActionDialog';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
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 type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes';
|
||||||
import {
|
import {
|
||||||
loadPdfFromFile,
|
loadPdfFromFile,
|
||||||
@@ -21,8 +45,20 @@ const THUMBNAIL_MAX_HEIGHT = 150;
|
|||||||
const THUMBNAIL_MAX_WIDTH = 140;
|
const THUMBNAIL_MAX_WIDTH = 140;
|
||||||
const THUMBNAIL_CONCURRENCY = 3;
|
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 {
|
function createPageRefId(): string {
|
||||||
return Math.random().toString(36).slice(2);
|
return createId('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialPageRefs(pageCount: number): PageRef[] {
|
function createInitialPageRefs(pageCount: number): PageRef[] {
|
||||||
@@ -64,10 +100,24 @@ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App: React.FC = () => {
|
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 [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [pages, setPages] = useState<PageRef[]>([]);
|
||||||
const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
|
const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -105,7 +155,27 @@ 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 = () => {
|
const resetWorkspaceState = () => {
|
||||||
|
setPdf(null);
|
||||||
|
setActiveWorkspaceId(null);
|
||||||
|
setWorkspaceName('');
|
||||||
|
setWorkspaceDirty(false);
|
||||||
|
setWorkspaceMessage(null);
|
||||||
|
setWorkspaceHistory([]);
|
||||||
setSplitResults([]);
|
setSplitResults([]);
|
||||||
setSelectedPageIds([]);
|
setSelectedPageIds([]);
|
||||||
setLastSelectedVisualIndex(null);
|
setLastSelectedVisualIndex(null);
|
||||||
@@ -116,6 +186,296 @@ const App: React.FC = () => {
|
|||||||
latestPagesRef.current = [];
|
latestPagesRef.current = [];
|
||||||
setPages([]);
|
setPages([]);
|
||||||
setPreviewPageId(null);
|
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) => {
|
const loadFileAsNew = async (file: File) => {
|
||||||
@@ -130,6 +490,12 @@ const App: React.FC = () => {
|
|||||||
setPdf(loaded);
|
setPdf(loaded);
|
||||||
setPages(initialPages);
|
setPages(initialPages);
|
||||||
latestPagesRef.current = initialPages;
|
latestPagesRef.current = initialPages;
|
||||||
|
|
||||||
|
setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name));
|
||||||
|
setWorkspaceHistory([]);
|
||||||
|
setRedoHistory([]);
|
||||||
|
setWorkspaceDirty(true);
|
||||||
|
setWorkspaceMessage(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Failed to load PDF (see console).');
|
setError('Failed to load PDF (see console).');
|
||||||
@@ -211,6 +577,12 @@ const App: React.FC = () => {
|
|||||||
thumbnailCacheRef.current.clear();
|
thumbnailCacheRef.current.clear();
|
||||||
previousPageRotationsRef.current.clear();
|
previousPageRotationsRef.current.clear();
|
||||||
setPreviewPageId(null);
|
setPreviewPageId(null);
|
||||||
|
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
|
||||||
|
setWorkspaceHistory([]);
|
||||||
|
setRedoHistory([]);
|
||||||
|
setWorkspaceDirty(true);
|
||||||
|
setActiveWorkspaceId(null);
|
||||||
|
setWorkspaceMessage(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError('Failed to merge PDF (see console).');
|
setError('Failed to merge PDF (see console).');
|
||||||
@@ -414,34 +786,125 @@ const App: React.FC = () => {
|
|||||||
const hasPdf = !!pdf;
|
const hasPdf = !!pdf;
|
||||||
|
|
||||||
// === UI interactions ===
|
// === UI interactions ===
|
||||||
|
|
||||||
const handleRotatePageClockwise = (pageId: string) => {
|
const handleRotatePageClockwise = (pageId: string) => {
|
||||||
setPages((prev) =>
|
const before = getCurrentCommandState();
|
||||||
prev.map((page) =>
|
const afterPages = pages.map((page) =>
|
||||||
page.id === pageId
|
page.id === pageId
|
||||||
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
|
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
|
||||||
: page
|
: page
|
||||||
)
|
);
|
||||||
|
|
||||||
|
executeWorkspaceCommand(
|
||||||
|
createWorkspaceCommand({
|
||||||
|
type: 'page.rotate',
|
||||||
|
label: 'Rotated page clockwise',
|
||||||
|
before,
|
||||||
|
after: {
|
||||||
|
...before,
|
||||||
|
pages: afterPages,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
pageId,
|
||||||
|
degrees: 90,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRotatePageCounterclockwise = (pageId: string) => {
|
const handleRotatePageCounterclockwise = (pageId: string) => {
|
||||||
setPages((prev) =>
|
const before = getCurrentCommandState();
|
||||||
prev.map((page) =>
|
const afterPages = pages.map((page) =>
|
||||||
page.id === pageId
|
page.id === pageId
|
||||||
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
|
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
|
||||||
: page
|
: page
|
||||||
)
|
);
|
||||||
|
|
||||||
|
executeWorkspaceCommand(
|
||||||
|
createWorkspaceCommand({
|
||||||
|
type: 'page.rotate',
|
||||||
|
label: 'Rotated page counterclockwise',
|
||||||
|
before,
|
||||||
|
after: {
|
||||||
|
...before,
|
||||||
|
pages: afterPages,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
pageId,
|
||||||
|
degrees: -90,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePage = (pageId: string) => {
|
const handleDeletePage = (pageId: string) => {
|
||||||
setPages((prev) => prev.filter((page) => page.id !== pageId));
|
const page = pages.find((item) => item.id === pageId);
|
||||||
setSelectedPageIds((prev) => prev.filter((id) => 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[]) => {
|
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 = (
|
const handleToggleSelect = (
|
||||||
@@ -482,10 +945,69 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleDeleteSelected = () => {
|
const handleDeleteSelected = () => {
|
||||||
if (selectedPageIds.length === 0) return;
|
if (selectedPageIds.length === 0) return;
|
||||||
const selectedSet = new Set(selectedPageIds);
|
|
||||||
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
|
const idsToDelete = [...selectedPageIds];
|
||||||
setSelectedPageIds([]);
|
|
||||||
setLastSelectedVisualIndex(null);
|
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) => {
|
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 clampedSlot = Math.min(Math.max(insertSlot, 0), pages.length);
|
||||||
|
|
||||||
|
const afterPages = [
|
||||||
|
...pages.slice(0, clampedSlot),
|
||||||
|
...copiedPages,
|
||||||
|
...pages.slice(clampedSlot),
|
||||||
|
];
|
||||||
|
|
||||||
const thumbnailUpdates: Record<string, string> = {};
|
const thumbnailUpdates: Record<string, string> = {};
|
||||||
|
|
||||||
sourcePages.forEach((sourcePage, index) => {
|
sourcePages.forEach((sourcePage, index) => {
|
||||||
@@ -525,19 +1053,27 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setPages((prev) => {
|
const before = getCurrentCommandState();
|
||||||
const slot = Math.min(Math.max(clampedSlot, 0), prev.length);
|
|
||||||
|
|
||||||
return [
|
executeWorkspaceCommand(
|
||||||
...prev.slice(0, slot),
|
createWorkspaceCommand({
|
||||||
...copiedPages,
|
type: 'pages.copy',
|
||||||
...prev.slice(slot),
|
label:
|
||||||
];
|
copiedPages.length === 1
|
||||||
});
|
? 'Copied page'
|
||||||
|
: `Copied ${copiedPages.length} pages`,
|
||||||
// Select the newly created copies.
|
before,
|
||||||
setSelectedPageIds(copiedPages.map((page) => page.id));
|
after: {
|
||||||
setLastSelectedVisualIndex(null);
|
pages: afterPages,
|
||||||
|
selectedPageIds: copiedPages.map((page) => page.id),
|
||||||
|
lastSelectedVisualIndex: null,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
count: copiedPages.length,
|
||||||
|
insertSlot: clampedSlot,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (Object.keys(thumbnailUpdates).length > 0) {
|
if (Object.keys(thumbnailUpdates).length > 0) {
|
||||||
setReorderThumbnails((prev) => ({
|
setReorderThumbnails((prev) => ({
|
||||||
@@ -545,10 +1081,18 @@ const App: React.FC = () => {
|
|||||||
...thumbnailUpdates,
|
...thumbnailUpdates,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Existing generated outputs no longer represent the current workspace.
|
const closeActionDialog = () => {
|
||||||
setSplitResults([]);
|
setActionDialog(null);
|
||||||
resetGeneratedUrls();
|
};
|
||||||
|
|
||||||
|
const openActionDialog = (dialog: {
|
||||||
|
title: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
actions: ActionDialogAction[];
|
||||||
|
}) => {
|
||||||
|
setActionDialog(dialog);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPreview = (pageId: string) => {
|
const handleOpenPreview = (pageId: string) => {
|
||||||
@@ -589,6 +1133,22 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const key = e.key.toLowerCase();
|
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') {
|
if ((e.ctrlKey || e.metaKey) && key === 'a') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedPageIds(pages.map((page) => page.id));
|
setSelectedPageIds(pages.map((page) => page.id));
|
||||||
@@ -598,10 +1158,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPageIds.length > 0) {
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPageIds.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const selectedSet = new Set(selectedPageIds);
|
handleDeleteSelected();
|
||||||
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
|
|
||||||
setSelectedPageIds([]);
|
|
||||||
setLastSelectedVisualIndex(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +1174,7 @@ const App: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [hasPdf, previewPageId, pages, selectedPageIds]);
|
}, [hasPdf, previewPageId, pages, selectedPageIds, workspaceHistory, redoHistory]);
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
if (!pdf) return;
|
if (!pdf) return;
|
||||||
@@ -703,6 +1260,29 @@ const App: React.FC = () => {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
<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 && (
|
{showMergeOptions && pendingFile && pdf && pages.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
@@ -857,6 +1437,15 @@ const App: React.FC = () => {
|
|||||||
onNext={handlePreviewNext}
|
onNext={handlePreviewNext}
|
||||||
onClose={handleClosePreview}
|
onClose={handleClosePreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ActionDialog
|
||||||
|
open={actionDialog !== null}
|
||||||
|
title={actionDialog?.title ?? ''}
|
||||||
|
actions={actionDialog?.actions ?? []}
|
||||||
|
onClose={closeActionDialog}
|
||||||
|
>
|
||||||
|
{actionDialog?.content}
|
||||||
|
</ActionDialog>
|
||||||
</Layout>
|
</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