undo / redo behaviour, workspace concept
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user