feat(rotate,delete-pages): Add custom rotation angles and enhance page management UI

- Add TypeScript type definitions for merge and alternate-merge Web Workers
- Implement custom rotation angle input with increment/decrement controls for rotate tool
- Extend delete-pages tool to render page thumbnails for better UX
- Hide number input spin buttons across all number inputs via CSS
- Refactor rotateAll function to accept angle parameter instead of direction multiplier
- Update fileHandler to support delete-pages tool thumbnail rendering
- Improve type safety in alternate-merge logic with proper interface definitions
- Enhance rotate tool UI with custom angle input field and adjustment buttons
This commit is contained in:
abdullahalam123
2025-12-01 14:54:46 +05:30
parent c5764e4172
commit 09436a689d
10 changed files with 397 additions and 109 deletions

View File

@@ -3,9 +3,14 @@ import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/he
import { state } from '../state.js';
import Sortable from 'sortablejs';
const alternateMergeState = {
pdfDocs: {} as Record<string, any>,
pdfBytes: {} as Record<string, ArrayBuffer>,
interface AlternateMergeState {
pdfDocs: Record<string, any>;
pdfBytes: Record<string, ArrayBuffer>;
}
const alternateMergeState: AlternateMergeState = {
pdfDocs: {},
pdfBytes: {},
};
const alternateMergeWorker = new Worker('/workers/alternate-merge.worker.js');
@@ -98,7 +103,7 @@ export async function alternateMerge() {
(li) => (li as HTMLElement).dataset.fileName
).filter(Boolean) as string[];
const filesToMerge: { name: string; data: ArrayBuffer }[] = [];
const filesToMerge: InterleaveFile[] = [];
for (const name of sortedFileNames) {
const bytes = alternateMergeState.pdfBytes[name];
if (bytes) {
@@ -112,12 +117,14 @@ export async function alternateMerge() {
return;
}
alternateMergeWorker.postMessage({
const message: InterleaveMessage = {
command: 'interleave',
files: filesToMerge
}, filesToMerge.map(f => f.data));
};
alternateMergeWorker.onmessage = (e) => {
alternateMergeWorker.postMessage(message, filesToMerge.map(f => f.data));
alternateMergeWorker.onmessage = (e: MessageEvent<InterleaveResponse>) => {
hideLoader();
if (e.data.status === 'success') {
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });

View File

@@ -68,3 +68,44 @@ export async function deletePages() {
hideLoader();
}
}
export function setupDeletePagesTool() {
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
if (!input) return;
const updateHighlights = () => {
const val = input.value;
const pagesToDelete = new Set<number>();
const parts = val.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.includes('-')) {
const [start, end] = trimmed.split('-').map(Number);
if (!isNaN(start) && !isNaN(end) && start <= end) {
for (let i = start; i <= end; i++) pagesToDelete.add(i);
}
} else {
const num = Number(trimmed);
if (!isNaN(num)) pagesToDelete.add(num);
}
}
const thumbnails = document.querySelectorAll('#delete-pages-preview .page-thumbnail');
thumbnails.forEach((thumb) => {
const pageNum = parseInt((thumb as HTMLElement).dataset.pageNumber || '0');
const innerContainer = thumb.querySelector('div.relative');
if (pagesToDelete.has(pageNum)) {
innerContainer?.classList.add('border-red-500');
innerContainer?.classList.remove('border-gray-600');
} else {
innerContainer?.classList.remove('border-red-500');
innerContainer?.classList.add('border-gray-600');
}
});
};
input.addEventListener('input', updateHighlights);
updateHighlights();
}

View File

@@ -18,7 +18,7 @@ import { pdfToPng } from './pdf-to-png.js';
import { pngToPdf } from './png-to-pdf.js';
import { pdfToWebp } from './pdf-to-webp.js';
import { webpToPdf } from './webp-to-pdf.js';
import { deletePages } from './delete-pages.js';
import { deletePages, setupDeletePagesTool } from './delete-pages.js';
import { addBlankPage } from './add-blank-page.js';
import { extractPages } from './extract-pages.js';
import { addWatermark, setupWatermarkUI } from './add-watermark.js';
@@ -92,7 +92,7 @@ export const toolLogic = {
'png-to-pdf': pngToPdf,
'pdf-to-webp': pdfToWebp,
'webp-to-pdf': webpToPdf,
'delete-pages': deletePages,
'delete-pages': { process: deletePages, setup: setupDeletePagesTool },
'add-blank-page': addBlankPage,
'extract-pages': extractPages,
'add-watermark': { process: addWatermark, setup: setupWatermarkUI },

View File

@@ -1,7 +1,7 @@
import { showLoader, hideLoader, showAlert } from '../ui.ts';
import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.ts';
import { state } from '../state.ts';
import { renderPagesProgressively, cleanupLazyRendering, createPlaceholder } from '../utils/render-utils.ts';
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.ts';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
@@ -9,9 +9,22 @@ import Sortable from 'sortablejs';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const mergeState = {
pdfDocs: {} as Record<string, any>,
pdfBytes: {} as Record<string, ArrayBuffer>,
interface MergeState {
pdfDocs: Record<string, any>;
pdfBytes: Record<string, ArrayBuffer>;
activeMode: 'file' | 'page';
sortableInstances: {
fileList?: Sortable;
pageThumbnails?: Sortable;
};
isRendering: boolean;
cachedThumbnails: boolean | null;
lastFileHash: string | null;
}
const mergeState: MergeState = {
pdfDocs: {},
pdfBytes: {},
activeMode: 'file',
sortableInstances: {},
isRendering: false,
@@ -21,47 +34,14 @@ const mergeState = {
const mergeWorker = new Worker('/workers/merge.worker.js');
function parsePageRanges(rangeString: any, totalPages: any) {
const indices = new Set();
if (!rangeString.trim()) return [];
const ranges = rangeString.split(',');
for (const range of ranges) {
const trimmedRange = range.trim();
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (
isNaN(start) ||
isNaN(end) ||
start < 1 ||
end > totalPages ||
start > end
)
continue;
for (let i = start; i <= end; i++) {
indices.add(i - 1);
}
} else {
const pageNum = Number(trimmedRange);
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
indices.add(pageNum - 1);
}
}
// @ts-expect-error TS(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message
return Array.from(indices).sort((a, b) => a - b);
}
function initializeFileListSortable() {
const fileList = document.getElementById('file-list');
if (!fileList) return;
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
if (mergeState.sortableInstances.fileList) {
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
mergeState.sortableInstances.fileList.destroy();
}
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
mergeState.sortableInstances.fileList = Sortable.create(fileList, {
handle: '.drag-handle',
animation: 150,
@@ -81,13 +61,10 @@ function initializePageThumbnailsSortable() {
const container = document.getElementById('page-merge-preview');
if (!container) return;
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
if (mergeState.sortableInstances.pageThumbnails) {
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
mergeState.sortableInstances.pageThumbnails.destroy();
}
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
animation: 150,
ghostClass: 'sortable-ghost',
@@ -224,8 +201,8 @@ async function renderPageMergeThumbnails() {
export async function merge() {
showLoader('Merging PDFs...');
try {
const jobs: any[] = [];
const filesToMerge: any[] = [];
const jobs: MergeJob[] = [];
const filesToMerge: MergeFile[] = [];
const uniqueFileNames = new Set<string>();
if (mergeState.activeMode === 'file') {
@@ -234,8 +211,7 @@ export async function merge() {
const sortedFiles = Array.from(fileList.children)
.map((li) => {
// @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element...
return state.files.find((f) => f.name === li.dataset.fileName);
return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
})
.filter(Boolean);
@@ -267,10 +243,9 @@ export async function merge() {
const rawPages: { fileName: string; pageIndex: number }[] = [];
for (const el of pageElements) {
// @ts-expect-error TS(2339)
const fileName = el.dataset.fileName;
// @ts-expect-error TS(2339)
const pageIndex = parseInt(el.dataset.pageIndex, 10); // 0-based index from dataset
const element = el as HTMLElement;
const fileName = element.dataset.fileName;
const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
if (fileName && !isNaN(pageIndex)) {
uniqueFileNames.add(fileName);
@@ -304,8 +279,8 @@ export async function merge() {
jobs.push({
fileName: current.fileName,
rangeType: 'range',
startPage: current.pageIndex + 1,
endPage: endPage + 1
startPage: current.pageIndex + 1,
endPage: endPage + 1
});
}
}
@@ -324,13 +299,15 @@ export async function merge() {
}
}
mergeWorker.postMessage({
const message: MergeMessage = {
command: 'merge',
files: filesToMerge,
jobs: jobs
}, filesToMerge.map(f => f.data));
};
mergeWorker.onmessage = (e) => {
mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
mergeWorker.onmessage = (e: MessageEvent<MergeResponse>) => {
hideLoader();
if (e.data.status === 'success') {
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });

View File

@@ -3,25 +3,56 @@ import { downloadFile, resetAndReloadTool } from '../utils/helpers.js';
import { state } from '../state.js';
import { getRotationState, resetRotationState } from '../handlers/fileHandler.js';
import { degrees } from 'pdf-lib';
import { PDFDocument, degrees } from 'pdf-lib';
export async function rotate() {
showLoader('Applying rotations...');
try {
const pages = state.pdfDoc.getPages();
const originalPdf = state.pdfDoc;
const pageCount = originalPdf.getPageCount();
const rotationStateArray = getRotationState();
// Apply rotations from state (not DOM) to ensure all pages including lazy-loaded ones are rotated
rotationStateArray.forEach((rotation, pageIndex) => {
if (rotation !== 0 && pages[pageIndex]) {
const currentRotation = pages[pageIndex].getRotation().angle;
pages[pageIndex].setRotation(degrees(currentRotation + rotation));
}
});
const newPdfDoc = await PDFDocument.create();
const rotatedPdfBytes = await state.pdfDoc.save();
for (let i = 0; i < pageCount; i++) {
const rotation = rotationStateArray[i] || 0;
const originalPage = originalPdf.getPage(i);
const currentRotation = originalPage.getRotation().angle;
const totalRotation = currentRotation + rotation;
if (totalRotation % 90 === 0) {
const [copiedPage] = await newPdfDoc.copyPages(originalPdf, [i]);
copiedPage.setRotation(degrees(totalRotation));
newPdfDoc.addPage(copiedPage);
} else {
const embeddedPage = await newPdfDoc.embedPage(originalPage);
const { width, height } = embeddedPage.scale(1);
const angleRad = (totalRotation * Math.PI) / 180;
const absCos = Math.abs(Math.cos(angleRad));
const absSin = Math.abs(Math.sin(angleRad));
const newWidth = width * absCos + height * absSin;
const newHeight = width * absSin + height * absCos;
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
const x = newWidth / 2 - (width / 2 * Math.cos(angleRad) - height / 2 * Math.sin(angleRad));
const y = newHeight / 2 - (width / 2 * Math.sin(angleRad) + height / 2 * Math.cos(angleRad));
newPage.drawPage(embeddedPage, {
x,
y,
width,
height,
rotate: degrees(totalRotation),
});
}
}
const rotatedPdfBytes = await newPdfDoc.save();
downloadFile(
new Blob([rotatedPdfBytes], { type: 'application/pdf' }),
new Blob([rotatedPdfBytes as any], { type: 'application/pdf' }),
'rotated.pdf'
);