Roadmap, robust page refs, copy behaviour
This commit is contained in:
77
README
77
README
@@ -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
|
||||||
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 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[]>([]);
|
||||||
|
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([]);
|
setSplitResults([]);
|
||||||
setSelectedPages([]);
|
setSelectedPageIds([]);
|
||||||
setLastSelectedVisualIndex(null);
|
setLastSelectedVisualIndex(null);
|
||||||
setSubsetUrl(null);
|
resetGeneratedUrls();
|
||||||
setSubsetFilename(null);
|
setReorderThumbnails({});
|
||||||
setExportUrl(null);
|
thumbnailCacheRef.current.clear();
|
||||||
setExportFilename(null);
|
previousPageRotationsRef.current.clear();
|
||||||
setBaseThumbnails(null);
|
latestPagesRef.current = [];
|
||||||
setReorderThumbnails(null);
|
setPages([]);
|
||||||
setRotations({});
|
setPreviewPageId(null);
|
||||||
setOrder([]);
|
};
|
||||||
setPreviewIndex(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)])
|
||||||
} catch (e) {
|
);
|
||||||
console.error(e);
|
|
||||||
if (!cancelled) {
|
if (changedPages.length === 0) return;
|
||||||
setError('Failed to generate rotated thumbnails (see console).');
|
|
||||||
}
|
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 () => {
|
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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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) => {
|
||||||
style={{
|
e.stopPropagation();
|
||||||
border: 'none',
|
onPrevious();
|
||||||
borderRadius: '999px',
|
}}
|
||||||
padding: '0.25rem 0.5rem',
|
disabled={!canGoPrevious}
|
||||||
fontSize: '0.8rem',
|
style={{
|
||||||
background: '#374151',
|
position: 'absolute',
|
||||||
color: '#e5e7eb',
|
left: 0,
|
||||||
cursor: 'pointer',
|
top: '50%',
|
||||||
}}
|
transform: 'translate(-50%, -50%)',
|
||||||
>
|
width: '2.5rem',
|
||||||
✕ Close
|
height: '2.5rem',
|
||||||
</button>
|
borderRadius: '999px',
|
||||||
</div>
|
border: 'none',
|
||||||
|
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',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
title="Close preview (Esc)"
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,115 +19,190 @@ 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);
|
||||||
const viewport = page.getViewport({ scale: 1 });
|
if (signal?.aborted) return;
|
||||||
const scale = maxHeight / viewport.height;
|
|
||||||
const scaledViewport = page.getViewport({ scale });
|
|
||||||
|
|
||||||
// First render unrotated page into a canvas
|
const pageIndex = pageNum - 1;
|
||||||
const baseCanvas = document.createElement('canvas');
|
const dataUrl = await renderPageThumbnail(
|
||||||
const baseCtx = baseCanvas.getContext('2d');
|
page,
|
||||||
if (!baseCtx) {
|
pageIndex,
|
||||||
thumbs.push('');
|
rotations,
|
||||||
continue;
|
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());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
baseCanvas.width = scaledViewport.width;
|
const workerCount = Math.min(concurrency, pageNums.length);
|
||||||
baseCanvas.height = scaledViewport.height;
|
if (workerCount === 0) return thumbs;
|
||||||
|
|
||||||
const renderTask = page.render({
|
await Promise.all(Array.from({ length: workerCount }, worker));
|
||||||
canvasContext: baseCtx,
|
|
||||||
viewport: scaledViewport,
|
|
||||||
});
|
|
||||||
await renderTask.promise;
|
|
||||||
|
|
||||||
const originalIndex = pageNum - 1;
|
|
||||||
const rotationDegRaw = rotations[originalIndex] ?? 0;
|
|
||||||
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; // normalize 0–359
|
|
||||||
|
|
||||||
if (rotationDeg === 0) {
|
|
||||||
thumbs.push(baseCanvas.toDataURL('image/png'));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render onto a second canvas with rotation applied
|
|
||||||
const rotatedCanvas = document.createElement('canvas');
|
|
||||||
const rotatedCtx = rotatedCanvas.getContext('2d');
|
|
||||||
if (!rotatedCtx) {
|
|
||||||
thumbs.push(baseCanvas.toDataURL('image/png'));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rad = (rotationDeg * Math.PI) / 180;
|
|
||||||
|
|
||||||
if (rotationDeg === 90 || rotationDeg === 270) {
|
|
||||||
rotatedCanvas.width = baseCanvas.height;
|
|
||||||
rotatedCanvas.height = baseCanvas.width;
|
|
||||||
} else {
|
|
||||||
rotatedCanvas.width = baseCanvas.width;
|
|
||||||
rotatedCanvas.height = baseCanvas.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
rotatedCtx.save();
|
|
||||||
|
|
||||||
switch (rotationDeg) {
|
|
||||||
case 90:
|
|
||||||
rotatedCtx.translate(rotatedCanvas.width, 0);
|
|
||||||
rotatedCtx.rotate(rad);
|
|
||||||
break;
|
|
||||||
case 180:
|
|
||||||
rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height);
|
|
||||||
rotatedCtx.rotate(rad);
|
|
||||||
break;
|
|
||||||
case 270:
|
|
||||||
rotatedCtx.translate(0, rotatedCanvas.height);
|
|
||||||
rotatedCtx.rotate(rad);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// fallback: no rotation
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
|
||||||
rotatedCtx.restore();
|
|
||||||
|
|
||||||
thumbs.push(rotatedCanvas.toDataURL('image/png'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbs;
|
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 scaleH = maxHeight / viewport.height;
|
||||||
|
const scaleW = maxWidth / viewport.width;
|
||||||
|
const scale = Math.min(scaleH, scaleW);
|
||||||
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const baseCanvas = document.createElement('canvas');
|
||||||
|
const baseCtx = baseCanvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!baseCtx) return '';
|
||||||
|
|
||||||
|
baseCanvas.width = scaledViewport.width;
|
||||||
|
baseCanvas.height = scaledViewport.height;
|
||||||
|
|
||||||
|
const renderTask = page.render({
|
||||||
|
canvasContext: baseCtx,
|
||||||
|
viewport: scaledViewport,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderTask.promise;
|
||||||
|
|
||||||
|
const rotationDegRaw = rotations[originalIndex] ?? 0;
|
||||||
|
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
|
||||||
|
|
||||||
|
if (rotationDeg === 0) {
|
||||||
|
return baseCanvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedCanvas = document.createElement('canvas');
|
||||||
|
const rotatedCtx = rotatedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!rotatedCtx) {
|
||||||
|
return baseCanvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rad = (rotationDeg * Math.PI) / 180;
|
||||||
|
|
||||||
|
if (rotationDeg === 90 || rotationDeg === 270) {
|
||||||
|
rotatedCanvas.width = baseCanvas.height;
|
||||||
|
rotatedCanvas.height = baseCanvas.width;
|
||||||
|
} else {
|
||||||
|
rotatedCanvas.width = baseCanvas.width;
|
||||||
|
rotatedCanvas.height = baseCanvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedCtx.save();
|
||||||
|
|
||||||
|
switch (rotationDeg) {
|
||||||
|
case 90:
|
||||||
|
rotatedCtx.translate(rotatedCanvas.width, 0);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
rotatedCtx.translate(0, rotatedCanvas.height);
|
||||||
|
rotatedCtx.rotate(rad);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedCtx.drawImage(baseCanvas, 0, 0);
|
||||||
|
rotatedCtx.restore();
|
||||||
|
|
||||||
|
return rotatedCanvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user