Roadmap, robust page refs, copy behaviour

This commit is contained in:
2026-05-16 02:37:20 +02:00
parent a649ede010
commit 2461cf3d64
9 changed files with 1588 additions and 744 deletions

77
README
View File

@@ -1 +1,76 @@
empty # PDF Workbench
Browser-only PDF tools for quick page-level editing. Processing happens completely locally in the browser; files are never uploaded to a server.
## Current features
- load a PDF in the browser
- generate page thumbnails progressively
- reorder pages via drag and drop
- select pages, including Shift range selection
- rotate pages clockwise/counter-clockwise
- delete pages from the working document
- preview pages in an overlay
- flip through preview pages with buttons or arrow keys
- merge a second PDF by replacing, appending, or inserting at a chosen position
- extract selected pages into a new PDF
- export the current reordered/rotated document
- split into single-page PDFs
## Keyboard shortcuts
- `CtrlA` / `⌘A`: select all pages
- `Delete` / `Backspace`: delete selected pages
- `Esc`: clear the current selection
- Preview overlay: `←` / `→` flip pages, `Esc` closes the overlay
Keyboard shortcuts are ignored while typing in form fields.
## Current implementation focus
The project is currently optimized around page-level PDF work: split, merge, reorder, rotate, preview, select, delete, extract, and export. Deep content-stream editing is intentionally out of scope for now.
## Roadmap
### Milestone 1: Fast preview and thumbnails
- [x] Remove unused `PageList` component
- [x] Bound thumbnail generation by width and height
- [x] Display thumbnails progressively
- [x] Add preview page flipping
- [x] Attach preview controls to the modal container
- [x] Add first keyboard shortcuts
- [x] Cache thumbnails by page and rotation
- [x] Regenerate only changed rotated thumbnails
### Milestone 2: Real page workspace
- [x] Introduce stable page references instead of only original page indices
- [x] Support duplicate selected pages
- [ ] Extract selection as a new active workspace
- [ ] Add command history as a foundation for undo/redo
- [ ] Add undo/redo
- [ ] Add grid/list view toggle
### Milestone 3: Better merge and mobile handling
- [ ] Add a full multi-file merge queue
- [ ] Support drag-and-drop of PDFs into the page grid at the hovered position
- [ ] Add custom long-press drag on mobile
- [ ] Consolidate actions into a toolbar
### Milestone 4: Structural PDF editing
- [ ] Metadata editing
- [ ] Crop pages
- [ ] Add tools directly in the preview overlay
- [ ] Read/fill/flatten forms
- [ ] Read bookmarks, then evaluate bookmark editing
- [ ] Read annotations, then evaluate annotation writing
### Milestone 5: Export and power tools
- [ ] Basic text extraction
- [ ] ZIP export for split results
- [ ] Optimize/compress MVP
- [ ] Carefully scoped encrypted PDF handling

View File

