Roadmap, robust page refs, copy behaviour
This commit is contained in:
636
src/App.tsx
636
src/App.tsx
@@ -1,38 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, 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 type { PdfFile, SplitResult } from './pdf/pdfTypes';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes';
|
||||
import {
|
||||
loadPdfFromFile,
|
||||
mergePdfFiles,
|
||||
splitIntoSinglePages,
|
||||
exportReordered,
|
||||
exportPages,
|
||||
} from './pdf/pdfService';
|
||||
import {
|
||||
generateThumbnails,
|
||||
generateThumbnailsWithRotations,
|
||||
generateThumbnailsProgressive,
|
||||
generateThumbnailsWithRotationsProgressive,
|
||||
} from './pdf/pdfThumbnailService';
|
||||
|
||||
const THUMBNAIL_MAX_HEIGHT = 150;
|
||||
const THUMBNAIL_MAX_WIDTH = 140;
|
||||
const THUMBNAIL_CONCURRENCY = 3;
|
||||
|
||||
function createPageRefId(): string {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function createInitialPageRefs(pageCount: number): PageRef[] {
|
||||
return Array.from({ length: pageCount }, (_, sourcePageIndex) => ({
|
||||
id: createPageRefId(),
|
||||
sourcePageIndex,
|
||||
rotation: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeRotation(rotation: number | undefined): number {
|
||||
return (((rotation ?? 0) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function thumbnailCacheKey(
|
||||
pdfId: string,
|
||||
sourcePageIndex: number,
|
||||
rotation: number
|
||||
): string {
|
||||
return [
|
||||
pdfId,
|
||||
sourcePageIndex,
|
||||
normalizeRotation(rotation),
|
||||
THUMBNAIL_MAX_WIDTH,
|
||||
THUMBNAIL_MAX_HEIGHT,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
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 [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null);
|
||||
const [reorderThumbnails, setReorderThumbnails] = useState<string[] | null>(
|
||||
null
|
||||
);
|
||||
const [pages, setPages] = useState<PageRef[]>([]);
|
||||
const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
|
||||
|
||||
const [order, setOrder] = useState<number[]>([]);
|
||||
const [rotations, setRotations] = useState<Record<number, number>>({});
|
||||
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||
const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<number | null>(null);
|
||||
|
||||
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
|
||||
const [subsetUrl, setSubsetUrl] = useState<string | null>(null);
|
||||
@@ -40,38 +80,56 @@ const App: React.FC = () => {
|
||||
const [exportUrl, setExportUrl] = useState<string | null>(null);
|
||||
const [exportFilename, setExportFilename] = useState<string | null>(null);
|
||||
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||
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 loadFileAsNew = async (file: File) => {
|
||||
setError(null);
|
||||
const thumbnailCacheRef = useRef<Map<string, string>>(new Map());
|
||||
const latestPagesRef = useRef<PageRef[]>([]);
|
||||
const previousPageRotationsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const resetGeneratedUrls = () => {
|
||||
if (subsetUrl) {
|
||||
URL.revokeObjectURL(subsetUrl);
|
||||
setSubsetUrl(null);
|
||||
setSubsetFilename(null);
|
||||
}
|
||||
|
||||
if (exportUrl) {
|
||||
URL.revokeObjectURL(exportUrl);
|
||||
setExportUrl(null);
|
||||
setExportFilename(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetWorkspaceState = () => {
|
||||
setSplitResults([]);
|
||||
setSelectedPages([]);
|
||||
setLastSelectedVisualIndex(null);
|
||||
setSubsetUrl(null);
|
||||
setSubsetFilename(null);
|
||||
setExportUrl(null);
|
||||
setExportFilename(null);
|
||||
setBaseThumbnails(null);
|
||||
setReorderThumbnails(null);
|
||||
setRotations({});
|
||||
setOrder([]);
|
||||
setPreviewIndex(null);
|
||||
setSelectedPageIds([]);
|
||||
setLastSelectedVisualIndex(null);
|
||||
resetGeneratedUrls();
|
||||
setReorderThumbnails({});
|
||||
thumbnailCacheRef.current.clear();
|
||||
previousPageRotationsRef.current.clear();
|
||||
latestPagesRef.current = [];
|
||||
setPages([]);
|
||||
setPreviewPageId(null);
|
||||
};
|
||||
|
||||
const loadFileAsNew = async (file: File) => {
|
||||
setError(null);
|
||||
resetWorkspaceState();
|
||||
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const loaded = await loadPdfFromFile(file);
|
||||
setPdf(loaded);
|
||||
const initialPages = createInitialPageRefs(loaded.pageCount);
|
||||
|
||||
const initialOrder = Array.from(
|
||||
{ length: loaded.pageCount },
|
||||
(_, i) => i
|
||||
);
|
||||
setOrder(initialOrder);
|
||||
setPdf(loaded);
|
||||
setPages(initialPages);
|
||||
latestPagesRef.current = initialPages;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Failed to load PDF (see console).');
|
||||
@@ -81,15 +139,13 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleFileLoaded = (file: File) => {
|
||||
// If no PDF loaded yet, just open it as before
|
||||
if (!pdf || order.length === 0) {
|
||||
if (!pdf || pages.length === 0) {
|
||||
void loadFileAsNew(file);
|
||||
} else {
|
||||
// Otherwise, ask whether to merge or replace
|
||||
setPendingFile(file);
|
||||
setShowMergeOptions(true);
|
||||
setMergeMode('append');
|
||||
setMergeInsertAt(String(order.length + 1));
|
||||
setMergeInsertAt(String(pages.length + 1));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +157,6 @@ const App: React.FC = () => {
|
||||
const handleMergeConfirm = async () => {
|
||||
if (!pendingFile) return;
|
||||
|
||||
// If there's no current PDF or the user chose overwrite, just load normally
|
||||
if (!pdf || mergeMode === 'overwrite') {
|
||||
await loadFileAsNew(pendingFile);
|
||||
setPendingFile(null);
|
||||
@@ -113,58 +168,49 @@ const App: React.FC = () => {
|
||||
setIsBusy(true);
|
||||
|
||||
try {
|
||||
// 1) Materialize the current in-memory state (order + rotations)
|
||||
const currentBlob = await exportReordered(pdf, order, rotations);
|
||||
// 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: order.length,
|
||||
pageCount: pages.length,
|
||||
};
|
||||
|
||||
// 2) Load the new PDF
|
||||
const newPdf = await loadPdfFromFile(pendingFile);
|
||||
|
||||
// 3) Determine insert position (0-based)
|
||||
let insertAt = order.length; // default: append at end
|
||||
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), order.length);
|
||||
insertAt = Math.min(Math.max(parsed - 1, 0), pages.length);
|
||||
}
|
||||
} else if (mergeMode === 'append') {
|
||||
insertAt = order.length;
|
||||
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);
|
||||
const mergedOrder = Array.from(
|
||||
{ length: mergedPdf.pageCount },
|
||||
(_, i) => i
|
||||
);
|
||||
setOrder(mergedOrder);
|
||||
setRotations({});
|
||||
setSelectedPages([]);
|
||||
|
||||
setPages(mergedPages);
|
||||
latestPagesRef.current = mergedPages;
|
||||
setSelectedPageIds([]);
|
||||
setLastSelectedVisualIndex(null);
|
||||
setSplitResults([]);
|
||||
|
||||
if (subsetUrl) {
|
||||
URL.revokeObjectURL(subsetUrl);
|
||||
setSubsetUrl(null);
|
||||
setSubsetFilename(null);
|
||||
}
|
||||
if (exportUrl) {
|
||||
URL.revokeObjectURL(exportUrl);
|
||||
setExportUrl(null);
|
||||
setExportFilename(null);
|
||||
}
|
||||
|
||||
setBaseThumbnails(null);
|
||||
setReorderThumbnails(null);
|
||||
setPreviewIndex(null);
|
||||
resetGeneratedUrls();
|
||||
setReorderThumbnails({});
|
||||
thumbnailCacheRef.current.clear();
|
||||
previousPageRotationsRef.current.clear();
|
||||
setPreviewPageId(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Failed to merge PDF (see console).');
|
||||
@@ -175,146 +221,404 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
latestPagesRef.current = pages;
|
||||
}, [pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdf) {
|
||||
setBaseThumbnails(null);
|
||||
setReorderThumbnails(null);
|
||||
setReorderThumbnails({});
|
||||
thumbnailCacheRef.current.clear();
|
||||
previousPageRotationsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const thumbs = await generateThumbnails(pdf.arrayBuffer);
|
||||
if (!cancelled) {
|
||||
setBaseThumbnails(thumbs);
|
||||
setReorderThumbnails(thumbs);
|
||||
const controller = new AbortController();
|
||||
|
||||
latestPagesRef.current = pages;
|
||||
thumbnailCacheRef.current.clear();
|
||||
previousPageRotationsRef.current = new Map(
|
||||
pages.map((page) => [page.id, normalizeRotation(page.rotation)])
|
||||
);
|
||||
setReorderThumbnails({});
|
||||
|
||||
void generateThumbnailsProgressive(pdf.arrayBuffer, {
|
||||
maxHeight: THUMBNAIL_MAX_HEIGHT,
|
||||
maxWidth: THUMBNAIL_MAX_WIDTH,
|
||||
concurrency: THUMBNAIL_CONCURRENCY,
|
||||
signal: controller.signal,
|
||||
onThumbnail: ({ pageIndex, dataUrl }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
thumbnailCacheRef.current.set(
|
||||
thumbnailCacheKey(pdf.id, pageIndex, 0),
|
||||
dataUrl
|
||||
);
|
||||
|
||||
const currentPages = latestPagesRef.current;
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
for (const page of currentPages) {
|
||||
if (
|
||||
page.sourcePageIndex === pageIndex &&
|
||||
normalizeRotation(page.rotation) === 0
|
||||
) {
|
||||
updates[page.id] = dataUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
setReorderThumbnails((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
}));
|
||||
},
|
||||
}).catch((e) => {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error(e);
|
||||
if (!cancelled) {
|
||||
setError('Failed to generate thumbnails (see console).');
|
||||
}
|
||||
setError('Failed to generate thumbnails (see console).');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [pdf]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdf) {
|
||||
setReorderThumbnails(null);
|
||||
previousPageRotationsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const thumbs = await generateThumbnailsWithRotations(
|
||||
pdf.arrayBuffer,
|
||||
rotations
|
||||
);
|
||||
if (!cancelled) {
|
||||
setReorderThumbnails(thumbs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!cancelled) {
|
||||
setError('Failed to generate rotated thumbnails (see console).');
|
||||
}
|
||||
const previousRotations = previousPageRotationsRef.current;
|
||||
const changedPages = pages.filter(
|
||||
(page) =>
|
||||
normalizeRotation(previousRotations.get(page.id)) !==
|
||||
normalizeRotation(page.rotation)
|
||||
);
|
||||
|
||||
previousPageRotationsRef.current = new Map(
|
||||
pages.map((page) => [page.id, normalizeRotation(page.rotation)])
|
||||
);
|
||||
|
||||
if (changedPages.length === 0) return;
|
||||
|
||||
const cachedUpdates: Record<string, string> = {};
|
||||
const pagesToRender: PageRef[] = [];
|
||||
|
||||
for (const page of changedPages) {
|
||||
const rotation = normalizeRotation(page.rotation);
|
||||
const cached = thumbnailCacheRef.current.get(
|
||||
thumbnailCacheKey(pdf.id, page.sourcePageIndex, rotation)
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
cachedUpdates[page.id] = cached;
|
||||
} else {
|
||||
pagesToRender.push(page);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (Object.keys(cachedUpdates).length > 0) {
|
||||
setReorderThumbnails((prev) => ({
|
||||
...prev,
|
||||
...cachedUpdates,
|
||||
}));
|
||||
}
|
||||
|
||||
if (pagesToRender.length === 0) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const groups = new Map<number, PageRef[]>();
|
||||
|
||||
for (const page of pagesToRender) {
|
||||
const rotation = normalizeRotation(page.rotation);
|
||||
const group = groups.get(rotation) ?? [];
|
||||
group.push(page);
|
||||
groups.set(rotation, group);
|
||||
}
|
||||
|
||||
const renderGroups = async () => {
|
||||
for (const [rotation, groupPages] of groups) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const pageIndices = Array.from(
|
||||
new Set(groupPages.map((page) => page.sourcePageIndex))
|
||||
);
|
||||
const rotationsBySourcePage: Record<number, number> = {};
|
||||
|
||||
for (const pageIndex of pageIndices) {
|
||||
rotationsBySourcePage[pageIndex] = rotation;
|
||||
}
|
||||
|
||||
await generateThumbnailsWithRotationsProgressive(
|
||||
pdf.arrayBuffer,
|
||||
rotationsBySourcePage,
|
||||
{
|
||||
maxHeight: THUMBNAIL_MAX_HEIGHT,
|
||||
maxWidth: THUMBNAIL_MAX_WIDTH,
|
||||
concurrency: Math.min(THUMBNAIL_CONCURRENCY, pageIndices.length),
|
||||
pageIndices,
|
||||
signal: controller.signal,
|
||||
onThumbnail: ({ pageIndex, dataUrl }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
thumbnailCacheRef.current.set(
|
||||
thumbnailCacheKey(pdf.id, pageIndex, rotation),
|
||||
dataUrl
|
||||
);
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
for (const page of latestPagesRef.current) {
|
||||
if (
|
||||
page.sourcePageIndex === pageIndex &&
|
||||
normalizeRotation(page.rotation) === rotation
|
||||
) {
|
||||
updates[page.id] = dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
setReorderThumbnails((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
}));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void renderGroups().catch((e) => {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error(e);
|
||||
setError('Failed to generate rotated thumbnails (see console).');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [pdf, rotations]);
|
||||
}, [pdf, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewPageId != null && !pages.some((page) => page.id === previewPageId)) {
|
||||
setPreviewPageId(null);
|
||||
}
|
||||
}, [previewPageId, pages]);
|
||||
|
||||
const hasPdf = !!pdf;
|
||||
const pageCount = pdf?.pageCount ?? 0;
|
||||
|
||||
// === UI interactions ===
|
||||
|
||||
const handleRotatePageClockwise = (pageIndex: number) => {
|
||||
setRotations((prev) => {
|
||||
const current = prev[pageIndex] ?? 0;
|
||||
const next = (current + 90) % 360;
|
||||
return { ...prev, [pageIndex]: next };
|
||||
});
|
||||
const handleRotatePageClockwise = (pageId: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((page) =>
|
||||
page.id === pageId
|
||||
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
|
||||
: page
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleRotatePageCounterclockwise = (pageIndex: number) => {
|
||||
setRotations((prev) => {
|
||||
const current = prev[pageIndex] ?? 0;
|
||||
const next = (current + 270) % 360;
|
||||
return { ...prev, [pageIndex]: next };
|
||||
});
|
||||
const handleRotatePageCounterclockwise = (pageId: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((page) =>
|
||||
page.id === pageId
|
||||
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
|
||||
: page
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeletePage = (pageIndex: number) => {
|
||||
setOrder((prev) => prev.filter((p) => p !== pageIndex));
|
||||
setSelectedPages((prev) => prev.filter((p) => p !== pageIndex));
|
||||
const handleDeletePage = (pageId: string) => {
|
||||
setPages((prev) => prev.filter((page) => page.id !== pageId));
|
||||
setSelectedPageIds((prev) => prev.filter((id) => id !== pageId));
|
||||
};
|
||||
|
||||
const handleReorder = (newOrder: number[]) => {
|
||||
setOrder(newOrder);
|
||||
const handleReorder = (newPages: PageRef[]) => {
|
||||
setPages(newPages);
|
||||
};
|
||||
|
||||
const handleToggleSelect = (
|
||||
pageIndex: number,
|
||||
pageId: string,
|
||||
visualIndex: number,
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
setSelectedPages((prev) => {
|
||||
// Shift: add a range (visual positions) to the existing selection
|
||||
if (e.shiftKey && lastSelectedVisualIndex !== null && order.length > 0) {
|
||||
setSelectedPageIds((prev) => {
|
||||
if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) {
|
||||
const from = Math.min(lastSelectedVisualIndex, visualIndex);
|
||||
const to = Math.max(lastSelectedVisualIndex, visualIndex);
|
||||
const rangeIndices = order.slice(from, to + 1); // original page indices
|
||||
const rangeIds = pages.slice(from, to + 1).map((page) => page.id);
|
||||
|
||||
const set = new Set(prev);
|
||||
rangeIndices.forEach((idx) => set.add(idx));
|
||||
rangeIds.forEach((id) => set.add(id));
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
// Plain click: toggle this page
|
||||
if (prev.includes(pageIndex)) {
|
||||
return prev.filter((p) => p !== pageIndex);
|
||||
if (prev.includes(pageId)) {
|
||||
return prev.filter((id) => id !== pageId);
|
||||
}
|
||||
|
||||
return [...prev, pageIndex];
|
||||
return [...prev, pageId];
|
||||
});
|
||||
|
||||
setLastSelectedVisualIndex(visualIndex);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedPages([...order]);
|
||||
setSelectedPageIds(pages.map((page) => page.id));
|
||||
setLastSelectedVisualIndex(null);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedPages([]);
|
||||
setSelectedPageIds([]);
|
||||
setLastSelectedVisualIndex(null);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedPages.length === 0) return;
|
||||
setOrder((prev) => prev.filter((p) => !selectedPages.includes(p)));
|
||||
setSelectedPages([]);
|
||||
if (selectedPageIds.length === 0) return;
|
||||
const selectedSet = new Set(selectedPageIds);
|
||||
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
|
||||
setSelectedPageIds([]);
|
||||
setLastSelectedVisualIndex(null);
|
||||
};
|
||||
|
||||
const handleOpenPreview = (pageIndex: number) => {
|
||||
setPreviewIndex(pageIndex);
|
||||
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 thumbnailUpdates: Record<string, string> = {};
|
||||
|
||||
sourcePages.forEach((sourcePage, index) => {
|
||||
const copiedPage = copiedPages[index];
|
||||
|
||||
const thumbnail =
|
||||
reorderThumbnails[sourcePage.id] ??
|
||||
thumbnailCacheRef.current.get(
|
||||
thumbnailCacheKey(
|
||||
pdf.id,
|
||||
sourcePage.sourcePageIndex,
|
||||
sourcePage.rotation
|
||||
)
|
||||
);
|
||||
|
||||
if (thumbnail) {
|
||||
thumbnailUpdates[copiedPage.id] = thumbnail;
|
||||
}
|
||||
});
|
||||
|
||||
setPages((prev) => {
|
||||
const slot = Math.min(Math.max(clampedSlot, 0), prev.length);
|
||||
|
||||
return [
|
||||
...prev.slice(0, slot),
|
||||
...copiedPages,
|
||||
...prev.slice(slot),
|
||||
];
|
||||
});
|
||||
|
||||
// Select the newly created copies.
|
||||
setSelectedPageIds(copiedPages.map((page) => page.id));
|
||||
setLastSelectedVisualIndex(null);
|
||||
|
||||
if (Object.keys(thumbnailUpdates).length > 0) {
|
||||
setReorderThumbnails((prev) => ({
|
||||
...prev,
|
||||
...thumbnailUpdates,
|
||||
}));
|
||||
}
|
||||
|
||||
// Existing generated outputs no longer represent the current workspace.
|
||||
setSplitResults([]);
|
||||
resetGeneratedUrls();
|
||||
};
|
||||
|
||||
const handleOpenPreview = (pageId: string) => {
|
||||
setPreviewPageId(pageId);
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewIndex(null);
|
||||
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 === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedPageIds(pages.map((page) => page.id));
|
||||
setLastSelectedVisualIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
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]);
|
||||
|
||||
const handleSplit = async () => {
|
||||
if (!pdf) return;
|
||||
setError(null);
|
||||
@@ -331,7 +635,7 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleExtractSelected = async () => {
|
||||
if (!pdf || selectedPages.length === 0) return;
|
||||
if (!pdf || selectedPageIds.length === 0) return
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
@@ -342,10 +646,9 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedOrder = order.filter((idx) =>
|
||||
selectedPages.includes(idx)
|
||||
);
|
||||
const blob = await exportReordered(pdf, selectedOrder, rotations);
|
||||
const selectedSet = new Set(selectedPageIds);
|
||||
const selectedPages = pages.filter((page) => selectedSet.has(page.id));
|
||||
const blob = await exportPages(pdf, selectedPages);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||
const filename = `${base}_selected.pdf`;
|
||||
@@ -360,7 +663,7 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleExportReordered = async () => {
|
||||
if (!pdf || order.length === 0) return;
|
||||
if (!pdf || pages.length === 0) return;
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
@@ -371,7 +674,7 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await exportReordered(pdf, order, rotations);
|
||||
const blob = await exportPages(pdf, pages);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const base = pdf.name.replace(/\.pdf$/i, '');
|
||||
const filename = `${base}_reordered.pdf`;
|
||||
@@ -385,18 +688,29 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
|
||||
{showMergeOptions && pendingFile && pdf && order.length > 0 && (
|
||||
{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 {order.length}{' '}
|
||||
You already have <strong>{pdf.name}</strong> with {pages.length}{' '}
|
||||
pages open. What should happen with{' '}
|
||||
<strong>{pendingFile.name}</strong>?
|
||||
</p>
|
||||
@@ -451,7 +765,7 @@ const App: React.FC = () => {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={order.length + 1}
|
||||
max={pages.length + 1}
|
||||
value={mergeInsertAt}
|
||||
onChange={(e) => setMergeInsertAt(e.target.value)}
|
||||
style={{
|
||||
@@ -461,7 +775,7 @@ const App: React.FC = () => {
|
||||
}}
|
||||
/>{' '}
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
(1 = before first page, {order.length + 1} = after last page)
|
||||
(1 = before first page, {pages.length + 1} = after last page)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -489,16 +803,16 @@ const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
<ReorderPanel
|
||||
order={order}
|
||||
pages={pages}
|
||||
thumbnails={reorderThumbnails}
|
||||
isBusy={isBusy}
|
||||
hasPdf={hasPdf}
|
||||
rotations={rotations}
|
||||
selectedPages={selectedPages}
|
||||
selectedPageIds={selectedPageIds}
|
||||
onRotateClockwise={handleRotatePageClockwise}
|
||||
onRotateCounterclockwise={handleRotatePageCounterclockwise}
|
||||
onDelete={handleDeletePage}
|
||||
onReorder={handleReorder}
|
||||
onCopyPagesToSlot={handleCopyPagesToSlot}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
onOpenPreview={handleOpenPreview}
|
||||
@@ -510,7 +824,7 @@ const App: React.FC = () => {
|
||||
<ActionsPanel
|
||||
hasPdf={hasPdf}
|
||||
isBusy={isBusy}
|
||||
selectedCount={selectedPages.length}
|
||||
selectedCount={selectedPageIds.length}
|
||||
onSplit={handleSplit}
|
||||
onExtractSelected={handleExtractSelected}
|
||||
onExportReordered={handleExportReordered}
|
||||
@@ -531,10 +845,16 @@ const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
<PagePreviewModal
|
||||
isOpen={previewIndex !== null}
|
||||
isOpen={previewPage !== null}
|
||||
pdf={pdf}
|
||||
pageIndex={previewIndex}
|
||||
rotation={previewIndex != null ? rotations[previewIndex] ?? 0 : 0}
|
||||
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}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user