feat: add Quick Look page preview and unify thumbnail styles across all tools
This commit is contained in:
@@ -159,7 +159,7 @@ async function renderThumbnails() {
|
||||
|
||||
for (let i = 1; i <= deleteState.totalPages; i++) {
|
||||
const page = await deleteState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.3 });
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from '../utils/render-utils.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import {
|
||||
showWasmRequiredDialog,
|
||||
@@ -210,6 +211,8 @@ async function renderPageMergeThumbnails() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
initPagePreview(container, pdfjsDoc);
|
||||
}
|
||||
|
||||
mergeState.cachedThumbnails = true;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
|
||||
import {
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
downloadFile,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface OrganizeState {
|
||||
file: File | null;
|
||||
@@ -39,8 +48,13 @@ function initializePage() {
|
||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('bg-gray-700'); });
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
@@ -64,7 +78,9 @@ function initializePage() {
|
||||
}
|
||||
|
||||
function applyCustomOrder() {
|
||||
const orderInput = document.getElementById('page-order-input') as HTMLInputElement;
|
||||
const orderInput = document.getElementById(
|
||||
'page-order-input'
|
||||
) as HTMLInputElement;
|
||||
const grid = document.getElementById('page-grid');
|
||||
|
||||
if (!orderInput || !grid) return;
|
||||
@@ -75,24 +91,30 @@ function applyCustomOrder() {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = orderString.split(',').map(s => parseInt(s.trim(), 10));
|
||||
const newOrder = orderString.split(',').map((s) => parseInt(s.trim(), 10));
|
||||
|
||||
// Validation
|
||||
const currentGridCount = grid.children.length;
|
||||
const validNumbers = newOrder.every(n => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
||||
const validNumbers = newOrder.every((n) => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
||||
if (!validNumbers) {
|
||||
showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newOrder.length !== currentGridCount) {
|
||||
showAlert('Incorrect Page Count', `The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`);
|
||||
showAlert(
|
||||
'Incorrect Page Count',
|
||||
`The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueNumbers = new Set(newOrder);
|
||||
if (uniqueNumbers.size !== newOrder.length) {
|
||||
showAlert('Duplicate Page Numbers', 'Please ensure all page numbers in the order are unique.');
|
||||
showAlert(
|
||||
'Duplicate Page Numbers',
|
||||
'Please ensure all page numbers in the order are unique.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,7 +125,8 @@ function applyCustomOrder() {
|
||||
for (const pageNum of newOrder) {
|
||||
const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
|
||||
const foundThumbnail = currentThumbnails.find(
|
||||
thumb => thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
||||
(thumb) =>
|
||||
thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
||||
);
|
||||
|
||||
if (foundThumbnail) {
|
||||
@@ -112,16 +135,24 @@ function applyCustomOrder() {
|
||||
}
|
||||
}
|
||||
|
||||
const allOriginalIndicesPresent = currentThumbnails.every(thumb => foundIndices.has(thumb.dataset.originalPageIndex));
|
||||
const allOriginalIndicesPresent = currentThumbnails.every((thumb) =>
|
||||
foundIndices.has(thumb.dataset.originalPageIndex)
|
||||
);
|
||||
|
||||
if (reorderedThumbnails.length !== currentGridCount || !allOriginalIndicesPresent) {
|
||||
showAlert('Invalid Page Order', 'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.');
|
||||
if (
|
||||
reorderedThumbnails.length !== currentGridCount ||
|
||||
!allOriginalIndicesPresent
|
||||
) {
|
||||
showAlert(
|
||||
'Invalid Page Order',
|
||||
'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the grid and append the reordered thumbnails
|
||||
grid.innerHTML = '';
|
||||
reorderedThumbnails.forEach(thumb => grid.appendChild(thumb));
|
||||
reorderedThumbnails.forEach((thumb) => grid.appendChild(thumb));
|
||||
|
||||
initializeSortable(); // Re-initialize sortable on the new order
|
||||
|
||||
@@ -134,7 +165,10 @@ function handleFileUpload(e: Event) {
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
if (
|
||||
file.type !== 'application/pdf' &&
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
@@ -144,8 +178,13 @@ async function handleFile(file: File) {
|
||||
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
organizeState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise;
|
||||
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
organizeState.pdfJsDoc = await getPDFDocument({
|
||||
data: (arrayBuffer as ArrayBuffer).slice(0),
|
||||
}).promise;
|
||||
organizeState.totalPages = organizeState.pdfDoc.getPageCount();
|
||||
|
||||
updateFileDisplay();
|
||||
@@ -164,7 +203,8 @@ function updateFileDisplay() {
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
@@ -220,7 +260,10 @@ function attachEventListeners(element: HTMLElement) {
|
||||
renumberPages();
|
||||
initializeSortable();
|
||||
} else {
|
||||
showAlert('Cannot Delete', 'You cannot delete the last page of the document.');
|
||||
showAlert(
|
||||
'Cannot Delete',
|
||||
'You cannot delete the last page of the document.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -238,7 +281,7 @@ async function renderThumbnails() {
|
||||
|
||||
for (let i = 1; i <= organizeState.totalPages; i++) {
|
||||
const page = await organizeState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
@@ -247,36 +290,42 @@ async function renderThumbnails() {
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'page-thumbnail relative cursor-move flex flex-col items-center gap-2';
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||
wrapper.dataset.originalPageIndex = (i - 1).toString();
|
||||
wrapper.dataset.pageNumber = i.toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
const pageLabel = document.createElement('span');
|
||||
pageLabel.className = 'page-number absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
||||
const pageLabel = document.createElement('div');
|
||||
pageLabel.className =
|
||||
'page-number absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||
pageLabel.textContent = i.toString();
|
||||
imgContainer.appendChild(pageLabel);
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex items-center justify-center gap-4';
|
||||
|
||||
const duplicateBtn = document.createElement('button');
|
||||
duplicateBtn.className = 'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
duplicateBtn.className =
|
||||
'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
duplicateBtn.title = 'Duplicate Page';
|
||||
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
deleteBtn.className =
|
||||
'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
deleteBtn.title = 'Delete Page';
|
||||
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
|
||||
|
||||
controlsDiv.append(duplicateBtn, deleteBtn);
|
||||
wrapper.append(imgContainer, pageLabel, controlsDiv);
|
||||
wrapper.append(imgContainer, controlsDiv);
|
||||
grid.appendChild(wrapper);
|
||||
|
||||
attachEventListeners(wrapper);
|
||||
@@ -284,6 +333,7 @@ async function renderThumbnails() {
|
||||
|
||||
createIcons({ icons });
|
||||
initializeSortable();
|
||||
initPagePreview(grid, organizeState.pdfJsDoc);
|
||||
}
|
||||
|
||||
function initializeSortable() {
|
||||
@@ -317,8 +367,10 @@ async function saveChanges() {
|
||||
|
||||
const finalPageElements = grid.querySelectorAll('.page-thumbnail');
|
||||
const finalIndices = Array.from(finalPageElements)
|
||||
.map(el => parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10))
|
||||
.filter(index => !isNaN(index) && index >= 0);
|
||||
.map((el) =>
|
||||
parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10)
|
||||
)
|
||||
.filter((index) => !isNaN(index) && index >= 0);
|
||||
|
||||
if (finalIndices.length === 0) {
|
||||
showAlert('Error', 'No valid pages to save.');
|
||||
@@ -326,15 +378,23 @@ async function saveChanges() {
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(organizeState.pdfDoc, finalIndices);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
organizeState.pdfDoc,
|
||||
finalIndices
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_organized.pdf`);
|
||||
downloadFile(
|
||||
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
`${baseName}_organized.pdf`
|
||||
);
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF organized successfully!', 'success', () => resetState());
|
||||
showAlert('Success', 'PDF organized successfully!', 'success', () =>
|
||||
resetState()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
hideLoader();
|
||||
|
||||
@@ -39,9 +39,13 @@ function resetState() {
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const previewArea = document.getElementById('booklet-preview');
|
||||
if (previewArea) previewArea.innerHTML = '<p class="text-gray-400 text-center py-8">Upload a PDF and click "Generate Preview" to see the booklet layout</p>';
|
||||
if (previewArea)
|
||||
previewArea.innerHTML =
|
||||
'<p class="text-gray-400 text-center py-8">Upload a PDF and click "Generate Preview" to see the booklet layout</p>';
|
||||
|
||||
const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement;
|
||||
const downloadBtn = document.getElementById(
|
||||
'download-btn'
|
||||
) as HTMLButtonElement;
|
||||
if (downloadBtn) downloadBtn.disabled = true;
|
||||
}
|
||||
|
||||
@@ -55,7 +59,8 @@ async function updateUI() {
|
||||
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
@@ -88,10 +93,12 @@ async function updateUI() {
|
||||
|
||||
pageState.pdfDoc = await PDFLibDocument.load(pageState.pdfBytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
pageState.pdfjsDoc = await pdfjsLib.getDocument({ data: pageState.pdfBytes.slice() }).promise;
|
||||
pageState.pdfjsDoc = await pdfjsLib.getDocument({
|
||||
data: pageState.pdfBytes.slice(),
|
||||
}).promise;
|
||||
|
||||
hideLoader();
|
||||
|
||||
@@ -100,7 +107,9 @@ async function updateUI() {
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
|
||||
const previewBtn = document.getElementById('preview-btn') as HTMLButtonElement;
|
||||
const previewBtn = document.getElementById(
|
||||
'preview-btn'
|
||||
) as HTMLButtonElement;
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
@@ -114,25 +123,45 @@ async function updateUI() {
|
||||
}
|
||||
|
||||
function getGridDimensions(): { rows: number; cols: number } {
|
||||
const gridMode = (document.querySelector('input[name="grid-mode"]:checked') as HTMLInputElement)?.value || '1x2';
|
||||
const gridMode =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="grid-mode"]:checked'
|
||||
) as HTMLInputElement
|
||||
)?.value || '1x2';
|
||||
switch (gridMode) {
|
||||
case '1x2': return { rows: 1, cols: 2 };
|
||||
case '2x2': return { rows: 2, cols: 2 };
|
||||
case '2x4': return { rows: 2, cols: 4 };
|
||||
case '4x4': return { rows: 4, cols: 4 };
|
||||
default: return { rows: 1, cols: 2 };
|
||||
case '1x2':
|
||||
return { rows: 1, cols: 2 };
|
||||
case '2x2':
|
||||
return { rows: 2, cols: 2 };
|
||||
case '2x4':
|
||||
return { rows: 2, cols: 4 };
|
||||
case '4x4':
|
||||
return { rows: 4, cols: 4 };
|
||||
default:
|
||||
return { rows: 1, cols: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
function getOrientation(isBookletMode: boolean): 'portrait' | 'landscape' {
|
||||
const orientationValue = (document.querySelector('input[name="orientation"]:checked') as HTMLInputElement)?.value || 'auto';
|
||||
const orientationValue =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="orientation"]:checked'
|
||||
) as HTMLInputElement
|
||||
)?.value || 'auto';
|
||||
if (orientationValue === 'portrait') return 'portrait';
|
||||
if (orientationValue === 'landscape') return 'landscape';
|
||||
return isBookletMode ? 'landscape' : 'portrait';
|
||||
}
|
||||
|
||||
function getSheetDimensions(isBookletMode: boolean): { width: number; height: number } {
|
||||
const paperSizeKey = (document.getElementById('paper-size') as HTMLSelectElement).value as keyof typeof PageSizes;
|
||||
function getSheetDimensions(isBookletMode: boolean): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const paperSizeKey = (
|
||||
document.getElementById('paper-size') as HTMLSelectElement
|
||||
).value as keyof typeof PageSizes;
|
||||
const pageDims = PageSizes[paperSizeKey] || PageSizes.Letter;
|
||||
const orientation = getOrientation(isBookletMode);
|
||||
if (orientation === 'landscape') {
|
||||
@@ -161,7 +190,8 @@ async function generatePreview() {
|
||||
numSheets = Math.ceil(totalPages / pagesPerSheet);
|
||||
}
|
||||
|
||||
const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode);
|
||||
const { width: sheetWidth, height: sheetHeight } =
|
||||
getSheetDimensions(isBookletMode);
|
||||
|
||||
// Get container width to make canvas fill it
|
||||
const previewContainer = document.getElementById('booklet-preview')!;
|
||||
@@ -170,13 +200,21 @@ async function generatePreview() {
|
||||
const canvasWidth = containerWidth;
|
||||
const canvasHeight = containerWidth / aspectRatio;
|
||||
|
||||
previewArea.innerHTML = '<p class="text-gray-400 text-center py-4">Generating preview...</p>';
|
||||
previewArea.innerHTML =
|
||||
'<p class="text-gray-400 text-center py-4">Generating preview...</p>';
|
||||
|
||||
const totalRounded = isBookletMode ? Math.ceil(totalPages / 4) * 4 : totalPages;
|
||||
const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none';
|
||||
const totalRounded = isBookletMode
|
||||
? Math.ceil(totalPages / 4) * 4
|
||||
: totalPages;
|
||||
const rotationMode =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="rotation"]:checked'
|
||||
) as HTMLInputElement
|
||||
)?.value || 'none';
|
||||
|
||||
const pageThumbnails: Map<number, ImageBitmap> = new Map();
|
||||
const thumbnailScale = 0.3;
|
||||
const thumbnailScale = 1;
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
try {
|
||||
@@ -247,9 +285,15 @@ async function generatePreview() {
|
||||
const physicalSheet = Math.floor(sheetIndex / 2);
|
||||
const isFrontSide = sheetIndex % 2 === 0;
|
||||
if (isFrontSide) {
|
||||
pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1;
|
||||
pageNumber =
|
||||
c === 0
|
||||
? totalRounded - 2 * physicalSheet
|
||||
: 2 * physicalSheet + 1;
|
||||
} else {
|
||||
pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1;
|
||||
pageNumber =
|
||||
c === 0
|
||||
? 2 * physicalSheet + 2
|
||||
: totalRounded - 2 * physicalSheet - 1;
|
||||
}
|
||||
} else {
|
||||
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
|
||||
@@ -268,12 +312,16 @@ async function generatePreview() {
|
||||
let rotation = 0;
|
||||
if (rotationMode === '90cw') rotation = 90;
|
||||
else if (rotationMode === '90ccw') rotation = -90;
|
||||
else if (rotationMode === 'alternate') rotation = (pageNumber % 2 === 1) ? 90 : -90;
|
||||
else if (rotationMode === 'alternate')
|
||||
rotation = pageNumber % 2 === 1 ? 90 : -90;
|
||||
|
||||
const isRotated = rotation !== 0;
|
||||
const srcWidth = isRotated ? thumbnail.height : thumbnail.width;
|
||||
const srcHeight = isRotated ? thumbnail.width : thumbnail.height;
|
||||
const scale = Math.min(slotWidth / srcWidth, slotHeight / srcHeight);
|
||||
const scale = Math.min(
|
||||
slotWidth / srcWidth,
|
||||
slotHeight / srcHeight
|
||||
);
|
||||
const drawWidth = srcWidth * scale;
|
||||
const drawHeight = srcHeight * scale;
|
||||
const drawX = x + (slotWidth - drawWidth) / 2;
|
||||
@@ -285,7 +333,13 @@ async function generatePreview() {
|
||||
const centerY = drawY + drawHeight / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.drawImage(thumbnail, -drawHeight / 2, -drawWidth / 2, drawHeight, drawWidth);
|
||||
ctx.drawImage(
|
||||
thumbnail,
|
||||
-drawHeight / 2,
|
||||
-drawWidth / 2,
|
||||
drawHeight,
|
||||
drawWidth
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(thumbnail, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
@@ -298,7 +352,11 @@ async function generatePreview() {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${pageNumber}`, x + slotWidth / 2, y + slotHeight - 4);
|
||||
ctx.fillText(
|
||||
`${pageNumber}`,
|
||||
x + slotWidth / 2,
|
||||
y + slotHeight - 4
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#374151';
|
||||
@@ -321,26 +379,39 @@ async function generatePreview() {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : '';
|
||||
ctx.fillText(`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`, canvasWidth - 6, 4);
|
||||
ctx.fillText(
|
||||
`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`,
|
||||
canvasWidth - 6,
|
||||
4
|
||||
);
|
||||
|
||||
previewArea.appendChild(canvas);
|
||||
}
|
||||
|
||||
pageThumbnails.forEach(bitmap => bitmap.close());
|
||||
pageThumbnails.forEach((bitmap) => bitmap.close());
|
||||
|
||||
const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement;
|
||||
const downloadBtn = document.getElementById(
|
||||
'download-btn'
|
||||
) as HTMLButtonElement;
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
|
||||
function applyRotation(doc: PDFLibDocument, mode: string) {
|
||||
const pages = doc.getPages();
|
||||
pages.forEach((page, index) => {
|
||||
let rotation = 0;
|
||||
let rotation: number;
|
||||
switch (mode) {
|
||||
case '90cw': rotation = 90; break;
|
||||
case '90ccw': rotation = -90; break;
|
||||
case 'alternate': rotation = (index % 2 === 0) ? 90 : -90; break;
|
||||
default: rotation = 0;
|
||||
case '90cw':
|
||||
rotation = 90;
|
||||
break;
|
||||
case '90ccw':
|
||||
rotation = -90;
|
||||
break;
|
||||
case 'alternate':
|
||||
rotation = index % 2 === 0 ? 90 : -90;
|
||||
break;
|
||||
default:
|
||||
rotation = 0;
|
||||
}
|
||||
if (rotation !== 0) {
|
||||
page.setRotation(degrees(page.getRotation().angle + rotation));
|
||||
@@ -358,7 +429,12 @@ async function createBooklet() {
|
||||
|
||||
try {
|
||||
const sourceDoc = await PDFLibDocument.load(pageState.pdfBytes.slice());
|
||||
const rotationMode = (document.querySelector('input[name="rotation"]:checked') as HTMLInputElement)?.value || 'none';
|
||||
const rotationMode =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="rotation"]:checked'
|
||||
) as HTMLInputElement
|
||||
)?.value || 'none';
|
||||
applyRotation(sourceDoc, rotationMode);
|
||||
|
||||
const totalPages = sourceDoc.getPageCount();
|
||||
@@ -366,7 +442,8 @@ async function createBooklet() {
|
||||
const pagesPerSheet = rows * cols;
|
||||
const isBookletMode = rows === 1 && cols === 2;
|
||||
|
||||
const { width: sheetWidth, height: sheetHeight } = getSheetDimensions(isBookletMode);
|
||||
const { width: sheetWidth, height: sheetHeight } =
|
||||
getSheetDimensions(isBookletMode);
|
||||
|
||||
const outputDoc = await PDFLibDocument.create();
|
||||
|
||||
@@ -396,27 +473,43 @@ async function createBooklet() {
|
||||
const physicalSheet = Math.floor(sheetIndex / 2);
|
||||
const isFrontSide = sheetIndex % 2 === 0;
|
||||
if (isFrontSide) {
|
||||
pageNumber = c === 0 ? totalRounded - 2 * physicalSheet : 2 * physicalSheet + 1;
|
||||
pageNumber =
|
||||
c === 0
|
||||
? totalRounded - 2 * physicalSheet
|
||||
: 2 * physicalSheet + 1;
|
||||
} else {
|
||||
pageNumber = c === 0 ? 2 * physicalSheet + 2 : totalRounded - 2 * physicalSheet - 1;
|
||||
pageNumber =
|
||||
c === 0
|
||||
? 2 * physicalSheet + 2
|
||||
: totalRounded - 2 * physicalSheet - 1;
|
||||
}
|
||||
} else {
|
||||
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
|
||||
}
|
||||
|
||||
if (pageNumber >= 1 && pageNumber <= totalPages) {
|
||||
const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [pageNumber - 1]);
|
||||
const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [
|
||||
pageNumber - 1,
|
||||
]);
|
||||
const { width: srcW, height: srcH } = embeddedPage;
|
||||
|
||||
const availableWidth = cellWidth - padding * 2;
|
||||
const availableHeight = cellHeight - padding * 2;
|
||||
const scale = Math.min(availableWidth / srcW, availableHeight / srcH);
|
||||
const scale = Math.min(
|
||||
availableWidth / srcW,
|
||||
availableHeight / srcH
|
||||
);
|
||||
|
||||
const scaledWidth = srcW * scale;
|
||||
const scaledHeight = srcH * scale;
|
||||
|
||||
const x = c * cellWidth + padding + (availableWidth - scaledWidth) / 2;
|
||||
const y = sheetHeight - (r + 1) * cellHeight + padding + (availableHeight - scaledHeight) / 2;
|
||||
const x =
|
||||
c * cellWidth + padding + (availableWidth - scaledWidth) / 2;
|
||||
const y =
|
||||
sheetHeight -
|
||||
(r + 1) * cellHeight +
|
||||
padding +
|
||||
(availableHeight - scaledHeight) / 2;
|
||||
|
||||
outputPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
@@ -430,16 +523,22 @@ async function createBooklet() {
|
||||
}
|
||||
|
||||
const pdfBytes = await outputDoc.save();
|
||||
const originalName = pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
|
||||
const originalName =
|
||||
pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_booklet.pdf`
|
||||
);
|
||||
|
||||
showAlert('Success', `Booklet created with ${numSheets} sheets!`, 'success', function () {
|
||||
showAlert(
|
||||
'Success',
|
||||
`Booklet created with ${numSheets} sheets!`,
|
||||
'success',
|
||||
function () {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'An error occurred while creating the booklet.');
|
||||
@@ -451,7 +550,10 @@ async function createBooklet() {
|
||||
function handleFileSelect(files: FileList | null) {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
if (
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
@@ -492,7 +594,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
||||
return (
|
||||
f.type === 'application/pdf' ||
|
||||
f.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
});
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -161,7 +162,7 @@ async function isPageBlank(
|
||||
}
|
||||
|
||||
async function generateThumbnail(page: any): Promise<string> {
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
@@ -211,6 +212,10 @@ async function detectBlankPages() {
|
||||
// Show preview panel
|
||||
updatePreviewPanel();
|
||||
document.getElementById('preview-panel')?.classList.remove('hidden');
|
||||
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
if (previewContainer) initPagePreview(previewContainer, pdfDoc);
|
||||
|
||||
hideLoader();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -231,17 +236,18 @@ function updatePreviewPanel() {
|
||||
pageState.detectedBlankPages.forEach((pageIndex) => {
|
||||
const thumbnail = pageState.pageThumbnails.get(pageIndex) || '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative cursor-pointer group';
|
||||
div.className =
|
||||
'relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-red-500 rounded-lg bg-gray-700 transition-colors group';
|
||||
div.dataset.pageIndex = String(pageIndex);
|
||||
div.dataset.selected = 'true';
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="relative border-2 border-red-500 rounded-lg overflow-hidden transition-all">
|
||||
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="w-full h-auto">
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs text-center py-1">
|
||||
Page ${pageIndex + 1}
|
||||
<div class="relative">
|
||||
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="rounded-md shadow-md max-w-full h-auto">
|
||||
<div class="absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none">
|
||||
${pageIndex + 1}
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark">
|
||||
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark z-10">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,18 +262,17 @@ function updatePreviewPanel() {
|
||||
|
||||
function togglePageSelection(div: HTMLElement, pageIndex: number) {
|
||||
const isSelected = div.dataset.selected === 'true';
|
||||
const border = div.querySelector('.border-2') as HTMLElement;
|
||||
const checkMark = div.querySelector('.check-mark') as HTMLElement;
|
||||
|
||||
if (isSelected) {
|
||||
div.dataset.selected = 'false';
|
||||
border?.classList.remove('border-red-500');
|
||||
border?.classList.add('border-gray-500', 'opacity-50');
|
||||
div.classList.remove('border-red-500');
|
||||
div.classList.add('border-gray-600', 'opacity-50');
|
||||
checkMark?.classList.add('hidden');
|
||||
} else {
|
||||
div.dataset.selected = 'true';
|
||||
border?.classList.add('border-red-500');
|
||||
border?.classList.remove('border-gray-500', 'opacity-50');
|
||||
div.classList.add('border-red-500');
|
||||
div.classList.remove('border-gray-600', 'opacity-50');
|
||||
checkMark?.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from '../utils/render-utils.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||
import JSZip from 'jszip';
|
||||
@@ -153,18 +154,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className =
|
||||
'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative';
|
||||
'page-thumbnail-wrapper p-2 border-2 border-gray-600 rounded-lg cursor-pointer hover:border-indigo-500 bg-gray-700 transition-colors relative group flex flex-col items-center gap-1';
|
||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md w-full h-auto';
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-center text-xs mt-1 text-gray-300';
|
||||
p.textContent = `Page ${pageNumber}`;
|
||||
const pageNumDiv = document.createElement('div');
|
||||
pageNumDiv.className =
|
||||
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||
pageNumDiv.textContent = pageNumber.toString();
|
||||
|
||||
wrapper.append(img, p);
|
||||
imgContainer.append(img, pageNumDiv);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const handleSelection = (e: any) => {
|
||||
e.preventDefault();
|
||||
@@ -174,10 +181,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (isSelected) {
|
||||
wrapper.classList.remove('selected', 'border-indigo-500');
|
||||
wrapper.classList.add('border-transparent');
|
||||
wrapper.classList.add('border-gray-600');
|
||||
} else {
|
||||
wrapper.classList.add('selected', 'border-indigo-500');
|
||||
wrapper.classList.remove('border-transparent');
|
||||
wrapper.classList.remove('border-gray-600');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,6 +210,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
createIcons({ icons });
|
||||
},
|
||||
});
|
||||
|
||||
initPagePreview(container, pdf);
|
||||
} catch (error) {
|
||||
console.error('Error rendering visual selector:', error);
|
||||
showAlert('Error', 'Failed to render page previews.');
|
||||
|
||||
@@ -50,3 +50,4 @@ export * from './bookmark-pdf-type.ts';
|
||||
export * from './scanner-effect-type.ts';
|
||||
export * from './adjust-colors-type.ts';
|
||||
export * from './bates-numbering-type.ts';
|
||||
export * from './page-preview-type.ts';
|
||||
|
||||
10
src/js/types/page-preview-type.ts
Normal file
10
src/js/types/page-preview-type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
export interface PreviewState {
|
||||
modal: HTMLElement | null;
|
||||
pdfjsDoc: PDFDocumentProxy | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isOpen: boolean;
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
151
src/js/ui.ts
151
src/js/ui.ts
@@ -1,15 +1,24 @@
|
||||
import { resetState } from './state.js';
|
||||
import { formatBytes, getPDFDocument } from './utils/helpers.js';
|
||||
import { tesseractLanguages } from './config/tesseract-languages.js';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from './utils/render-utils.js';
|
||||
import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from './utils/render-utils.js';
|
||||
import { initPagePreview } from './utils/page-preview.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import Sortable from 'sortablejs';
|
||||
import { getRotationState, updateRotationState } from './utils/rotation-state.js';
|
||||
import {
|
||||
getRotationState,
|
||||
updateRotationState,
|
||||
} from './utils/rotation-state.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { t } from './i18n/i18n';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
// Centralizing DOM element selection
|
||||
export const dom = {
|
||||
@@ -50,8 +59,12 @@ export const showLoader = (text = t('common.loading'), progress?: number) => {
|
||||
// Add or update progress bar if progress is provided
|
||||
const loaderModal = dom.loaderModal;
|
||||
if (loaderModal) {
|
||||
let progressBar = loaderModal.querySelector('.loader-progress-bar') as HTMLElement;
|
||||
let progressContainer = loaderModal.querySelector('.loader-progress-container') as HTMLElement;
|
||||
let progressBar = loaderModal.querySelector(
|
||||
'.loader-progress-bar'
|
||||
) as HTMLElement;
|
||||
let progressContainer = loaderModal.querySelector(
|
||||
'.loader-progress-container'
|
||||
) as HTMLElement;
|
||||
|
||||
if (progress !== undefined && progress >= 0) {
|
||||
// Create progress container if it doesn't exist
|
||||
@@ -64,15 +77,21 @@ export const showLoader = (text = t('common.loading'), progress?: number) => {
|
||||
</div>
|
||||
<p class="loader-progress-text text-xs text-gray-400 mt-1 text-center">0%</p>
|
||||
`;
|
||||
loaderModal.querySelector('.bg-gray-800')?.appendChild(progressContainer);
|
||||
progressBar = progressContainer.querySelector('.loader-progress-bar') as HTMLElement;
|
||||
loaderModal
|
||||
.querySelector('.bg-gray-800')
|
||||
?.appendChild(progressContainer);
|
||||
progressBar = progressContainer.querySelector(
|
||||
'.loader-progress-bar'
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
const progressText = progressContainer.querySelector('.loader-progress-text');
|
||||
const progressText = progressContainer.querySelector(
|
||||
'.loader-progress-text'
|
||||
);
|
||||
if (progressText) {
|
||||
progressText.textContent = `${Math.round(progress)}%`;
|
||||
}
|
||||
@@ -92,7 +111,12 @@ export const hideLoader = () => {
|
||||
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
||||
};
|
||||
|
||||
export const showAlert = (title: any, message: any, type: string = 'error', callback?: () => void) => {
|
||||
export const showAlert = (
|
||||
title: any,
|
||||
message: any,
|
||||
type: string = 'error',
|
||||
callback?: () => void
|
||||
) => {
|
||||
if (dom.alertTitle) dom.alertTitle.textContent = title;
|
||||
if (dom.alertMessage) dom.alertMessage.textContent = message;
|
||||
if (dom.alertModal) dom.alertModal.classList.remove('hidden');
|
||||
@@ -180,7 +204,12 @@ function initializeOrganizeSortable(containerId: any) {
|
||||
* @param {object} pdfDoc The loaded pdf-lib document instance.
|
||||
*/
|
||||
export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
const containerId = toolId === 'organize' ? 'page-organizer' : toolId === 'delete-pages' ? 'delete-pages-preview' : 'page-rotator';
|
||||
const containerId =
|
||||
toolId === 'organize'
|
||||
? 'page-organizer'
|
||||
: toolId === 'delete-pages'
|
||||
? 'delete-pages-preview'
|
||||
: 'page-rotator';
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
@@ -204,27 +233,29 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
wrapper.dataset.pageIndex = pageNumber - 1;
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className =
|
||||
'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
if (toolId === 'organize') {
|
||||
wrapper.className = 'page-thumbnail relative group';
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const pageNumSpan = document.createElement('span');
|
||||
const pageNumSpan = document.createElement('div');
|
||||
pageNumSpan.className =
|
||||
'absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
||||
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
|
||||
if (toolId === 'organize') {
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className =
|
||||
'delete-page-btn absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center';
|
||||
'delete-page-btn absolute top-1 right-1 bg-red-600 hover:bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center z-10';
|
||||
deleteBtn.innerHTML = '×';
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
(e.currentTarget as HTMLElement).parentElement.remove();
|
||||
@@ -232,7 +263,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
// Renumber remaining pages
|
||||
const pages = container.querySelectorAll('.page-thumbnail');
|
||||
pages.forEach((page, index) => {
|
||||
const numSpan = page.querySelector('span');
|
||||
const numSpan = page.querySelector('.bg-indigo-600');
|
||||
if (numSpan) {
|
||||
numSpan.textContent = (index + 1).toString();
|
||||
}
|
||||
@@ -241,9 +272,10 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
initializeOrganizeSortable(containerId);
|
||||
});
|
||||
|
||||
wrapper.append(pageNumSpan, deleteBtn);
|
||||
wrapper.appendChild(deleteBtn);
|
||||
} else if (toolId === 'rotate') {
|
||||
wrapper.className = 'page-rotator-item flex flex-col items-center gap-2 relative group';
|
||||
wrapper.className =
|
||||
'page-rotator-item flex flex-col items-center gap-2 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors relative group';
|
||||
|
||||
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
||||
const rotationStateArray = getRotationState();
|
||||
@@ -258,34 +290,33 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
img.style.transform = `rotate(${initialRotation}deg)`;
|
||||
}
|
||||
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
// Page Number Overlay (Top Left)
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||
controlsDiv.className =
|
||||
'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||
|
||||
// Custom Stepper Component
|
||||
const stepperContainer = document.createElement('div');
|
||||
stepperContainer.className = 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||
stepperContainer.className =
|
||||
'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||
|
||||
const decrementBtn = document.createElement('button');
|
||||
decrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||
decrementBtn.className =
|
||||
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
||||
|
||||
const angleInput = document.createElement('input');
|
||||
angleInput.type = 'number';
|
||||
angleInput.className = 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||
angleInput.className =
|
||||
'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||
angleInput.value = initialRotation.toString();
|
||||
angleInput.placeholder = "0";
|
||||
angleInput.placeholder = '0';
|
||||
|
||||
const incrementBtn = document.createElement('button');
|
||||
incrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||
incrementBtn.className =
|
||||
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
||||
|
||||
// Helper to update rotation
|
||||
@@ -326,7 +357,8 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
||||
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className = 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||
rotateBtn.className =
|
||||
'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||
rotateBtn.title = 'Rotate +90°';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
||||
rotateBtn.addEventListener('click', (e) => {
|
||||
@@ -338,30 +370,28 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
controlsDiv.append(stepperContainer, rotateBtn);
|
||||
wrapper.appendChild(controlsDiv);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
wrapper.className = 'page-thumbnail relative group cursor-pointer transition-all duration-200';
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const innerContainer = document.createElement('div');
|
||||
innerContainer.className = 'relative w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600 transition-colors duration-200';
|
||||
innerContainer.appendChild(img);
|
||||
wrapper.appendChild(innerContainer);
|
||||
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
wrapper.addEventListener('click', () => {
|
||||
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
const input = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
if (!input) return;
|
||||
|
||||
const currentVal = input.value;
|
||||
let pages = currentVal.split(',').map(s => s.trim()).filter(s => s);
|
||||
let pages = currentVal
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
const pageStr = pageNumber.toString();
|
||||
|
||||
if (pages.includes(pageStr)) {
|
||||
pages = pages.filter(p => p !== pageStr);
|
||||
pages = pages.filter((p) => p !== pageStr);
|
||||
} else {
|
||||
pages.push(pageStr);
|
||||
}
|
||||
@@ -383,11 +413,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
|
||||
try {
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdf,
|
||||
container,
|
||||
createWrapper,
|
||||
{
|
||||
await renderPagesProgressively(pdf, container, createWrapper, {
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
@@ -399,9 +425,8 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
},
|
||||
shouldCancel: () => {
|
||||
return container.dataset.renderId !== currentRenderId.toString();
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (toolId === 'organize') {
|
||||
initializeOrganizeSortable(containerId);
|
||||
@@ -411,6 +436,9 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
|
||||
// Reinitialize lucide icons for dynamically added elements
|
||||
createIcons({ icons });
|
||||
|
||||
// Attach Quick Look page preview
|
||||
initPagePreview(container, pdf);
|
||||
} catch (error) {
|
||||
console.error('Error rendering page thumbnails:', error);
|
||||
showAlert(t('multiTool.error'), t('multiTool.errorRendering'));
|
||||
@@ -465,7 +493,8 @@ const createFileInputHTML = (options = {}) => {
|
||||
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" ${multiple} accept="${acceptedFiles}">
|
||||
</div>
|
||||
|
||||
${showControls
|
||||
${
|
||||
showControls
|
||||
? `
|
||||
<!-- NEW: Add control buttons for multi-file uploads -->
|
||||
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||
|
||||
215
src/js/utils/page-preview.ts
Normal file
215
src/js/utils/page-preview.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { PreviewState } from '@/types';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const state: PreviewState = {
|
||||
modal: null,
|
||||
pdfjsDoc: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
isOpen: false,
|
||||
container: null,
|
||||
};
|
||||
|
||||
function getOrCreateModal(): HTMLElement {
|
||||
if (state.modal) return state.modal;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'page-preview-modal';
|
||||
modal.className =
|
||||
'fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-200';
|
||||
modal.innerHTML = `
|
||||
<button id="preview-close" class="absolute top-4 right-4 text-white/70 hover:text-white z-10 transition-colors" title="Close (Esc)">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
<button id="preview-prev" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Previous page">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<button id="preview-next" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Next page">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
<div id="preview-canvas-container" class="flex items-center justify-center max-w-[90vw] max-h-[85vh]">
|
||||
<div id="preview-loading" class="text-white/60 text-sm">Loading...</div>
|
||||
</div>
|
||||
<div id="preview-page-info" class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-gray-900/80 text-white text-sm px-4 py-2 rounded-full backdrop-blur-sm"></div>
|
||||
`;
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) hidePreview();
|
||||
});
|
||||
modal.querySelector('#preview-close')!.addEventListener('click', hidePreview);
|
||||
modal
|
||||
.querySelector('#preview-prev')!
|
||||
.addEventListener('click', () => navigatePage(-1));
|
||||
modal
|
||||
.querySelector('#preview-next')!
|
||||
.addEventListener('click', () => navigatePage(1));
|
||||
|
||||
document.body.appendChild(modal);
|
||||
state.modal = modal;
|
||||
return modal;
|
||||
}
|
||||
|
||||
async function renderPreviewPage(pageNumber: number): Promise<void> {
|
||||
if (!state.pdfjsDoc) return;
|
||||
|
||||
const modal = getOrCreateModal();
|
||||
const container = modal.querySelector(
|
||||
'#preview-canvas-container'
|
||||
) as HTMLElement;
|
||||
const pageInfo = modal.querySelector('#preview-page-info') as HTMLElement;
|
||||
const prevBtn = modal.querySelector('#preview-prev') as HTMLElement;
|
||||
const nextBtn = modal.querySelector('#preview-next') as HTMLElement;
|
||||
|
||||
container.innerHTML = '<div class="text-white/60 text-sm">Loading...</div>';
|
||||
|
||||
pageInfo.textContent = `Page ${pageNumber} of ${state.totalPages}`;
|
||||
prevBtn.style.visibility = pageNumber > 1 ? 'visible' : 'hidden';
|
||||
nextBtn.style.visibility =
|
||||
pageNumber < state.totalPages ? 'visible' : 'hidden';
|
||||
|
||||
try {
|
||||
const page = await state.pdfjsDoc.getPage(pageNumber);
|
||||
const scale = 2.0;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.className =
|
||||
'max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl';
|
||||
canvas.style.width = 'auto';
|
||||
canvas.style.height = 'auto';
|
||||
canvas.style.maxWidth = '90vw';
|
||||
canvas.style.maxHeight = '85vh';
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(canvas);
|
||||
state.currentPage = pageNumber;
|
||||
} catch (err) {
|
||||
console.error('Preview render error:', err);
|
||||
container.innerHTML =
|
||||
'<div class="text-red-400 text-sm">Failed to render page</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function navigatePage(delta: number): void {
|
||||
const newPage = state.currentPage + delta;
|
||||
if (newPage >= 1 && newPage <= state.totalPages) {
|
||||
renderPreviewPage(newPage);
|
||||
}
|
||||
}
|
||||
|
||||
export function showPreview(
|
||||
pdfjsDoc: PDFDocumentProxy,
|
||||
pageNumber: number,
|
||||
totalPages: number
|
||||
): void {
|
||||
state.pdfjsDoc = pdfjsDoc;
|
||||
state.totalPages = totalPages;
|
||||
state.isOpen = true;
|
||||
|
||||
const modal = getOrCreateModal();
|
||||
modal.classList.remove('opacity-0', 'pointer-events-none');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
renderPreviewPage(pageNumber);
|
||||
}
|
||||
|
||||
export function hidePreview(): void {
|
||||
if (!state.modal) return;
|
||||
state.isOpen = false;
|
||||
state.modal.classList.add('opacity-0', 'pointer-events-none');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (!state.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
hidePreview();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
navigatePage(-1);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
navigatePage(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
export function initPagePreview(
|
||||
container: HTMLElement,
|
||||
pdfjsDoc: PDFDocumentProxy,
|
||||
options: { pageAttr?: string } = {}
|
||||
): void {
|
||||
const totalPages = pdfjsDoc.numPages;
|
||||
|
||||
const thumbnails = container.querySelectorAll<HTMLElement>(
|
||||
'[data-page-number], [data-page-index], [data-pageIndex]'
|
||||
);
|
||||
|
||||
thumbnails.forEach((thumb) => {
|
||||
if (thumb.dataset.previewInit) return;
|
||||
thumb.dataset.previewInit = 'true';
|
||||
|
||||
let pageNum = 1;
|
||||
if (thumb.dataset.pageNumber) {
|
||||
pageNum = parseInt(thumb.dataset.pageNumber, 10);
|
||||
} else if (thumb.dataset.pageIndex !== undefined) {
|
||||
pageNum = parseInt(thumb.dataset.pageIndex, 10) + 1;
|
||||
}
|
||||
|
||||
const icon = document.createElement('button');
|
||||
icon.className =
|
||||
'page-preview-btn absolute bottom-1 right-1 bg-gray-900/80 hover:bg-indigo-600 text-white/70 hover:text-white rounded-full w-7 h-7 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10';
|
||||
icon.title = 'Preview';
|
||||
icon.innerHTML =
|
||||
'<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>';
|
||||
icon.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||
});
|
||||
|
||||
if (!thumb.classList.contains('relative')) {
|
||||
thumb.classList.add('relative');
|
||||
}
|
||||
if (!thumb.classList.contains('group')) {
|
||||
thumb.classList.add('group');
|
||||
}
|
||||
|
||||
thumb.appendChild(icon);
|
||||
});
|
||||
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' && !state.isOpen) {
|
||||
const hovered = container.querySelector<HTMLElement>(
|
||||
'[data-preview-init]:hover'
|
||||
);
|
||||
if (hovered) {
|
||||
e.preventDefault();
|
||||
let pageNum = 1;
|
||||
if (hovered.dataset.pageNumber) {
|
||||
pageNum = parseInt(hovered.dataset.pageNumber, 10);
|
||||
} else if (hovered.dataset.pageIndex !== undefined) {
|
||||
pageNum = parseInt(hovered.dataset.pageIndex, 10) + 1;
|
||||
}
|
||||
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export interface RenderConfig {
|
||||
*/
|
||||
interface PageTask {
|
||||
pageNumber: number;
|
||||
pdfjsDoc: any;
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy;
|
||||
fileName?: string;
|
||||
container: HTMLElement;
|
||||
scale?: number;
|
||||
@@ -92,9 +92,9 @@ export function createPlaceholder(
|
||||
* Renders a single page to canvas
|
||||
*/
|
||||
export async function renderPageToCanvas(
|
||||
pdfjsDoc: any,
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
scale: number = 1
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const page = await pdfjsDoc.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
@@ -260,7 +260,7 @@ function requestIdleCallbackPolyfill(callback: () => void): void {
|
||||
* Main function to render pages progressively with optional lazy loading
|
||||
*/
|
||||
export async function renderPagesProgressively(
|
||||
pdfjsDoc: any,
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||
container: HTMLElement,
|
||||
createWrapper: (
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -298,7 +298,7 @@ export async function renderPagesProgressively(
|
||||
pageNumber: i,
|
||||
pdfjsDoc,
|
||||
container,
|
||||
scale: useLazyLoading ? 0.3 : 0.5,
|
||||
scale: useLazyLoading ? 0.5 : 1,
|
||||
createWrapper,
|
||||
placeholderElement: placeholders[i - 1],
|
||||
});
|
||||
|
||||
@@ -182,24 +182,25 @@
|
||||
></div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<div id="advanced-settings" class="hidden mt-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">
|
||||
Advanced Settings
|
||||
</h3>
|
||||
<div class="bg-gray-700 p-4 rounded-lg">
|
||||
<div id="advanced-settings" class="hidden mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-white">Advanced Settings</h3>
|
||||
<div class="bg-gray-800/50 p-4 rounded-lg border border-gray-700">
|
||||
<label
|
||||
for="page-order-input"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>Page Order (comma-separated)</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
id="page-order-input"
|
||||
class="w-full bg-gray-900 text-white rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 border border-gray-600"
|
||||
class="flex-1 bg-gray-900 text-white rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 border border-gray-600 placeholder-gray-500"
|
||||
placeholder="e.g., 3,1,4,2"
|
||||
/>
|
||||
<button id="apply-order-btn" class="btn-secondary">
|
||||
<button
|
||||
id="apply-order-btn"
|
||||
class="btn-gradient whitespace-nowrap !px-5 !py-2.5 text-sm"
|
||||
>
|
||||
Apply Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user