1149 lines
30 KiB
TypeScript
1149 lines
30 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import Layout from "./components/Layout";
|
|
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 HelpDialog from "./components/HelpDialog";
|
|
import { PDFDocument } from "pdf-lib";
|
|
import type {
|
|
StoredWorkspace,
|
|
WorkspaceSummary,
|
|
} from "./workspace/workspaceTypes";
|
|
import {
|
|
createInitialPageRefs,
|
|
createPageRefId,
|
|
createWorkspaceId,
|
|
defaultWorkspaceNameFromPdfName,
|
|
normalizeRotation,
|
|
useWorkspaceState,
|
|
} from "./workspace/useWorkspaceState";
|
|
import {
|
|
deleteWorkspaceFromIndexedDb,
|
|
listWorkspaces,
|
|
loadWorkspaceFromIndexedDb,
|
|
saveWorkspaceToIndexedDb,
|
|
} from "./workspace/workspaceDb";
|
|
import type { PageRef, PdfFile } from "./pdf/pdfTypes";
|
|
import {
|
|
loadPdfFromFile,
|
|
mergePdfFiles,
|
|
splitIntoSinglePages,
|
|
exportPages,
|
|
} from "./pdf/pdfService";
|
|
import { usePdfThumbnails } from "./pdf/usePdfThumbnails";
|
|
import { usePdfGeneratedOutputs } from "./hooks/usePdfGeneratedOutputs";
|
|
|
|
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
|
|
const tagName = target.tagName.toLowerCase();
|
|
return (
|
|
target.isContentEditable ||
|
|
tagName === "input" ||
|
|
tagName === "textarea" ||
|
|
tagName === "select"
|
|
);
|
|
}
|
|
|
|
const App: React.FC = () => {
|
|
const [actionDialog, setActionDialog] = useState<{
|
|
title: string;
|
|
content: React.ReactNode;
|
|
actions: ActionDialogAction[];
|
|
} | null>(null);
|
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
|
|
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 [previewPageId, setPreviewPageId] = useState<string | null>(null);
|
|
|
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
|
const [showMergeOptions, setShowMergeOptions] = useState(false);
|
|
const [mergeMode, setMergeMode] = useState<
|
|
"overwrite" | "append" | "insertAt"
|
|
>("append");
|
|
const [mergeInsertAt, setMergeInsertAt] = useState<string>("");
|
|
|
|
const {
|
|
splitDownloads,
|
|
subsetDownload,
|
|
exportDownload,
|
|
replaceSplitResults,
|
|
replaceSubsetResult,
|
|
replaceExportResult,
|
|
clearAllResults: clearGeneratedOutputs,
|
|
} = usePdfGeneratedOutputs();
|
|
|
|
const handleWorkspaceContentChanged = useCallback(() => {
|
|
clearGeneratedOutputs();
|
|
}, [clearGeneratedOutputs]);
|
|
|
|
const {
|
|
pages,
|
|
selectedPageIds,
|
|
setSelectedPageIds,
|
|
lastSelectedVisualIndex,
|
|
setLastSelectedVisualIndex,
|
|
workspaceDirty,
|
|
setWorkspaceDirty,
|
|
workspaceMessage,
|
|
setWorkspaceMessage,
|
|
workspaceHistory,
|
|
redoHistory,
|
|
getCurrentCommandState,
|
|
createWorkspaceCommand,
|
|
executeWorkspaceCommand,
|
|
handleUndo,
|
|
handleRedo,
|
|
replaceWorkspaceState,
|
|
resetWorkspaceState: resetWorkspaceCommandState,
|
|
} = useWorkspaceState({ onContentChanged: handleWorkspaceContentChanged });
|
|
|
|
const handleThumbnailError = useCallback(
|
|
(message: string, thrown: unknown) => {
|
|
console.error(thrown);
|
|
setError(message);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const { thumbnails: reorderThumbnails, clearThumbnailCache } =
|
|
usePdfThumbnails({
|
|
pdf,
|
|
pages,
|
|
onError: handleThumbnailError,
|
|
});
|
|
|
|
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("");
|
|
resetWorkspaceCommandState();
|
|
clearGeneratedOutputs();
|
|
clearThumbnailCache();
|
|
setPreviewPageId(null);
|
|
};
|
|
|
|
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 ?? createWorkspaceId();
|
|
|
|
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;
|
|
}
|
|
|
|
clearGeneratedOutputs();
|
|
|
|
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);
|
|
replaceWorkspaceState({
|
|
pages: loaded.workspace.pages,
|
|
selectedPageIds: loaded.workspace.selectedPageIds ?? [],
|
|
lastSelectedVisualIndex: null,
|
|
history: loaded.workspace.history ?? [],
|
|
redoHistory: loaded.workspace.redoHistory ?? [],
|
|
dirty: false,
|
|
message: `Workspace "${loaded.workspace.name}" loaded.`,
|
|
});
|
|
setPreviewPageId(null);
|
|
|
|
clearThumbnailCache();
|
|
|
|
setActiveWorkspaceId(loaded.workspace.id);
|
|
setWorkspaceName(loaded.workspace.name);
|
|
} 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) => {
|
|
setError(null);
|
|
resetWorkspaceState();
|
|
|
|
setIsBusy(true);
|
|
try {
|
|
const loaded = await loadPdfFromFile(file);
|
|
const initialPages = createInitialPageRefs(loaded.pageCount);
|
|
|
|
setPdf(loaded);
|
|
replaceWorkspaceState({
|
|
pages: initialPages,
|
|
selectedPageIds: [],
|
|
lastSelectedVisualIndex: null,
|
|
history: [],
|
|
redoHistory: [],
|
|
dirty: true,
|
|
message: null,
|
|
});
|
|
|
|
setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name));
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Failed to load PDF (see console).");
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleFileLoaded = (file: File) => {
|
|
if (!pdf || pages.length === 0) {
|
|
void loadFileAsNew(file);
|
|
} else {
|
|
setPendingFile(file);
|
|
setShowMergeOptions(true);
|
|
setMergeMode("append");
|
|
setMergeInsertAt(String(pages.length + 1));
|
|
}
|
|
};
|
|
|
|
const handleMergeCancel = () => {
|
|
setPendingFile(null);
|
|
setShowMergeOptions(false);
|
|
};
|
|
|
|
const handleMergeConfirm = async () => {
|
|
if (!pendingFile) return;
|
|
|
|
if (!pdf || mergeMode === "overwrite") {
|
|
await loadFileAsNew(pendingFile);
|
|
setPendingFile(null);
|
|
setShowMergeOptions(false);
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setIsBusy(true);
|
|
|
|
try {
|
|
// 1) Materialize the current in-memory workspace (page refs + rotations)
|
|
const currentBlob = await exportPages(pdf, pages);
|
|
const currentArrayBuffer = await currentBlob.arrayBuffer();
|
|
const currentDoc = await PDFDocument.load(currentArrayBuffer);
|
|
const currentPdf: PdfFile = {
|
|
id: pdf.id,
|
|
name: pdf.name,
|
|
doc: currentDoc,
|
|
arrayBuffer: currentArrayBuffer,
|
|
pageCount: pages.length,
|
|
};
|
|
|
|
// 2) Load the new PDF
|
|
const newPdf = await loadPdfFromFile(pendingFile);
|
|
|
|
// 3) Determine insert position (0-based)
|
|
let insertAt = pages.length; // default: append at end
|
|
if (mergeMode === "insertAt") {
|
|
const parsed = parseInt(mergeInsertAt, 10);
|
|
if (Number.isFinite(parsed)) {
|
|
insertAt = Math.min(Math.max(parsed - 1, 0), pages.length);
|
|
}
|
|
} else if (mergeMode === "append") {
|
|
insertAt = pages.length;
|
|
}
|
|
|
|
// 4) Merge
|
|
const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt);
|
|
const mergedPages = createInitialPageRefs(mergedPdf.pageCount);
|
|
|
|
// 5) Reset state to the merged document
|
|
setPdf(mergedPdf);
|
|
replaceWorkspaceState({
|
|
pages: mergedPages,
|
|
selectedPageIds: [],
|
|
lastSelectedVisualIndex: null,
|
|
history: [],
|
|
redoHistory: [],
|
|
dirty: true,
|
|
message: null,
|
|
});
|
|
clearGeneratedOutputs();
|
|
clearThumbnailCache();
|
|
setPreviewPageId(null);
|
|
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
|
|
setActiveWorkspaceId(null);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Failed to merge PDF (see console).");
|
|
} finally {
|
|
setIsBusy(false);
|
|
setPendingFile(null);
|
|
setShowMergeOptions(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (
|
|
previewPageId != null &&
|
|
!pages.some((page) => page.id === previewPageId)
|
|
) {
|
|
setPreviewPageId(null);
|
|
}
|
|
}, [previewPageId, pages]);
|
|
|
|
const hasPdf = !!pdf;
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (isEditableKeyboardTarget(e.target)) return;
|
|
|
|
if (e.key === "F1" || e.key === "?") {
|
|
e.preventDefault();
|
|
setHelpOpen(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
// === UI interactions ===
|
|
const handleRotatePageClockwise = (pageId: string) => {
|
|
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) => {
|
|
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) => {
|
|
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[]) => {
|
|
const before = getCurrentCommandState();
|
|
|
|
executeWorkspaceCommand(
|
|
createWorkspaceCommand({
|
|
type: "pages.reorder",
|
|
label: "Reordered pages",
|
|
before,
|
|
after: {
|
|
...before,
|
|
pages: newPages,
|
|
},
|
|
details: {
|
|
pageCount: newPages.length,
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleToggleSelect = (
|
|
pageId: string,
|
|
visualIndex: number,
|
|
e: React.MouseEvent<HTMLButtonElement>,
|
|
) => {
|
|
setSelectedPageIds((prev) => {
|
|
if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) {
|
|
const from = Math.min(lastSelectedVisualIndex, visualIndex);
|
|
const to = Math.max(lastSelectedVisualIndex, visualIndex);
|
|
const rangeIds = pages.slice(from, to + 1).map((page) => page.id);
|
|
|
|
const set = new Set(prev);
|
|
rangeIds.forEach((id) => set.add(id));
|
|
return Array.from(set);
|
|
}
|
|
|
|
if (prev.includes(pageId)) {
|
|
return prev.filter((id) => id !== pageId);
|
|
}
|
|
|
|
return [...prev, pageId];
|
|
});
|
|
|
|
setLastSelectedVisualIndex(visualIndex);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
setSelectedPageIds(pages.map((page) => page.id));
|
|
setLastSelectedVisualIndex(null);
|
|
};
|
|
|
|
const handleClearSelection = () => {
|
|
setSelectedPageIds([]);
|
|
setLastSelectedVisualIndex(null);
|
|
};
|
|
|
|
const handleDeleteSelected = () => {
|
|
if (selectedPageIds.length === 0) return;
|
|
|
|
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) => {
|
|
if (!pdf || pageIds.length === 0) return;
|
|
|
|
const pageIdSet = new Set(pageIds);
|
|
|
|
// Copy in current visual order, not in arbitrary selectedPageIds order.
|
|
const sourcePages = pages.filter((page) => pageIdSet.has(page.id));
|
|
|
|
if (sourcePages.length === 0) return;
|
|
|
|
const copiedPages: PageRef[] = sourcePages.map((page) => ({
|
|
...page,
|
|
id: createPageRefId(),
|
|
}));
|
|
|
|
const clampedSlot = Math.min(Math.max(insertSlot, 0), pages.length);
|
|
|
|
const afterPages = [
|
|
...pages.slice(0, clampedSlot),
|
|
...copiedPages,
|
|
...pages.slice(clampedSlot),
|
|
];
|
|
|
|
const before = getCurrentCommandState();
|
|
|
|
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,
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
const closeActionDialog = () => {
|
|
setActionDialog(null);
|
|
};
|
|
|
|
const openActionDialog = (dialog: {
|
|
title: string;
|
|
content: React.ReactNode;
|
|
actions: ActionDialogAction[];
|
|
}) => {
|
|
setActionDialog(dialog);
|
|
};
|
|
|
|
const handleOpenPreview = (pageId: string) => {
|
|
setPreviewPageId(pageId);
|
|
};
|
|
|
|
const handleClosePreview = () => {
|
|
setPreviewPageId(null);
|
|
};
|
|
|
|
const handlePreviewPrevious = () => {
|
|
setPreviewPageId((current) => {
|
|
if (current == null || pages.length === 0) return current;
|
|
|
|
const visualIndex = pages.findIndex((page) => page.id === current);
|
|
if (visualIndex <= 0) return current;
|
|
|
|
return pages[visualIndex - 1].id;
|
|
});
|
|
};
|
|
|
|
const handlePreviewNext = () => {
|
|
setPreviewPageId((current) => {
|
|
if (current == null || pages.length === 0) return current;
|
|
|
|
const visualIndex = pages.findIndex((page) => page.id === current);
|
|
if (visualIndex < 0 || visualIndex >= pages.length - 1) return current;
|
|
|
|
return pages[visualIndex + 1].id;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!hasPdf || previewPageId !== null) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (isEditableKeyboardTarget(e.target)) return;
|
|
|
|
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));
|
|
setLastSelectedVisualIndex(null);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(e.key === "Delete" || e.key === "Backspace") &&
|
|
selectedPageIds.length > 0
|
|
) {
|
|
e.preventDefault();
|
|
handleDeleteSelected();
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Escape" && selectedPageIds.length > 0) {
|
|
e.preventDefault();
|
|
setSelectedPageIds([]);
|
|
setLastSelectedVisualIndex(null);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [
|
|
hasPdf,
|
|
previewPageId,
|
|
pages,
|
|
selectedPageIds,
|
|
workspaceHistory,
|
|
redoHistory,
|
|
handleUndo,
|
|
handleRedo,
|
|
handleDeleteSelected,
|
|
setSelectedPageIds,
|
|
setLastSelectedVisualIndex,
|
|
]);
|
|
|
|
const handleSplit = async () => {
|
|
if (!pdf) return;
|
|
setError(null);
|
|
setIsBusy(true);
|
|
try {
|
|
const result = await splitIntoSinglePages(pdf);
|
|
replaceSplitResults(result);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Error while splitting PDF (see console).");
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleExtractSelected = async () => {
|
|
if (!pdf || selectedPageIds.length === 0) return;
|
|
setError(null);
|
|
setIsBusy(true);
|
|
|
|
try {
|
|
const selectedSet = new Set(selectedPageIds);
|
|
const selectedPages = pages.filter((page) => selectedSet.has(page.id));
|
|
const blob = await exportPages(pdf, selectedPages);
|
|
const base = pdf.name.replace(/\.pdf$/i, "");
|
|
const filename = `${base}_selected.pdf`;
|
|
replaceSubsetResult(blob, filename);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Error while extracting selected pages (see console).");
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleExportReordered = async () => {
|
|
if (!pdf || pages.length === 0) return;
|
|
setError(null);
|
|
setIsBusy(true);
|
|
|
|
try {
|
|
const blob = await exportPages(pdf, pages);
|
|
const base = pdf.name.replace(/\.pdf$/i, "");
|
|
const filename = `${base}_reordered.pdf`;
|
|
replaceExportResult(blob, filename);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError("Error while exporting reordered PDF (see console).");
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const previewVisualIndex =
|
|
previewPageId != null
|
|
? pages.findIndex((page) => page.id === previewPageId)
|
|
: -1;
|
|
const previewPage =
|
|
previewVisualIndex >= 0 ? pages[previewVisualIndex] : null;
|
|
|
|
const canPreviewPrevious = previewVisualIndex > 0;
|
|
const canPreviewNext =
|
|
previewVisualIndex >= 0 && previewVisualIndex < pages.length - 1;
|
|
|
|
return (
|
|
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
|
<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"
|
|
style={{ border: "1px solid #bfdbfe", background: "#eff6ff" }}
|
|
>
|
|
<h2>Open file: merge or replace?</h2>
|
|
<p style={{ fontSize: "0.85rem", color: "#374151" }}>
|
|
You already have <strong>{pdf.name}</strong> with {pages.length}{" "}
|
|
pages open. What should happen with{" "}
|
|
<strong>{pendingFile.name}</strong>?
|
|
</p>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.5rem",
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.9rem",
|
|
}}
|
|
>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="mergeMode"
|
|
value="overwrite"
|
|
checked={mergeMode === "overwrite"}
|
|
onChange={() => setMergeMode("overwrite")}
|
|
/>
|
|
<span>Replace current document</span>
|
|
</label>
|
|
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="mergeMode"
|
|
value="append"
|
|
checked={mergeMode === "append"}
|
|
onChange={() => setMergeMode("append")}
|
|
/>
|
|
<span>Merge and append pages at the end</span>
|
|
</label>
|
|
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="mergeMode"
|
|
value="insertAt"
|
|
checked={mergeMode === "insertAt"}
|
|
onChange={() => setMergeMode("insertAt")}
|
|
/>
|
|
<span>
|
|
Merge and insert starting at position{" "}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={pages.length + 1}
|
|
value={mergeInsertAt}
|
|
onChange={(e) => setMergeInsertAt(e.target.value)}
|
|
style={{
|
|
width: "4rem",
|
|
padding: "0.15rem 0.3rem",
|
|
fontSize: "0.85rem",
|
|
}}
|
|
/>{" "}
|
|
<span style={{ color: "#6b7280" }}>
|
|
(1 = before first page, {pages.length + 1} = after last page)
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="button-row" style={{ marginTop: "0.75rem" }}>
|
|
<button
|
|
className="secondary"
|
|
type="button"
|
|
onClick={handleMergeCancel}
|
|
disabled={isBusy}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="primary"
|
|
type="button"
|
|
onClick={handleMergeConfirm}
|
|
disabled={isBusy}
|
|
>
|
|
{isBusy ? "Working…" : "Continue"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ReorderPanel
|
|
pages={pages}
|
|
thumbnails={reorderThumbnails}
|
|
isBusy={isBusy}
|
|
hasPdf={hasPdf}
|
|
selectedPageIds={selectedPageIds}
|
|
onRotateClockwise={handleRotatePageClockwise}
|
|
onRotateCounterclockwise={handleRotatePageCounterclockwise}
|
|
onDelete={handleDeletePage}
|
|
onReorder={handleReorder}
|
|
onCopyPagesToSlot={handleCopyPagesToSlot}
|
|
onToggleSelect={handleToggleSelect}
|
|
onSelectAll={handleSelectAll}
|
|
onOpenPreview={handleOpenPreview}
|
|
onClearSelection={handleClearSelection}
|
|
onDeleteSelected={handleDeleteSelected}
|
|
/>
|
|
|
|
<ActionsPanel
|
|
hasPdf={hasPdf}
|
|
isBusy={isBusy}
|
|
selectedCount={selectedPageIds.length}
|
|
onSplit={handleSplit}
|
|
onExtractSelected={handleExtractSelected}
|
|
onExportReordered={handleExportReordered}
|
|
splitDownloads={splitDownloads}
|
|
subsetDownload={subsetDownload}
|
|
exportDownload={exportDownload}
|
|
/>
|
|
|
|
{error && (
|
|
<div
|
|
className="card"
|
|
style={{ border: "1px solid #fecaca", background: "#fef2f2" }}
|
|
>
|
|
<strong>Error:</strong> {error}
|
|
</div>
|
|
)}
|
|
|
|
<PagePreviewModal
|
|
isOpen={previewPage !== null}
|
|
pdf={pdf}
|
|
pageIndex={previewPage?.sourcePageIndex ?? null}
|
|
rotation={previewPage?.rotation ?? 0}
|
|
visualIndex={previewVisualIndex >= 0 ? previewVisualIndex : null}
|
|
totalPages={pages.length}
|
|
canGoPrevious={canPreviewPrevious}
|
|
canGoNext={canPreviewNext}
|
|
onPrevious={handlePreviewPrevious}
|
|
onNext={handlePreviewNext}
|
|
onClose={handleClosePreview}
|
|
/>
|
|
|
|
<ActionDialog
|
|
open={actionDialog !== null}
|
|
title={actionDialog?.title ?? ""}
|
|
actions={actionDialog?.actions ?? []}
|
|
onClose={closeActionDialog}
|
|
>
|
|
{actionDialog?.content}
|
|
</ActionDialog>
|
|
|
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default App;
|