@@ -1,38 +1,78 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import Layout from './components/Layout'; import Layout from './components/Layout';
import FileLoader from './components/FileLoader'; 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 type { PdfFile, SplitResult } from './pdf/pdfTypes'; import { PDFDocument } from 'pdf-lib';
import type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes';
import { import {
loadPdfFromFile, loadPdfFromFile,
mergePdfFiles, mergePdfFiles,
splitIntoSinglePages, splitIntoSinglePages,
exportReordered, exportPages,
} from './pdf/pdfService'; } from './pdf/pdfService';
import { import {
generateThumbnails, generateThumbnailsProgressive,
generateThumbnailsWithRotations, generateThumbnailsWithRotationsProgressive,
} from './pdf/pdfThumbnailService'; } 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 App: React.FC = () => {
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 [baseThumbnails, setBaseThumbnails] = useState<string[] | null>(null); const [pages, setPages] = useState<PageRef[]>([]);
const [reorderThumbnails, setReorderThumbnails] = useState<string[] | null>( const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
null
);
const [order, setOrder] = useState<number[]>([]); const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const [rotations, setRotations] = useState<Record<number, number>>({}); const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<number | null>(null);
const [selectedPages, setSelectedPages] = useState<number[]>([]);
const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<
number | null
>(null);
const [splitResults, setSplitResults] = useState<SplitResult[]>([]); const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
const [subsetUrl, setSubsetUrl] = useState<string | null>(null); const [subsetUrl, setSubsetUrl] = useState<string | null>(null);
@@ -40,38 +80,56 @@ const App: React.FC = () => {
const [exportUrl, setExportUrl] = useState<string | null>(null); const [exportUrl, setExportUrl] = useState<string | null>(null);
const [exportFilename, setExportFilename] = 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 [pendingFile, setPendingFile] = useState<File | null>(null);
const [showMergeOptions, setShowMergeOptions] = useState(false); const [showMergeOptions, setShowMergeOptions] = useState(false);
const [mergeMode, setMergeMode] = useState<'overwrite' | 'append' | 'insertAt'>('append'); const [mergeMode, setMergeMode] = useState<'overwrite' | 'append' | 'insertAt'>('append');
const [mergeInsertAt, setMergeInsertAt] = useState<string>(''); const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
const loadFileAsNew = async (file: File) => { const thumbnailCacheRef = useRef<Map<string, string>>(new Map());
setError(null); const latestPagesRef = useRef<PageRef[]>([]);
setSplitResults([]); const previousPageRotationsRef = useRef<Map<string, number>>(new Map());
setSelectedPages([]);
setLastSelectedVisualIndex(null); const resetGeneratedUrls = () => {
if (subsetUrl) {
URL.revokeObjectURL(subsetUrl);
setSubsetUrl(null); setSubsetUrl(null);
setSubsetFilename(null); setSubsetFilename(null);
}
if (exportUrl) {
URL.revokeObjectURL(exportUrl);
setExportUrl(null); setExportUrl(null);
setExportFilename(null); setExportFilename(null);
setBaseThumbnails(null); }
setReorderThumbnails(null); };
setRotations({});
setOrder([]); const resetWorkspaceState = () => {
setPreviewIndex(null); setSplitResults([]);
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); setIsBusy(true);
try { try {
const loaded = await loadPdfFromFile(file); const loaded = await loadPdfFromFile(file);
setPdf(loaded); const initialPages = createInitialPageRefs(loaded.pageCount);
const initialOrder = Array.from( setPdf(loaded);
{ length: loaded.pageCount }, setPages(initialPages);
(_, i) => i latestPagesRef.current = initialPages;
);
setOrder(initialOrder);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError('Failed to load PDF (see console).'); setError('Failed to load PDF (see console).');
@@ -81,15 +139,13 @@ const App: React.FC = () => {
}; };
const handleFileLoaded = (file: File) => { const handleFileLoaded = (file: File) => {
// If no PDF loaded yet, just open it as before if (!pdf || pages.length === 0) {
if (!pdf || order.length === 0) {
void loadFileAsNew(file); void loadFileAsNew(file);
} else { } else {
// Otherwise, ask whether to merge or replace
setPendingFile(file); setPendingFile(file);
setShowMergeOptions(true); setShowMergeOptions(true);
setMergeMode('append'); setMergeMode('append');
setMergeInsertAt(String(order.length + 1)); setMergeInsertAt(String(pages.length + 1));
} }
}; };
@@ -101,7 +157,6 @@ const App: React.FC = () => {
const handleMergeConfirm = async () => { const handleMergeConfirm = async () => {
if (!pendingFile) return; if (!pendingFile) return;
// If there's no current PDF or the user chose overwrite, just load normally
if (!pdf || mergeMode === 'overwrite') { if (!pdf || mergeMode === 'overwrite') {
await loadFileAsNew(pendingFile); await loadFileAsNew(pendingFile);
setPendingFile(null); setPendingFile(null);
@@ -113,58 +168,49 @@ const App: React.FC = () => {
setIsBusy(true); setIsBusy(true);
try { try {
// 1) Materialize the current in-memory state (order + rotations) // 1) Materialize the current in-memory workspace (page refs + rotations)
const currentBlob = await exportReordered(pdf, order, rotations); const currentBlob = await exportPages(pdf, pages);
const currentArrayBuffer = await currentBlob.arrayBuffer(); const currentArrayBuffer = await currentBlob.arrayBuffer();
const currentDoc = await PDFDocument.load(currentArrayBuffer);
const currentPdf: PdfFile = { const currentPdf: PdfFile = {
id: pdf.id,
name: pdf.name, name: pdf.name,
doc: currentDoc,
arrayBuffer: currentArrayBuffer, arrayBuffer: currentArrayBuffer,
pageCount: order.length, pageCount: pages.length,
}; };
// 2) Load the new PDF // 2) Load the new PDF
const newPdf = await loadPdfFromFile(pendingFile); const newPdf = await loadPdfFromFile(pendingFile);
// 3) Determine insert position (0-based) // 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') { if (mergeMode === 'insertAt') {
const parsed = parseInt(mergeInsertAt, 10); const parsed = parseInt(mergeInsertAt, 10);
if (Number.isFinite(parsed)) { 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') { } else if (mergeMode === 'append') {
insertAt = order.length; insertAt = pages.length;
} }
// 4) Merge // 4) Merge
const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt); const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt);
const mergedPages = createInitialPageRefs(mergedPdf.pageCount);
// 5) Reset state to the merged document // 5) Reset state to the merged document
setPdf(mergedPdf); setPdf(mergedPdf);
const mergedOrder = Array.from(
{ length: mergedPdf.pageCount }, setPages(mergedPages);
(_, i) => i latestPagesRef.current = mergedPages;
); setSelectedPageIds([]);
setOrder(mergedOrder);
setRotations({});
setSelectedPages([]);
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
setSplitResults([]); setSplitResults([]);
resetGeneratedUrls();
if (subsetUrl) { setReorderThumbnails({});
URL.revokeObjectURL(subsetUrl); thumbnailCacheRef.current.clear();
setSubsetUrl(null); previousPageRotationsRef.current.clear();
setSubsetFilename(null); setPreviewPageId(null);
}
if (exportUrl) {
URL.revokeObjectURL(exportUrl);
setExportUrl(null);
setExportFilename(null);
}
setBaseThumbnails(null);
setReorderThumbnails(null);
setPreviewIndex(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).');
@@ -175,146 +221,404 @@ const App: React.FC = () => {
} }
}; };
useEffect(() => {
latestPagesRef.current = pages;
}, [pages]);
useEffect(() => { useEffect(() => {
if (!pdf) { if (!pdf) {
setBaseThumbnails(null); setReorderThumbnails({});
setReorderThumbnails(null); thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
return; return;
} }
let cancelled = false;
(async () => { const controller = new AbortController();
try {
const thumbs = await generateThumbnails(pdf.arrayBuffer); latestPagesRef.current = pages;
if (!cancelled) { thumbnailCacheRef.current.clear();
setBaseThumbnails(thumbs); previousPageRotationsRef.current = new Map(
setReorderThumbnails(thumbs); 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); console.error(e);
if (!cancelled) {
setError('Failed to generate thumbnails (see console).'); setError('Failed to generate thumbnails (see console).');
} }
} });
})();
return () => { return () => {
cancelled = true; controller.abort();
}; };
}, [pdf]); }, [pdf]);
useEffect(() => { useEffect(() => {
if (!pdf) { if (!pdf) {
setReorderThumbnails(null); previousPageRotationsRef.current.clear();
return; return;
} }
let cancelled = false;
(async () => { const previousRotations = previousPageRotationsRef.current;
try { const changedPages = pages.filter(
const thumbs = await generateThumbnailsWithRotations( (page) =>
pdf.arrayBuffer, normalizeRotation(previousRotations.get(page.id)) !==
rotations normalizeRotation(page.rotation)
); );
if (!cancelled) {
setReorderThumbnails(thumbs); 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);
} }
} catch (e) { }
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); console.error(e);
if (!cancelled) {
setError('Failed to generate rotated thumbnails (see console).'); setError('Failed to generate rotated thumbnails (see console).');
} }
} });
})();
return () => { 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 hasPdf = !!pdf;
const pageCount = pdf?.pageCount ?? 0;
// === UI interactions === // === UI interactions ===
const handleRotatePageClockwise = (pageIndex: number) => { const handleRotatePageClockwise = (pageId: string) => {
setRotations((prev) => { setPages((prev) =>
const current = prev[pageIndex] ?? 0; prev.map((page) =>
const next = (current + 90) % 360; page.id === pageId
return { ...prev, [pageIndex]: next }; ? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
}); : page
)
);
}; };
const handleRotatePageCounterclockwise = (pageIndex: number) => { const handleRotatePageCounterclockwise = (pageId: string) => {
setRotations((prev) => { setPages((prev) =>
const current = prev[pageIndex] ?? 0; prev.map((page) =>
const next = (current + 270) % 360; page.id === pageId
return { ...prev, [pageIndex]: next }; ? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
}); : page
)
);
}; };
const handleDeletePage = (pageIndex: number) => { const handleDeletePage = (pageId: string) => {
setOrder((prev) => prev.filter((p) => p !== pageIndex)); setPages((prev) => prev.filter((page) => page.id !== pageId));
setSelectedPages((prev) => prev.filter((p) => p !== pageIndex)); setSelectedPageIds((prev) => prev.filter((id) => id !== pageId));
}; };
const handleReorder = (newOrder: number[]) => { const handleReorder = (newPages: PageRef[]) => {
setOrder(newOrder); setPages(newPages);
}; };
const handleToggleSelect = ( const handleToggleSelect = (
pageIndex: number, pageId: string,
visualIndex: number, visualIndex: number,
e: React.MouseEvent<HTMLButtonElement> e: React.MouseEvent<HTMLButtonElement>
) => { ) => {
setSelectedPages((prev) => { setSelectedPageIds((prev) => {
// Shift: add a range (visual positions) to the existing selection if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) {
if (e.shiftKey && lastSelectedVisualIndex !== null && order.length > 0) {
const from = Math.min(lastSelectedVisualIndex, visualIndex); const from = Math.min(lastSelectedVisualIndex, visualIndex);
const to = Math.max(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); const set = new Set(prev);
rangeIndices.forEach((idx) => set.add(idx)); rangeIds.forEach((id) => set.add(id));
return Array.from(set); return Array.from(set);
} }
// Plain click: toggle this page if (prev.includes(pageId)) {
if (prev.includes(pageIndex)) { return prev.filter((id) => id !== pageId);
return prev.filter((p) => p !== pageIndex);
} }
return [...prev, pageIndex]; return [...prev, pageId];
}); });
setLastSelectedVisualIndex(visualIndex); setLastSelectedVisualIndex(visualIndex);
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
setSelectedPages([...order]); setSelectedPageIds(pages.map((page) => page.id));
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
}; };
const handleClearSelection = () => { const handleClearSelection = () => {
setSelectedPages([]); setSelectedPageIds([]);
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
}; };
const handleDeleteSelected = () => { const handleDeleteSelected = () => {
if (selectedPages.length === 0) return; if (selectedPageIds.length === 0) return;
setOrder((prev) => prev.filter((p) => !selectedPages.includes(p))); const selectedSet = new Set(selectedPageIds);
setSelectedPages([]); setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
setSelectedPageIds([]);
setLastSelectedVisualIndex(null); setLastSelectedVisualIndex(null);
}; };
const handleOpenPreview = (pageIndex: number) => { const handleCopyPagesToSlot = (pageIds: string[], insertSlot: number) => {
setPreviewIndex(pageIndex); 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 = () => { 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 () => { const handleSplit = async () => {
if (!pdf) return; if (!pdf) return;
setError(null); setError(null);
@@ -331,7 +635,7 @@ const App: React.FC = () => {
}; };
const handleExtractSelected = async () => { const handleExtractSelected = async () => {
if (!pdf || selectedPages.length === 0) return; if (!pdf || selectedPageIds.length === 0) return
setError(null); setError(null);
setIsBusy(true); setIsBusy(true);
@@ -342,10 +646,9 @@ const App: React.FC = () => {
} }
try { try {
const selectedOrder = order.filter((idx) => const selectedSet = new Set(selectedPageIds);
selectedPages.includes(idx) const selectedPages = pages.filter((page) => selectedSet.has(page.id));
); const blob = await exportPages(pdf, selectedPages);
const blob = await exportReordered(pdf, selectedOrder, rotations);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const base = pdf.name.replace(/\.pdf$/i, ''); const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_selected.pdf`; const filename = `${base}_selected.pdf`;
@@ -360,7 +663,7 @@ const App: React.FC = () => {
}; };
const handleExportReordered = async () => { const handleExportReordered = async () => {
if (!pdf || order.length === 0) return; if (!pdf || pages.length === 0) return;
setError(null); setError(null);
setIsBusy(true); setIsBusy(true);
@@ -371,7 +674,7 @@ const App: React.FC = () => {
} }
try { try {
const blob = await exportReordered(pdf, order, rotations); const blob = await exportPages(pdf, pages);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const base = pdf.name.replace(/\.pdf$/i, ''); const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_reordered.pdf`; 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 ( return (
<Layout> <Layout>
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} /> <FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
{showMergeOptions && pendingFile && pdf && order.length > 0 && ( {showMergeOptions && pendingFile && pdf && pages.length > 0 && (
<div <div
className="card" className="card"
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }} style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
> >
<h2>Open file: merge or replace?</h2> <h2>Open file: merge or replace?</h2>
<p style={{ fontSize: '0.85rem', color: '#374151' }}> <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{' '} pages open. What should happen with{' '}
<strong>{pendingFile.name}</strong>? <strong>{pendingFile.name}</strong>?
</p> </p>
@@ -451,7 +765,7 @@ const App: React.FC = () => {
<input <input
type="number" type="number"
min={1} min={1}
max={order.length + 1} max={pages.length + 1}
value={mergeInsertAt} value={mergeInsertAt}
onChange={(e) => setMergeInsertAt(e.target.value)} onChange={(e) => setMergeInsertAt(e.target.value)}
style={{ style={{
@@ -461,7 +775,7 @@ const App: React.FC = () => {
}} }}
/>{' '} />{' '}
<span style={{ color: '#6b7280' }}> <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>
</span> </span>
</label> </label>
@@ -489,16 +803,16 @@ const App: React.FC = () => {
)} )}
<ReorderPanel <ReorderPanel
order={order} pages={pages}
thumbnails={reorderThumbnails} thumbnails={reorderThumbnails}
isBusy={isBusy} isBusy={isBusy}
hasPdf={hasPdf} hasPdf={hasPdf}
rotations={rotations} selectedPageIds={selectedPageIds}
selectedPages={selectedPages}
onRotateClockwise={handleRotatePageClockwise} onRotateClockwise={handleRotatePageClockwise}
onRotateCounterclockwise={handleRotatePageCounterclockwise} onRotateCounterclockwise={handleRotatePageCounterclockwise}
onDelete={handleDeletePage} onDelete={handleDeletePage}
onReorder={handleReorder} onReorder={handleReorder}
onCopyPagesToSlot={handleCopyPagesToSlot}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onOpenPreview={handleOpenPreview} onOpenPreview={handleOpenPreview}
@@ -510,7 +824,7 @@ const App: React.FC = () => {
<ActionsPanel <ActionsPanel
hasPdf={hasPdf} hasPdf={hasPdf}
isBusy={isBusy} isBusy={isBusy}
selectedCount={selectedPages.length} selectedCount={selectedPageIds.length}
onSplit={handleSplit} onSplit={handleSplit}
onExtractSelected={handleExtractSelected} onExtractSelected={handleExtractSelected}
onExportReordered={handleExportReordered} onExportReordered={handleExportReordered}
@@ -531,10 +845,16 @@ const App: React.FC = () => {
)} )}
<PagePreviewModal <PagePreviewModal
isOpen={previewIndex !== null} isOpen={previewPage !== null}
pdf={pdf} pdf={pdf}
pageIndex={previewIndex} pageIndex={previewPage?.sourcePageIndex ?? null}
rotation={previewIndex != null ? rotations[previewIndex] ?? 0 : 0} rotation={previewPage?.rotation ?? 0}
visualIndex={previewVisualIndex >= 0 ? previewVisualIndex : null}
totalPages={pages.length}
canGoPrevious={canPreviewPrevious}
canGoNext={canPreviewNext}
onPrevious={handlePreviewPrevious}
onNext={handlePreviewNext}
onClose={handleClosePreview} onClose={handleClosePreview}
/> />
</Layout> </Layout>

View File

@@ -1,79 +0,0 @@
import React from 'react';
interface PageListProps {
pageCount: number;
selectedPages: number[];
onTogglePage: (index: number) => void;
thumbnails: string[] | null;
}
const PageList: React.FC<PageListProps> = ({
pageCount,
selectedPages,
onTogglePage,
thumbnails,
}) => {
if (pageCount === 0) return null;
const pages = Array.from({ length: pageCount }, (_, i) => i);
return (
<div className="card">
<h2>2. Pages</h2>
<p>
Thumbnails are generated in your browser. Click to select pages (used by
future tools).
</p>
<div className="page-list">
{pages.map((i) => {
const selected = selectedPages.includes(i);
const thumb = thumbnails?.[i];
return (
<button
key={i}
type="button"
className={`page-pill ${selected ? 'selected' : ''}`}
onClick={() => onTogglePage(i)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
padding: '0.4rem',
minWidth: '90px',
}}
>
{thumb ? (
<img
src={thumb}
alt={`Page ${i + 1}`}
style={{
maxHeight: '100px',
width: 'auto',
borderRadius: '0.25rem',
border: '1px solid #e5e7eb',
background: 'white',
}}
/>
) : (
<div
style={{
width: '60px',
height: '80px',
borderRadius: '0.25rem',
border: '1px dashed #d1d5db',
background: '#f3f4f6',
}}
/>
)}
<span>Page {i + 1}</span>
</button>
);
})}
</div>
</div>
);
};
export default PageList;

View File

@@ -10,8 +10,17 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
interface PagePreviewModalProps { interface PagePreviewModalProps {
isOpen: boolean; isOpen: boolean;
pdf: PdfFile | null; pdf: PdfFile | null;
pageIndex: number | null; // original page index (0-based) pageIndex: number | null; // original page index, 0-based
rotation: number; // degrees rotation: number; // degrees
visualIndex: number | null; // current position in order, 0-based
totalPages: number;
canGoPrevious: boolean;
canGoNext: boolean;
onPrevious: () => void;
onNext: () => void;
onClose: () => void; onClose: () => void;
} }
@@ -20,10 +29,45 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
pdf, pdf,
pageIndex, pageIndex,
rotation, rotation,
visualIndex,
totalPages,
canGoPrevious,
canGoNext,
onPrevious,
onNext,
onClose, onClose,
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'ArrowLeft' && canGoPrevious) {
e.preventDefault();
onPrevious();
return;
}
if (e.key === 'ArrowRight' && canGoNext) {
e.preventDefault();
onNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
useEffect(() => { useEffect(() => {
if (!isOpen || !pdf || pageIndex == null) return; if (!isOpen || !pdf || pageIndex == null) return;
@@ -31,6 +75,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
(async () => { (async () => {
try { try {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
// copy data for pdf.js (avoid detaching original ArrayBuffer) // copy data for pdf.js (avoid detaching original ArrayBuffer)
const src = new Uint8Array(pdf.arrayBuffer); const src = new Uint8Array(pdf.arrayBuffer);
const copy = new Uint8Array(src.byteLength); const copy = new Uint8Array(src.byteLength);
@@ -44,16 +96,23 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
if (cancelled) return; if (cancelled) return;
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });
const maxWidth = Math.min(window.innerWidth * 0.9, 800); const maxWidth = Math.min(window.innerWidth * 0.9, 800);
const scale = maxWidth / viewport.width; const maxHeight = window.innerHeight * 0.75;
const scale = Math.min(
maxWidth / viewport.width,
maxHeight / viewport.height
);
const scaledViewport = page.getViewport({ scale }); const scaledViewport = page.getViewport({ scale });
const canvas = canvasRef.current; const visibleCanvas = canvasRef.current;
if (!canvas) return; if (!visibleCanvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return; const visibleCtx = visibleCanvas.getContext('2d');
if (!visibleCtx) return;
// base size
let canvasWidth = scaledViewport.width; let canvasWidth = scaledViewport.width;
let canvasHeight = scaledViewport.height; let canvasHeight = scaledViewport.height;
@@ -64,10 +123,9 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
canvasHeight = scaledViewport.width; canvasHeight = scaledViewport.width;
} }
canvas.width = canvasWidth; visibleCanvas.width = canvasWidth;
canvas.height = canvasHeight; visibleCanvas.height = canvasHeight;
// render into an offscreen canvas first
const baseCanvas = document.createElement('canvas'); const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d'); const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) return; if (!baseCtx) return;
@@ -79,31 +137,29 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
canvasContext: baseCtx, canvasContext: baseCtx,
viewport: scaledViewport, viewport: scaledViewport,
}); });
await renderTask.promise; await renderTask.promise;
if (cancelled) return; if (cancelled) return;
// draw rotated onto visible canvas visibleCtx.save();
ctx.save();
switch (angle) { switch (angle) {
case 90: case 90:
ctx.translate(canvasWidth, 0); visibleCtx.translate(canvasWidth, 0);
ctx.rotate((angle * Math.PI) / 180); visibleCtx.rotate((angle * Math.PI) / 180);
break; break;
case 180: case 180:
ctx.translate(canvasWidth, canvasHeight); visibleCtx.translate(canvasWidth, canvasHeight);
ctx.rotate((angle * Math.PI) / 180); visibleCtx.rotate((angle * Math.PI) / 180);
break; break;
case 270: case 270:
ctx.translate(0, canvasHeight); visibleCtx.translate(0, canvasHeight);
ctx.rotate((angle * Math.PI) / 180); visibleCtx.rotate((angle * Math.PI) / 180);
break;
default:
break; break;
} }
ctx.drawImage(baseCanvas, 0, 0); visibleCtx.drawImage(baseCanvas, 0, 0);
ctx.restore(); visibleCtx.restore();
} catch (e) { } catch (e) {
console.error('Error rendering preview', e); console.error('Error rendering preview', e);
} }
@@ -116,6 +172,11 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
if (!isOpen || !pdf || pageIndex == null) return null; if (!isOpen || !pdf || pageIndex == null) return null;
const positionLabel =
visualIndex != null && visualIndex >= 0
? `${visualIndex + 1} / ${totalPages}`
: `Page ${pageIndex + 1}`;
return ( return (
<div <div
onClick={onClose} onClick={onClose}
@@ -133,6 +194,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
position: 'relative',
background: '#111827', background: '#111827',
borderRadius: '0.75rem', borderRadius: '0.75rem',
padding: '0.75rem', padding: '0.75rem',
@@ -142,25 +204,107 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: '0.5rem', gap: '0.5rem',
overflow: 'visible',
}} }}
> >
<div style={{ alignSelf: 'flex-end' }}> {/* Previous page */}
<button <button
type="button" type="button"
onClick={onClose} onClick={(e) => {
e.stopPropagation();
onPrevious();
}}
disabled={!canGoPrevious}
style={{ style={{
border: 'none', position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px', borderRadius: '999px',
padding: '0.25rem 0.5rem', border: 'none',
fontSize: '0.8rem', background: canGoPrevious ? '#374151' : '#1f2937',
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
cursor: canGoPrevious ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Previous page (←)"
aria-label="Previous page"
>
</button>
{/* Next page */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
disabled={!canGoNext}
style={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translate(50%, -50%)',
width: '2.5rem',
height: '2.5rem',
borderRadius: '999px',
border: 'none',
background: canGoNext ? '#374151' : '#1f2937',
color: canGoNext ? '#e5e7eb' : '#6b7280',
cursor: canGoNext ? 'pointer' : 'default',
fontSize: '1.35rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
title="Next page (→)"
aria-label="Next page"
>
</button>
{/* Close */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
position: 'absolute',
top: 0,
right: 0,
transform: 'translate(50%, -50%)',
width: '2.25rem',
height: '2.25rem',
borderRadius: '999px',
border: 'none',
background: '#374151', background: '#374151',
color: '#e5e7eb', color: '#e5e7eb',
cursor: 'pointer', cursor: 'pointer',
fontSize: '1.2rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 3,
}} }}
title="Close preview (Esc)"
aria-label="Close preview"
> >
Close ×
</button> </button>
</div>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
@@ -170,8 +314,9 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
borderRadius: '0.5rem', borderRadius: '0.5rem',
}} }}
/> />
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}> <div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
Page {pageIndex + 1} · Rot {rotation}° {positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,41 +1,42 @@
import React, { useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import type { PageRef } from '../pdf/pdfTypes';
interface ReorderPanelProps { interface ReorderPanelProps {
order: number[]; // current page order (page indices) pages: PageRef[];
thumbnails: string[] | null; // thumbnails by original page index thumbnails: Record<string, string>;
isBusy: boolean; isBusy: boolean;
hasPdf: boolean; hasPdf: boolean;
rotations: Record<number, number>; selectedPageIds: string[];
selectedPages: number[]; // selected original page indices
onRotateClockwise: (pageIndex: number) => void; onRotateClockwise: (pageId: string) => void;
onRotateCounterclockwise: (pageIndex: number) => void; onRotateCounterclockwise: (pageId: string) => void;
onDelete: (pageIndex: number) => void; onDelete: (pageId: string) => void;
onReorder: (newOrder: number[]) => void; onReorder: (newPages: PageRef[]) => void;
onCopyPagesToSlot: (pageIds: string[], insertSlot: number) => void;
onToggleSelect: ( onToggleSelect: (
pageIndex: number, pageId: string,
visualIndex: number, visualIndex: number,
e: React.MouseEvent<HTMLButtonElement> e: React.MouseEvent<HTMLButtonElement>
) => void; ) => void;
onSelectAll: () => void; onSelectAll: () => void;
onOpenPreview: (pageIndex: number) => void; onOpenPreview: (pageId: string) => void;
onClearSelection: () => void; onClearSelection: () => void;
onDeleteSelected: () => void; onDeleteSelected: () => void;
} }
const ReorderPanel: React.FC<ReorderPanelProps> = ({ const ReorderPanel: React.FC<ReorderPanelProps> = ({
order, pages,
thumbnails, thumbnails,
isBusy, isBusy,
hasPdf, hasPdf,
rotations, selectedPageIds,
selectedPages,
onRotateClockwise, onRotateClockwise,
onRotateCounterclockwise, onRotateCounterclockwise,
onDelete, onDelete,
onReorder, onReorder,
onCopyPagesToSlot,
onToggleSelect, onToggleSelect,
onSelectAll, onSelectAll,
onOpenPreview, onOpenPreview,
@@ -43,10 +44,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
onDeleteSelected, onDeleteSelected,
}) => { }) => {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null); const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null); // slot 0..order.length const [dropIndex, setDropIndex] = useState<number | null>(null);
const [isCopyDragging, setIsCopyDragging] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyTargetPosition, setCopyTargetPosition] = useState('');
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
const dragGhostRef = useRef<HTMLDivElement | null>(null); const dragGhostRef = useRef<HTMLDivElement | null>(null);
const isSelected = (pageIndex: number) => selectedPages.includes(pageIndex); const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
const cleanupDragGhost = () => { const cleanupDragGhost = () => {
if (dragGhostRef.current && dragGhostRef.current.parentNode) { if (dragGhostRef.current && dragGhostRef.current.parentNode) {
@@ -55,11 +62,31 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
dragGhostRef.current = null; dragGhostRef.current = null;
}; };
const isCopyModifierPressed = (e: React.DragEvent) => {
return e.ctrlKey || e.metaKey;
};
const getDraggedPages = (visualIndex: number): PageRef[] => {
const draggedPage = pages[visualIndex];
if (!draggedPage) return [];
const selectedInVisualOrder = pages.filter((page) =>
selectedPageIds.includes(page.id)
);
const draggingIsSelected =
selectedInVisualOrder.length > 0 &&
selectedInVisualOrder.some((page) => page.id === draggedPage.id);
return draggingIsSelected ? selectedInVisualOrder : [draggedPage];
};
const createDragGhost = (e: React.DragEvent, count: number) => { const createDragGhost = (e: React.DragEvent, count: number) => {
cleanupDragGhost(); cleanupDragGhost();
const ghost = document.createElement('div'); const ghost = document.createElement('div');
ghost.textContent = count === 1 ? '1 page' : `${count} pages`; ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
ghost.style.position = 'fixed'; ghost.style.position = 'fixed';
ghost.style.top = '0'; ghost.style.top = '0';
ghost.style.left = '0'; ghost.style.left = '0';
@@ -68,49 +95,48 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
ghost.style.background = '#111827'; ghost.style.background = '#111827';
ghost.style.color = '#e5e7eb'; ghost.style.color = '#e5e7eb';
ghost.style.fontSize = '12px'; ghost.style.fontSize = '12px';
ghost.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; ghost.style.fontFamily =
ghost.style.boxShadow = '0 4px 8px rgba(15, 23, 42, 0.4)'; 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ghost.style.zIndex = '9999'; ghost.style.zIndex = '9999';
document.body.appendChild(ghost); document.body.appendChild(ghost);
dragGhostRef.current = ghost; dragGhostRef.current = ghost;
// center drag image under cursor
const rect = ghost.getBoundingClientRect(); const rect = ghost.getBoundingClientRect();
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2); e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
}; };
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => { const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
setDraggingIndex(visualIndex); setDraggingIndex(visualIndex);
setDropIndex(visualIndex); // initial: before itself setDropIndex(visualIndex);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(visualIndex)); // Firefox
const draggedPageIndex = order[visualIndex]; const copying = isCopyModifierPressed(e);
const selectedInVisualOrder = order.filter((p) => setIsCopyDragging(copying);
selectedPages.includes(p)
);
const draggingIsSelected =
selectedInVisualOrder.length > 0 &&
selectedInVisualOrder.includes(draggedPageIndex);
const movingPages = draggingIsSelected e.dataTransfer.effectAllowed = 'copyMove';
? selectedInVisualOrder e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
: [draggedPageIndex]; e.dataTransfer.setData('text/plain', String(visualIndex));
createDragGhost(e, movingPages.length); const draggedPages = getDraggedPages(visualIndex);
createDragGhost(e, draggedPages.length);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
cleanupDragGhost(); cleanupDragGhost();
setDraggingIndex(null); setDraggingIndex(null);
setDropIndex(null); setDropIndex(null);
setIsCopyDragging(false);
}; };
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => { const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
if (draggingIndex == null) return; if (draggingIndex == null) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
@@ -121,9 +147,15 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
const handleEndSlotDragOver = (e: React.DragEvent) => { const handleEndSlotDragOver = (e: React.DragEvent) => {
if (draggingIndex == null) return; if (draggingIndex == null) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropIndex(order.length); const copying = isCopyModifierPressed(e);
setIsCopyDragging(copying);
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
setDropIndex(pages.length);
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
@@ -132,78 +164,126 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
if (draggingIndex == null || dropIndex == null) return; if (draggingIndex == null || dropIndex == null) return;
const draggedPageIndex = order[draggingIndex]; const draggedPages = getDraggedPages(draggingIndex);
if (draggedPages.length === 0) return;
// Selected pages in current visual order const shouldCopy = isCopyModifierPressed(e) || isCopyDragging;
const selectedInVisualOrder = order.filter((p) =>
selectedPages.includes(p) if (shouldCopy) {
onCopyPagesToSlot(
draggedPages.map((page) => page.id),
dropIndex
); );
const draggingIsSelected = setDraggingIndex(null);
selectedInVisualOrder.length > 0 && setDropIndex(null);
selectedInVisualOrder.includes(draggedPageIndex); setIsCopyDragging(false);
return;
}
// Pages that will move: const indexMap = new Map<string, number>();
// - if dragging selected -> move full selection (in visual order) pages.forEach((page, idx) => indexMap.set(page.id, idx));
// - else -> only the dragged page
const movingPages = draggingIsSelected
? selectedInVisualOrder
: [draggedPageIndex];
// Map from page index to visual position const countBefore = draggedPages.reduce((count, page) => {
const indexMap = new Map<number, number>(); const idx = indexMap.get(page.id);
order.forEach((p, idx) => indexMap.set(p, idx));
// how many of the moving pages were before the drop slot?
const countBefore = movingPages.reduce((count, p) => {
const idx = indexMap.get(p);
if (idx != null && idx < dropIndex) return count + 1; if (idx != null && idx < dropIndex) return count + 1;
return count; return count;
}, 0); }, 0);
const adjustedSlot = dropIndex - countBefore; const adjustedSlot = dropIndex - countBefore;
const movingSet = new Set(draggedPages.map((page) => page.id));
const remaining = pages.filter((page) => !movingSet.has(page.id));
// Remove moving pages from current order const newPages = [
const movingSet = new Set(movingPages);
const remaining = order.filter((p) => !movingSet.has(p));
// Insert in same relative order at new slot
const newOrder = [
...remaining.slice(0, adjustedSlot), ...remaining.slice(0, adjustedSlot),
...movingPages, ...draggedPages,
...remaining.slice(adjustedSlot), ...remaining.slice(adjustedSlot),
]; ];
onReorder(newOrder); onReorder(newPages);
setDraggingIndex(null);
setDropIndex(null);
setIsCopyDragging(false);
};
const handleDeleteClick = (pageId: string) => () => {
onDelete(pageId);
setDraggingIndex(null); setDraggingIndex(null);
setDropIndex(null); setDropIndex(null);
}; };
const handleDeleteClick = (pageIndex: number) => () => { const handleRotateClickClockwise = (pageId: string) => () => {
onDelete(pageIndex); onRotateClockwise(pageId);
setDraggingIndex(null);
setDropIndex(null);
}; };
const handleRotateClickClockwise = (pageIndex: number) => () => { const handleRotateClickCounterclockwise = (pageId: string) => () => {
onRotateClockwise(pageIndex); onRotateCounterclockwise(pageId);
}; };
const handleRotateClickCounterclockwise = (pageIndex: number) => () => { const handleCardClick = (pageId: string) => () => {
onRotateCounterclockwise(pageIndex); onOpenPreview(pageId);
};
const handleCardClick = (pageIndex: number) => () => {
onOpenPreview(pageIndex);
}; };
const handleCheckboxClick = const handleCheckboxClick =
(pageIndex: number, visualIndex: number) => (pageId: string, visualIndex: number) =>
(e: React.MouseEvent<HTMLButtonElement>) => { (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // don't trigger preview e.stopPropagation(); // don't trigger preview
onToggleSelect(pageIndex, visualIndex, e); onToggleSelect(pageId, visualIndex, e);
}; };
const handleCopySelectedClick = () => {
if (selectedPageIds.length === 0) return;
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
setCopyDialogError(null);
setCopyDialogOpen(true);
};
const handleCopyDialogCancel = () => {
setCopyDialogOpen(false);
setCopyDialogError(null);
};
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
e?.preventDefault();
if (selectedPageIds.length === 0) {
setCopyDialogError('No pages selected.');
return;
}
const maxPosition = pages.length + 1;
const parsed = Number.parseInt(copyTargetPosition.trim(), 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > maxPosition) {
setCopyDialogError(`Please enter a number between 1 and ${maxPosition}.`);
return;
}
onCopyPagesToSlot(selectedPageIds, parsed - 1);
setCopyDialogOpen(false);
setCopyDialogError(null);
};
useEffect(() => {
if (!copyDialogOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleCopyDialogCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [copyDialogOpen]);
if (!hasPdf) { if (!hasPdf) {
return ( return (
<div className="card"> <div className="card">
@@ -222,23 +302,26 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
draggingIndex !== null; draggingIndex !== null;
const showEndLine = () => const showEndLine = () =>
dropIndex !== null && dropIndex === order.length && draggingIndex !== null; dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
// For highlighting the whole selection while dragging it // For highlighting the whole selection while dragging it
const draggingPageIndex = const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
draggingIndex != null ? order[draggingIndex] : null;
const draggingSelectionActive = const draggingSelectionActive =
draggingPageIndex != null && draggingPage != null &&
selectedPages.length > 0 && selectedPageIds.length > 0 &&
selectedPages.includes(draggingPageIndex); selectedPageIds.includes(draggingPage.id);
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
return ( return (
<>
<div className="card"> <div className="card">
<h2>Pages</h2> <h2>Pages</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}> <p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
Tap/click a page to preview it. Use the checkbox to select pages Tap/click a page to preview it. Use the checkbox to select pages
(Shift for ranges). Drag to reorder; dragging a selected page moves the (Shift for ranges). Drag to reorder; dragging a selected page moves the
whole selection. whole selection. Hold Ctrl/ while dropping to copy instead of move.
Shortcuts: Ctrl/+A selects all, Delete removes selected pages, Esc clears
selection.
</p> </p>
<div <div
@@ -251,7 +334,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
}} }}
> >
<span> <span>
Selected: <strong>{selectedPages.length}</strong> Selected: <strong>{selectedPageIds.length}</strong>
</span> </span>
<div <div
style={{ style={{
@@ -259,7 +342,26 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
gap: '0.4rem', gap: '0.4rem',
}} }}
> >
{selectedPages.length > 0 && ( {selectedPageIds.length > 0 && (
<button
type="button"
onClick={handleCopySelectedClick}
disabled={selectedPageIds.length === 0}
style={{
border: 'none',
borderRadius: '999px',
padding: '0.15rem 0.6rem',
fontSize: '0.8rem',
background: '#dcfce7',
color: '#166534',
cursor: 'pointer',
}}
title={'Copy selected pages to another position'}
>
Copy selected
</button>
)}
{selectedPageIds.length > 0 && (
<button <button
type="button" type="button"
onClick={onDeleteSelected} onClick={onDeleteSelected}
@@ -294,17 +396,15 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
<button <button
type="button" type="button"
onClick={onClearSelection} onClick={onClearSelection}
disabled={selectedPages.length === 0} disabled={selectedPageIds.length === 0}
style={{ style={{
border: 'none', border: 'none',
borderRadius: '999px', borderRadius: '999px',
padding: '0.15rem 0.6rem', padding: '0.15rem 0.6rem',
fontSize: '0.8rem', fontSize: '0.8rem',
background: background: '#e5e7eb',
selectedPages.length === 0 ? '#e5e7eb' : '#e5e7eb', color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
color: cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
selectedPages.length === 0 ? '#6b7280' : '#111827',
cursor: selectedPages.length === 0 ? 'default' : 'pointer',
}} }}
> >
Clear selection Clear selection
@@ -322,10 +422,10 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
}} }}
onDrop={handleDrop} onDrop={handleDrop}
> >
{order.map((pageIndex, visualIndex) => { {pages.map((page, visualIndex) => {
const thumb = thumbnails?.[pageIndex]; const thumb = thumbnails[page.id];
const rotation = rotations[pageIndex] ?? 0; const rotation = page.rotation;
const selected = isSelected(pageIndex); const selected = isSelected(page.id);
const isDraggingCard = const isDraggingCard =
draggingIndex != null && draggingIndex != null &&
@@ -334,20 +434,22 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
return ( return (
<div <div
key={`${pageIndex}-${visualIndex}`} key={page.id}
draggable draggable
onDragStart={handleDragStart(visualIndex)} onDragStart={handleDragStart(visualIndex)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragOver={handleCardDragOver(visualIndex)} onDragOver={handleCardDragOver(visualIndex)}
onClick={handleCardClick(pageIndex)} onClick={handleCardClick(page.id)}
style={{ style={{
position: 'relative', position: 'relative',
width: '162px', width: '162px',
padding: '0.4rem', padding: '0.4rem',
borderRadius: '0.5rem', borderRadius: '0.5rem',
border: '1px solid #e5e7eb', // constant → no jump border: '1px solid #e5e7eb',
background: isDraggingCard background: isDraggingCard
? '#dbeafe' ? isCopyDragging
? '#dcfce7'
: '#dbeafe'
: selected : selected
? '#eff6ff' ? '#eff6ff'
: '#f9fafb', : '#f9fafb',
@@ -355,14 +457,14 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: '0.25rem', gap: '0.25rem',
cursor: isBusy ? 'default' : 'grab', cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
opacity: isBusy ? 0.7 : 1, opacity: isBusy ? 0.7 : 1,
}} }}
> >
{/* selection checkbox */} {/* selection checkbox */}
<button <button
type="button" type="button"
onClick={handleCheckboxClick(pageIndex, visualIndex)} onClick={handleCheckboxClick(page.id, visualIndex)}
style={{ style={{
position: 'absolute', position: 'absolute',
top: '4px', top: '4px',
@@ -395,7 +497,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
bottom: '4px', bottom: '4px',
width: '3px', width: '3px',
borderRadius: '999px', borderRadius: '999px',
background: '#2563eb', background: dropIndicatorColor,
}} }}
/> />
)} )}
@@ -410,18 +512,30 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
bottom: '4px', bottom: '4px',
width: '3px', width: '3px',
borderRadius: '999px', borderRadius: '999px',
background: '#2563eb', background: dropIndicatorColor,
}} }}
/> />
)} )}
<div
style={{
width: '110px',
height: '90px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{thumb ? ( {thumb ? (
<img <img
src={thumb} src={thumb}
alt={`Page ${pageIndex + 1}`} alt={`Page ${page.sourcePageIndex + 1}`}
style={{ style={{
maxHeight: '90px', maxWidth: '100%',
maxHeight: '100%',
width: 'auto', width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: '0.25rem', borderRadius: '0.25rem',
border: '1px solid #e5e7eb', border: '1px solid #e5e7eb',
background: 'white', background: 'white',
@@ -438,7 +552,9 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
}} }}
/> />
)} )}
<span style={{ fontSize: '0.8rem' }}>Page {pageIndex + 1}</span> </div>
<span style={{ fontSize: '0.8rem' }}>Page {page.sourcePageIndex + 1}</span>
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}> <span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
Pos {visualIndex + 1} · Rot {rotation}° Pos {visualIndex + 1} · Rot {rotation}°
</span> </span>
@@ -454,7 +570,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRotateClickClockwise(pageIndex)(); handleRotateClickClockwise(page.id)();
}} }}
style={{ style={{
border: 'none', border: 'none',
@@ -471,7 +587,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRotateClickCounterclockwise(pageIndex)(); handleRotateClickCounterclockwise(page.id)();
}} }}
style={{ style={{
border: 'none', border: 'none',
@@ -488,7 +604,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteClick(pageIndex)(); handleDeleteClick(page.id)();
}} }}
style={{ style={{
border: 'none', border: 'none',
@@ -509,7 +625,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
})} })}
{/* end slot for dropping after the last card */} {/* end slot for dropping after the last card */}
{order.length > 0 && ( {pages.length > 0 && (
<div <div
onDragOver={handleEndSlotDragOver} onDragOver={handleEndSlotDragOver}
onDrop={handleDrop} onDrop={handleDrop}
@@ -529,7 +645,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
bottom: '4px', bottom: '4px',
width: '3px', width: '3px',
borderRadius: '999px', borderRadius: '999px',
background: '#2563eb', background: dropIndicatorColor,
}} }}
/> />
)} )}
@@ -537,6 +653,196 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
)} )}
</div> </div>
</div> </div>
{copyDialogOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="copy-pages-dialog-title"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
handleCopyDialogCancel();
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<form
onSubmit={handleCopyDialogConfirm}
style={{
width: '100%',
maxWidth: '420px',
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="copy-pages-dialog-title"
style={{
margin: 0,
fontSize: '1rem',
}}
>
Copy selected pages
</h2>
<button
type="button"
onClick={handleCopyDialogCancel}
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 copy dialog"
>
×
</button>
</div>
<p
style={{
margin: 0,
fontSize: '0.9rem',
color: '#4b5563',
}}
>
Copy{' '}
<strong>
{selectedPageIds.length === 1
? '1 selected page'
: `${selectedPageIds.length} selected pages`}
</strong>{' '}
to a new position.
</p>
<label
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
fontSize: '0.9rem',
}}
>
Insert before position
<input
type="number"
min={1}
max={pages.length + 1}
value={copyTargetPosition}
autoFocus
onChange={(e) => {
setCopyTargetPosition(e.target.value);
setCopyDialogError(null);
}}
style={{
padding: '0.45rem 0.55rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
fontSize: '0.95rem',
}}
/>
</label>
<div
style={{
fontSize: '0.8rem',
color: '#6b7280',
lineHeight: 1.4,
}}
>
<div>1 = before the first page</div>
<div>{pages.length + 1} = after the last page</div>
</div>
{copyDialogError && (
<div
style={{
borderRadius: '0.5rem',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
padding: '0.5rem',
fontSize: '0.85rem',
}}
>
{copyDialogError}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<button
type="button"
onClick={handleCopyDialogCancel}
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#e5e7eb',
color: '#111827',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Cancel
</button>
<button
type="submit"
style={{
border: 'none',
borderRadius: '0.5rem',
padding: '0.45rem 0.8rem',
background: '#16a34a',
color: 'white',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Copy pages
</button>
</div>
</form>
</div>
)}
</>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { PDFDocument, degrees } from 'pdf-lib'; import { PDFDocument, degrees } from 'pdf-lib';
import type { PdfFile, SplitResult, Range } from './pdfTypes'; import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
function createId() { function createId() {
return Math.random().toString(36).slice(2); return Math.random().toString(36).slice(2);
@@ -53,19 +53,18 @@ export async function mergePdfFiles(
} }
const bytes = await mergedDoc.save(); const bytes = await mergedDoc.save();
const buffer = bytes.buffer.slice( const buffer = new ArrayBuffer(bytes.byteLength);
bytes.byteOffset, new Uint8Array(buffer).set(bytes);
bytes.byteOffset + bytes.byteLength
);
const baseName = basePdf.name.replace(/\.pdf$/i, ''); const baseName = basePdf.name.replace(/\.pdf$/i, '');
const newName = newPdf.name.replace(/\.pdf$/i, ''); const newName = newPdf.name.replace(/\.pdf$/i, '');
return { return {
id: createId(),
name: `${baseName}_plus_${newName}.pdf`, name: `${baseName}_plus_${newName}.pdf`,
arrayBuffer: buffer, arrayBuffer: buffer,
pageCount: mergedDoc.getPageCount(), pageCount: mergedDoc.getPageCount(),
doc: mergedDoc, // 👈 important doc: mergedDoc,
}; };
} }
@@ -93,7 +92,7 @@ export async function splitIntoSinglePages(
if (title) newDoc.setTitle(title); if (title) newDoc.setTitle(title);
if (author) newDoc.setAuthor(author); if (author) newDoc.setAuthor(author);
if (subject) newDoc.setSubject(subject); if (subject) newDoc.setSubject(subject);
if (keywords) newDoc.setKeywords(keywords); if (keywords) newDoc.setKeywords([keywords]);
if (producer) newDoc.setProducer(producer); if (producer) newDoc.setProducer(producer);
if (creator) newDoc.setCreator(creator); if (creator) newDoc.setCreator(creator);
if (creationDate) newDoc.setCreationDate(creationDate); if (creationDate) newDoc.setCreationDate(creationDate);
@@ -154,30 +153,32 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
return new Blob([bytes], { type: 'application/pdf' }); return new Blob([bytes], { type: 'application/pdf' });
} }
export async function exportReordered( export async function exportPages(
pdf: PdfFile, pdf: PdfFile,
order: number[], pages: PageRef[]
rotations?: Record<number, number>
): Promise<Blob> { ): Promise<Blob> {
const { doc } = pdf; const { doc } = pdf;
const pageCount = doc.getPageCount(); const pageCount = doc.getPageCount();
if (order.length === 0) { if (pages.length === 0) {
throw new Error('Order must contain at least one page'); throw new Error('Pages must contain at least one page');
} }
if (order.some((i) => i < 0 || i >= pageCount)) { if (
throw new Error('Order contains invalid page indices'); pages.some(
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount
)
) {
throw new Error('Pages contain invalid source page indices');
} }
const newDoc = await PDFDocument.create(); const newDoc = await PDFDocument.create();
const indices = [...order]; const indices = pages.map((page) => page.sourcePageIndex);
const copiedPages = await newDoc.copyPages(doc, indices); const copiedPages = await newDoc.copyPages(doc, indices);
copiedPages.forEach((page, idx) => { copiedPages.forEach((page, idx) => {
const originalIndex = indices[idx]; const angle = pages[idx].rotation;
const angle = rotations?.[originalIndex];
if (typeof angle === 'number' && angle % 360 !== 0) { if (typeof angle === 'number' && angle % 360 !== 0) {
page.setRotation(degrees(angle)); page.setRotation(degrees(angle));
@@ -189,3 +190,18 @@ export async function exportReordered(
const bytes = await newDoc.save(); const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' }); return new Blob([bytes], { type: 'application/pdf' });
} }
export async function exportReordered(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
): Promise<Blob> {
return exportPages(
pdf,
order.map((sourcePageIndex) => ({
id: String(sourcePageIndex),
sourcePageIndex,
rotation: rotations?.[sourcePageIndex] ?? 0,
}))
);
}

View File

@@ -19,53 +19,136 @@ function makePdfJsDataCopy(arrayBuffer: ArrayBuffer): Uint8Array {
return copy; return copy;
} }
interface ThumbnailUpdate {
pageIndex: number;
dataUrl: string;
}
interface ThumbnailGenerationOptions {
maxHeight?: number;
maxWidth?: number;
concurrency?: number;
/**
* Optional subset of 0-based page indices to render.
* If omitted, all pages are rendered.
*/
pageIndices?: number[];
signal?: AbortSignal;
onThumbnail?: (update: ThumbnailUpdate) => void;
}
/** /**
* Unrotated thumbnails used e.g. in the Split/Extract view. * Unrotated thumbnails used e.g. in the Split/Extract view.
*/ */
export async function generateThumbnails( export async function generateThumbnailsProgressive(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
maxHeight = 150 options: ThumbnailGenerationOptions = {}
): Promise<string[]> { ): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, {}, maxHeight); return generateThumbnailsInternal(arrayBuffer, {}, options);
} }
/** /**
* Thumbnails that respect per-page rotations (for the Reorder view). * Thumbnails that respect per-page rotations (for the Reorder view).
*/ */
export async function generateThumbnailsWithRotations( export async function generateThumbnailsWithRotationsProgressive(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
rotations: RotationsMap, rotations: RotationsMap,
maxHeight = 150 options: ThumbnailGenerationOptions = {}
): Promise<string[]> { ): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight); return generateThumbnailsInternal(arrayBuffer, rotations, options);
} }
async function generateThumbnailsInternal( async function generateThumbnailsInternal(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
rotations: RotationsMap, rotations: RotationsMap,
maxHeight: number options: ThumbnailGenerationOptions = {}
): Promise<string[]> { ): Promise<string[]> {
// IMPORTANT: use a COPY so pdf.js can detach it without breaking future calls const maxHeight = options.maxHeight ?? 150;
const dataCopy = makePdfJsDataCopy(arrayBuffer); const maxWidth = options.maxWidth ?? 140;
const concurrency = Math.max(1, Math.min(options.concurrency ?? 3, 6));
const signal = options.signal;
const dataCopy = makePdfJsDataCopy(arrayBuffer);
const loadingTask = pdfjsLib.getDocument({ data: dataCopy }); const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
const thumbs: string[] = []; const thumbs = Array<string>(pdf.numPages).fill('');
const pageNums = options.pageIndices
? Array.from(
new Set(
options.pageIndices
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
.map((pageIndex) => pageIndex + 1)
)
)
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
let nextPageIndex = 0;
const renderOne = async (pageNum: number) => {
if (signal?.aborted) return;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum); const page = await pdf.getPage(pageNum);
if (signal?.aborted) return;
const pageIndex = pageNum - 1;
const dataUrl = await renderPageThumbnail(
page,
pageIndex,
rotations,
maxHeight,
maxWidth
);
if (signal?.aborted) return;
thumbs[pageIndex] = dataUrl;
options.onThumbnail?.({ pageIndex, dataUrl });
};
const worker = async () => {
while (!signal?.aborted) {
const pageNum = pageNums[nextPageIndex];
nextPageIndex += 1;
if (pageNum == null) return;
await renderOne(pageNum);
// Let React/browser paint between batches.
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
};
const workerCount = Math.min(concurrency, pageNums.length);
if (workerCount === 0) return thumbs;
await Promise.all(Array.from({ length: workerCount }, worker));
return thumbs;
}
async function renderPageThumbnail(
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
originalIndex: number,
rotations: RotationsMap,
maxHeight: number,
maxWidth: number
): Promise<string> {
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });
const scale = maxHeight / viewport.height; const scaleH = maxHeight / viewport.height;
const scaleW = maxWidth / viewport.width;
const scale = Math.min(scaleH, scaleW);
const scaledViewport = page.getViewport({ scale }); const scaledViewport = page.getViewport({ scale });
// First render unrotated page into a canvas
const baseCanvas = document.createElement('canvas'); const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d'); const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) {
thumbs.push(''); if (!baseCtx) return '';
continue;
}
baseCanvas.width = scaledViewport.width; baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height; baseCanvas.height = scaledViewport.height;
@@ -74,23 +157,21 @@ async function generateThumbnailsInternal(
canvasContext: baseCtx, canvasContext: baseCtx,
viewport: scaledViewport, viewport: scaledViewport,
}); });
await renderTask.promise; await renderTask.promise;
const originalIndex = pageNum - 1;
const rotationDegRaw = rotations[originalIndex] ?? 0; const rotationDegRaw = rotations[originalIndex] ?? 0;
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; // normalize 0359 const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
if (rotationDeg === 0) { if (rotationDeg === 0) {
thumbs.push(baseCanvas.toDataURL('image/png')); return baseCanvas.toDataURL('image/png');
continue;
} }
// Re-render onto a second canvas with rotation applied
const rotatedCanvas = document.createElement('canvas'); const rotatedCanvas = document.createElement('canvas');
const rotatedCtx = rotatedCanvas.getContext('2d'); const rotatedCtx = rotatedCanvas.getContext('2d');
if (!rotatedCtx) { if (!rotatedCtx) {
thumbs.push(baseCanvas.toDataURL('image/png')); return baseCanvas.toDataURL('image/png');
continue;
} }
const rad = (rotationDeg * Math.PI) / 180; const rad = (rotationDeg * Math.PI) / 180;
@@ -118,16 +199,10 @@ async function generateThumbnailsInternal(
rotatedCtx.translate(0, rotatedCanvas.height); rotatedCtx.translate(0, rotatedCanvas.height);
rotatedCtx.rotate(rad); rotatedCtx.rotate(rad);
break; break;
default:
// fallback: no rotation
break;
} }
rotatedCtx.drawImage(baseCanvas, 0, 0); rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore(); rotatedCtx.restore();
thumbs.push(rotatedCanvas.toDataURL('image/png')); return rotatedCanvas.toDataURL('image/png');
}
return thumbs;
} }

View File

@@ -8,6 +8,12 @@ export interface PdfFile {
arrayBuffer: ArrayBuffer; arrayBuffer: ArrayBuffer;
} }
export interface PageRef {
id: string;
sourcePageIndex: number;
rotation: number;
}
export interface SplitResult { export interface SplitResult {
pageIndex: number; pageIndex: number;
blob: Blob; blob: Blob;

View File

@@ -120,12 +120,6 @@ button.secondary {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.page-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.app-root { .app-root {
min-height: 100vh; min-height: 100vh;
background-color: #f3f4f6; background-color: #f3f4f6;
@@ -175,20 +169,6 @@ button.secondary {
width: 100%; width: 100%;
} }
/* Slightly less rounded page pills so they look like rectangles */
.page-pill {
padding: 0.2rem 0.5rem;
border-radius: 0.5rem; /* was 999px */
border: 1px solid #e5e7eb;
font-size: 0.8rem;
background: #f9fafb;
}
.page-pill.selected {
background: #dbeafe;
border-color: #93c5fd;
}
.download-link { .download-link {
display: inline-block; display: inline-block;
margin: 0.15rem 0; margin: 0.15rem 0;