From 3cae20a10c10b434045cc46b82df673081a87fcd Mon Sep 17 00:00:00 2001 From: alam00000 Date: Sat, 21 Feb 2026 14:05:38 +0530 Subject: [PATCH] feat: add Bates numbering tool with PDF processing capabilities - Implemented bates-numbering-page.ts for handling Bates numbering logic. - Created a new HTML page for Bates numbering functionality. - Added style presets and file handling for multiple PDF uploads. - Integrated user interface elements for file selection, style customization, and preview. - Enhanced main.ts to support collapsible categories and compact mode for tool grid. - Updated types for Bates numbering in bates-numbering-type.ts. - Registered the new tool in tools.html and updated routing in vite.config.ts. --- README.md | 1 + index.html | 32 ++ public/locales/ar/tools.json | 4 + public/locales/be/tools.json | 4 + public/locales/da/tools.json | 4 + public/locales/de/tools.json | 4 + public/locales/en/tools.json | 4 + public/locales/es/tools.json | 4 + public/locales/fr/tools.json | 4 + public/locales/id/tools.json | 4 + public/locales/it/tools.json | 4 + public/locales/nl/tools.json | 4 + public/locales/pt/tools.json | 4 + public/locales/tr/tools.json | 4 + public/locales/vi/tools.json | 4 + public/locales/zh-TW/tools.json | 4 + public/locales/zh/tools.json | 4 + src/css/styles.css | 136 ++++++- src/js/config/tools.ts | 6 + src/js/logic/bates-numbering-page.ts | 548 +++++++++++++++++++++++++++ src/js/main.ts | 103 ++++- src/js/types/bates-numbering-type.ts | 17 + src/js/types/index.ts | 1 + src/pages/bates-numbering.html | 548 +++++++++++++++++++++++++++ tools.html | 8 +- vite.config.ts | 4 + 26 files changed, 1443 insertions(+), 21 deletions(-) create mode 100644 src/js/logic/bates-numbering-page.ts create mode 100644 src/js/types/bates-numbering-type.ts create mode 100644 src/pages/bates-numbering.html diff --git a/README.md b/README.md index 11a3bec..f74ca79 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. | **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. | | **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. | | **Add Page Numbers** | Easily add page numbers with customizable formatting. | +| **Bates Numbering** | Add sequential Bates numbers across one or more PDF files. | | **Add Watermark** | Add text or image watermarks to protect your documents. | | **Header & Footer** | Add customizable headers and footers. | | **Crop PDF** | Crop specific pages or the entire document. | diff --git a/index.html b/index.html index 9da3b4f..516c13b 100644 --- a/index.html +++ b/index.html @@ -717,6 +717,38 @@ +
+
+ +

+ Display tools as a compact list instead of cards +

+
+ +
+ div { +#embed-pdf-container > div { width: 100%; height: 100%; } @@ -126,7 +226,7 @@ input[type='file']::file-selector-button { } .page-thumbnail, -#file-list>li { +#file-list > li { cursor: grab; } @@ -264,8 +364,6 @@ footer a { z-index: -1; } - - .section-divider { height: 1px; background: linear-gradient(to right, transparent, #4d44f7, transparent); @@ -501,19 +599,19 @@ footer a { color: rgb(165 180 252); } -details>summary { +details > summary { list-style: none; } -details>summary::-webkit-details-marker { +details > summary::-webkit-details-marker { display: none; } -details>summary .icon { +details > summary .icon { transition: transform 0.2s ease-in-out; } -details[open]>summary .icon { +details[open] > summary .icon { transform: rotate(45deg); } @@ -537,7 +635,9 @@ button:disabled, height: 1.5rem; padding: 0 0.25rem; font-size: 0.75rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; line-height: 1; color: #e5e7eb; background-color: #374151; @@ -547,7 +647,9 @@ button:disabled, } .shortcut-input { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; letter-spacing: 0.05em; } @@ -591,18 +693,22 @@ button:disabled, linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); - -webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); + -webkit-mask-image: radial-gradient( + ellipse at center, + black 40%, + transparent 80% + ); pointer-events: none; } /* Hide spin buttons for number inputs */ -input[type="number"] { +input[type='number'] { appearance: none; -moz-appearance: textfield; /* Firefox */ } -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; /* Chrome, Safari, Edge */ margin: 0; -} \ No newline at end of file +} diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index cc676c7..862b2ca 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -108,6 +108,12 @@ export const categories = [ icon: 'ph-list-numbers', subtitle: 'Insert page numbers into your document.', }, + { + href: import.meta.env.BASE_URL + 'bates-numbering.html', + name: 'Bates Numbering', + icon: 'ph-hash', + subtitle: 'Add sequential Bates numbers across one or more PDF files.', + }, { href: import.meta.env.BASE_URL + 'add-watermark.html', name: 'Add Watermark', diff --git a/src/js/logic/bates-numbering-page.ts b/src/js/logic/bates-numbering-page.ts new file mode 100644 index 0000000..fe093ac --- /dev/null +++ b/src/js/logic/bates-numbering-page.ts @@ -0,0 +1,548 @@ +import { createIcons, icons } from 'lucide'; +import { showAlert, showLoader, hideLoader } from '../ui.js'; +import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js'; +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import JSZip from 'jszip'; +import Sortable from 'sortablejs'; +import { FileEntry, Position, StylePreset } from '@/types'; + +const FONT_MAP: Record = { + Helvetica: 'Helvetica', + TimesRoman: 'TimesRoman', + Courier: 'Courier', +}; + +const STYLE_PRESETS: Record = { + 'full-6': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 6, + }, + 'full-5': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 5, + }, + 'full-4': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 4, + }, + 'full-3': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 3, + }, + 'full-0': { + template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]', + padding: 0, + }, + 'no-page-6': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 6 }, + 'no-page-5': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 5 }, + 'no-page-4': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 4 }, + 'no-page-3': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 3 }, + 'no-page-0': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 0 }, + 'case-6': { template: 'Case XYZ [BATES]', padding: 6 }, + 'case-5': { template: 'Case XYZ [BATES]', padding: 5 }, + 'case-4': { template: 'Case XYZ [BATES]', padding: 4 }, + 'case-3': { template: 'Case XYZ [BATES]', padding: 3 }, + 'case-0': { template: 'Case XYZ [BATES]', padding: 0 }, + 'bates-6': { template: '[BATES]', padding: 6 }, + 'bates-5': { template: '[BATES]', padding: 5 }, + 'bates-4': { template: '[BATES]', padding: 4 }, + 'bates-3': { template: '[BATES]', padding: 3 }, + 'bates-0': { template: '[BATES]', padding: 0 }, +}; + +const files: FileEntry[] = []; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePage); +} else { + initializePage(); +} + +function initializePage() { + createIcons({ icons }); + + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const dropZone = document.getElementById('drop-zone'); + const backBtn = document.getElementById('back-to-tools'); + const processBtn = document.getElementById('process-btn'); + const addMoreBtn = document.getElementById('add-more-btn'); + const clearFilesBtn = document.getElementById('clear-files-btn'); + const stylePreset = document.getElementById( + 'style-preset' + ) as HTMLSelectElement; + const templateInput = document.getElementById( + 'bates-template' + ) as HTMLInputElement; + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files?.length) { + handleFiles(fileInput.files); + fileInput.value = ''; + } + }); + } + + if (dropZone) { + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('border-indigo-500'); + }); + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('border-indigo-500'); + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('border-indigo-500'); + if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files); + }); + } + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (processBtn) { + processBtn.addEventListener('click', applyBatesNumbers); + } + + if (addMoreBtn) { + addMoreBtn.addEventListener('click', () => fileInput?.click()); + } + + if (clearFilesBtn) { + clearFilesBtn.addEventListener('click', resetState); + } + + if (stylePreset) { + stylePreset.addEventListener('change', () => { + const value = stylePreset.value; + const isCustom = value === 'custom'; + const paddingGroup = document.getElementById('padding-group'); + if (!isCustom && STYLE_PRESETS[value]) { + templateInput.value = STYLE_PRESETS[value].template; + if (paddingGroup) paddingGroup.classList.add('hidden'); + } else { + if (paddingGroup) paddingGroup.classList.remove('hidden'); + } + templateInput.readOnly = !isCustom; + updatePreview(); + }); + } + + if (templateInput) { + templateInput.addEventListener('input', () => { + const preset = stylePreset; + if (preset && preset.value !== 'custom') { + preset.value = 'custom'; + templateInput.readOnly = false; + document.getElementById('padding-group')?.classList.remove('hidden'); + } + updatePreview(); + }); + } + + document + .getElementById('bates-padding') + ?.addEventListener('change', updatePreview); + document + .getElementById('bates-start') + ?.addEventListener('input', updatePreview); + document + .getElementById('file-start') + ?.addEventListener('input', updatePreview); + + initSortable(); +} + +function initSortable() { + const fileList = document.getElementById('file-list'); + if (!fileList) return; + Sortable.create(fileList, { + handle: '.drag-handle', + animation: 150, + onEnd: (evt) => { + if (evt.oldIndex !== undefined && evt.newIndex !== undefined) { + const [moved] = files.splice(evt.oldIndex, 1); + files.splice(evt.newIndex, 0, moved); + updatePreview(); + } + }, + }); +} + +async function handleFiles(fileList: FileList) { + showLoader('Loading PDFs...'); + try { + for (const file of Array.from(fileList)) { + if (file.type !== 'application/pdf') continue; + const arrayBuffer = await file.arrayBuffer(); + const pdfDoc = await PDFDocument.load(arrayBuffer); + files.push({ file, pageCount: pdfDoc.getPageCount() }); + } + + if (files.length === 0) { + showAlert('Invalid File', 'Please upload valid PDF files.'); + return; + } + + renderFileList(); + document.getElementById('options-panel')?.classList.remove('hidden'); + document.getElementById('file-controls')?.classList.remove('hidden'); + updatePreview(); + } catch (error) { + console.error(error); + showAlert('Error', 'Failed to load one or more PDF files.'); + } finally { + hideLoader(); + } +} + +function renderFileList() { + const fileListEl = document.getElementById('file-list'); + if (!fileListEl) return; + + fileListEl.innerHTML = ''; + let totalPages = 0; + + files.forEach((entry, index) => { + totalPages += entry.pageCount; + + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + + const leftSection = document.createElement('div'); + leftSection.className = 'flex items-center gap-3 flex-1 min-w-0'; + + const dragHandle = document.createElement('i'); + dragHandle.setAttribute('data-lucide', 'grip-vertical'); + dragHandle.className = + 'drag-handle w-4 h-4 text-gray-400 cursor-grab flex-shrink-0'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col min-w-0'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm'; + nameSpan.textContent = entry.file.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`; + + infoContainer.append(nameSpan, metaSpan); + leftSection.append(dragHandle, infoContainer); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + files.splice(index, 1); + renderFileList(); + updatePreview(); + if (files.length === 0) resetState(); + }; + + fileDiv.append(leftSection, removeBtn); + fileListEl.appendChild(fileDiv); + }); + + createIcons({ icons }); + + const summary = document.createElement('div'); + summary.className = 'text-xs text-gray-400 mt-1'; + summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`; + fileListEl.appendChild(summary); +} + +function formatBatesText( + template: string, + batesNum: number, + pageNum: number, + fileNum: number, + fileName: string, + padding: number +): string { + const batesStr = + padding > 0 ? String(batesNum).padStart(padding, '0') : String(batesNum); + return template + .replace(/\[BATES\]/g, batesStr) + .replace(/\[PAGE\]/g, String(pageNum)) + .replace(/\[FILE\]/g, String(fileNum)) + .replace(/\[FILENAME\]/g, fileName); +} + +function getActivePadding(): number { + const presetValue = ( + document.getElementById('style-preset') as HTMLSelectElement + ).value; + if (presetValue !== 'custom' && STYLE_PRESETS[presetValue]) { + return STYLE_PRESETS[presetValue].padding; + } + return ( + parseInt( + (document.getElementById('bates-padding') as HTMLSelectElement).value + ) || 0 + ); +} + +function updatePreview() { + const previewEl = document.getElementById('preview-content'); + if (!previewEl) return; + + const template = ( + document.getElementById('bates-template') as HTMLInputElement + ).value; + const padding = getActivePadding(); + const batesStart = + parseInt( + (document.getElementById('bates-start') as HTMLInputElement).value + ) || 1; + const fileStart = + parseInt( + (document.getElementById('file-start') as HTMLInputElement).value + ) || 1; + + const lines: string[] = []; + + if (files.length === 0) { + lines.push( + formatBatesText(template, batesStart, 1, fileStart, 'document', padding) + ); + lines.push( + formatBatesText( + template, + batesStart + 1, + 2, + fileStart, + 'document', + padding + ) + ); + } else { + let batesCounter = batesStart; + let fileCounter = fileStart; + for (const entry of files) { + const name = entry.file.name.replace(/\.pdf$/i, ''); + lines.push( + `File ${fileCounter}, Page 1: ${formatBatesText(template, batesCounter, 1, fileCounter, name, padding)}` + ); + if (entry.pageCount > 1) { + lines.push( + `File ${fileCounter}, Page 2: ${formatBatesText(template, batesCounter + 1, 2, fileCounter, name, padding)}` + ); + } + batesCounter += entry.pageCount; + fileCounter++; + } + const lastEntry = files[files.length - 1]; + const lastName = lastEntry.file.name.replace(/\.pdf$/i, ''); + const lastBates = batesCounter - 1; + lines.push('...'); + lines.push( + `File ${fileStart + files.length - 1}, Page ${lastEntry.pageCount}: ${formatBatesText(template, lastBates, lastEntry.pageCount, fileStart + files.length - 1, lastName, padding)}` + ); + } + + previewEl.textContent = lines.join('\n'); +} + +function calculatePosition( + pageWidth: number, + pageHeight: number, + xOffset: number, + yOffset: number, + textWidth: number, + fontSize: number, + position: Position +): { x: number; y: number } { + const minMargin = 8; + const maxMargin = 40; + const marginPct = 0.04; + + const hMargin = Math.max( + minMargin, + Math.min(maxMargin, pageWidth * marginPct) + ); + const vMargin = Math.max( + minMargin, + Math.min(maxMargin, pageHeight * marginPct) + ); + const safeH = Math.max(hMargin, textWidth / 2 + 3); + const safeV = Math.max(vMargin, fontSize + 3); + + let x = 0, + y = 0; + + switch (position) { + case 'bottom-center': + x = + Math.max( + safeH, + Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2) + ) + xOffset; + y = safeV + yOffset; + break; + case 'bottom-left': + x = safeH + xOffset; + y = safeV + yOffset; + break; + case 'bottom-right': + x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset; + y = safeV + yOffset; + break; + case 'top-center': + x = + Math.max( + safeH, + Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2) + ) + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + case 'top-left': + x = safeH + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + case 'top-right': + x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset; + y = pageHeight - safeV - fontSize + yOffset; + break; + } + + x = Math.max(xOffset + 3, Math.min(xOffset + pageWidth - textWidth - 3, x)); + y = Math.max(yOffset + 3, Math.min(yOffset + pageHeight - fontSize - 3, y)); + + return { x, y }; +} + +function resetState() { + files.length = 0; + const fileListEl = document.getElementById('file-list'); + if (fileListEl) fileListEl.innerHTML = ''; + document.getElementById('options-panel')?.classList.add('hidden'); + document.getElementById('file-controls')?.classList.add('hidden'); + const fileInput = document.getElementById('file-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; +} + +async function applyBatesNumbers() { + if (files.length === 0) { + showAlert('Error', 'Please upload at least one PDF file.'); + return; + } + + showLoader('Applying Bates numbers...'); + try { + const template = ( + document.getElementById('bates-template') as HTMLInputElement + ).value; + const padding = getActivePadding(); + const batesStart = + parseInt( + (document.getElementById('bates-start') as HTMLInputElement).value + ) || 1; + const fileStart = + parseInt( + (document.getElementById('file-start') as HTMLInputElement).value + ) || 1; + const position = (document.getElementById('position') as HTMLSelectElement) + .value as Position; + const fontKey = ( + document.getElementById('font-family') as HTMLSelectElement + ).value; + const fontSize = + parseInt( + (document.getElementById('font-size') as HTMLInputElement).value + ) || 10; + const colorHex = (document.getElementById('text-color') as HTMLInputElement) + .value; + const textColor = hexToRgb(colorHex); + + const fontName = FONT_MAP[fontKey] || 'Helvetica'; + const results: { name: string; bytes: Uint8Array }[] = []; + let batesCounter = batesStart; + let fileCounter = fileStart; + + for (const entry of files) { + const arrayBuffer = await entry.file.arrayBuffer(); + const pdfDoc = await PDFDocument.load(arrayBuffer); + const font = await pdfDoc.embedFont(StandardFonts[fontName]); + const pages = pdfDoc.getPages(); + const fileName = entry.file.name.replace(/\.pdf$/i, ''); + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const bounds = page.getCropBox() || page.getMediaBox(); + const text = formatBatesText( + template, + batesCounter, + i + 1, + fileCounter, + fileName, + padding + ); + const textWidth = font.widthOfTextAtSize(text, fontSize); + + const { x, y } = calculatePosition( + bounds.width, + bounds.height, + bounds.x || 0, + bounds.y || 0, + textWidth, + fontSize, + position + ); + + page.drawText(text, { + x, + y, + font, + size: fontSize, + color: rgb(textColor.r, textColor.g, textColor.b), + }); + + batesCounter++; + } + + fileCounter++; + const pdfBytes = await pdfDoc.save(); + results.push({ + name: `bates_${entry.file.name}`, + bytes: new Uint8Array(pdfBytes), + }); + } + + if (results.length === 1) { + downloadFile( + new Blob([new Uint8Array(results[0].bytes)], { + type: 'application/pdf', + }), + results[0].name + ); + } else { + const zip = new JSZip(); + for (const result of results) { + zip.file(result.name, result.bytes); + } + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'bates_numbered.zip'); + } + + showAlert( + 'Success', + `Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`, + 'success', + () => { + resetState(); + } + ); + } catch (e) { + console.error(e); + showAlert('Error', 'Failed to apply Bates numbers.'); + } finally { + hideLoader(); + } +} diff --git a/src/js/main.ts b/src/js/main.ts index d44d7e9..8987e90 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -255,19 +255,78 @@ const init = async () => { if (dom.toolGrid) { dom.toolGrid.textContent = ''; + let collapsedCategories: string[] = []; + try { + const stored = localStorage.getItem('collapsedCategories'); + if (stored) collapsedCategories = JSON.parse(stored); + } catch { + localStorage.removeItem('collapsedCategories'); + } + + function saveCollapsedCategories() { + localStorage.setItem( + 'collapsedCategories', + JSON.stringify(collapsedCategories) + ); + } + categories.forEach((category) => { const categoryGroup = document.createElement('div'); categoryGroup.className = 'category-group col-span-full'; - const title = document.createElement('h2'); - title.className = - 'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white'; + const header = document.createElement('button'); + header.className = 'category-header'; + header.type = 'button'; + + const title = document.createElement('span'); const categoryKey = categoryTranslationKeys[category.name]; title.textContent = categoryKey ? t(categoryKey) : category.name; + const chevron = document.createElement('i'); + chevron.setAttribute('data-lucide', 'chevron-down'); + chevron.className = + 'category-chevron w-5 h-5 text-gray-400 transition-transform duration-300'; + + header.append(title, chevron); + const toolsContainer = document.createElement('div'); toolsContainer.className = - 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; + 'category-tools grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; + + const isCollapsed = collapsedCategories.includes(category.name); + if (isCollapsed) { + categoryGroup.classList.add('collapsed'); + toolsContainer.style.maxHeight = '0px'; + } + + toolsContainer.addEventListener('transitionend', (e) => { + if ((e as TransitionEvent).propertyName !== 'max-height') return; + if (!categoryGroup.classList.contains('collapsed')) { + toolsContainer.style.maxHeight = 'none'; + toolsContainer.style.overflow = 'visible'; + } + }); + + header.addEventListener('click', () => { + const collapsed = categoryGroup.classList.toggle('collapsed'); + if (collapsed) { + toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px'; + toolsContainer.style.overflow = 'hidden'; + requestAnimationFrame(() => { + toolsContainer.style.maxHeight = '0px'; + }); + if (!collapsedCategories.includes(category.name)) { + collapsedCategories.push(category.name); + } + } else { + toolsContainer.style.overflow = 'hidden'; + toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px'; + collapsedCategories = collapsedCategories.filter( + (n) => n !== category.name + ); + } + saveCollapsedCategories(); + }); category.tools.forEach((tool) => { let toolCard: HTMLDivElement | HTMLAnchorElement; @@ -312,8 +371,13 @@ const init = async () => { toolsContainer.appendChild(toolCard); }); - categoryGroup.append(title, toolsContainer); + categoryGroup.append(header, toolsContainer); dom.toolGrid.appendChild(categoryGroup); + + if (!isCollapsed) { + toolsContainer.style.maxHeight = 'none'; + toolsContainer.style.overflow = 'visible'; + } }); const searchBar = document.getElementById('search-bar'); @@ -547,6 +611,35 @@ const init = async () => { }); } + const compactModeToggle = document.getElementById( + 'compact-mode-toggle' + ) as HTMLInputElement; + + const savedCompactMode = localStorage.getItem('compactMode') === 'true'; + if (compactModeToggle) { + compactModeToggle.checked = savedCompactMode; + } + applyCompactMode(savedCompactMode); + + function applyCompactMode(enabled: boolean) { + if (dom.toolGrid) { + dom.toolGrid.classList.toggle('compact-mode', enabled); + dom.toolGrid + .querySelectorAll('.category-group:not(.collapsed) .category-tools') + .forEach((container) => { + (container as HTMLElement).style.maxHeight = 'none'; + }); + } + } + + if (compactModeToggle) { + compactModeToggle.addEventListener('change', (e) => { + const enabled = (e.target as HTMLInputElement).checked; + localStorage.setItem('compactMode', enabled.toString()); + applyCompactMode(enabled); + }); + } + // Shortcuts UI Handlers if (dom.openShortcutsBtn) { dom.openShortcutsBtn.addEventListener('click', () => { diff --git a/src/js/types/bates-numbering-type.ts b/src/js/types/bates-numbering-type.ts new file mode 100644 index 0000000..8246dc3 --- /dev/null +++ b/src/js/types/bates-numbering-type.ts @@ -0,0 +1,17 @@ +export interface StylePreset { + template: string; + padding: number; +} + +export type Position = + | 'bottom-center' + | 'bottom-left' + | 'bottom-right' + | 'top-center' + | 'top-left' + | 'top-right'; + +export interface FileEntry { + file: File; + pageCount: number; +} diff --git a/src/js/types/index.ts b/src/js/types/index.ts index 75febb2..d27c8c6 100644 --- a/src/js/types/index.ts +++ b/src/js/types/index.ts @@ -49,3 +49,4 @@ export * from './email-to-pdf-type.ts'; export * from './bookmark-pdf-type.ts'; export * from './scanner-effect-type.ts'; export * from './adjust-colors-type.ts'; +export * from './bates-numbering-type.ts'; diff --git a/src/pages/bates-numbering.html b/src/pages/bates-numbering.html new file mode 100644 index 0000000..b0244a9 --- /dev/null +++ b/src/pages/bates-numbering.html @@ -0,0 +1,548 @@ + + + + + + + Bates Numbering Online Free - Add Bates Stamps | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Bates Numbering +

+

+ Add sequential Bates numbers across one or more PDF files. +

+ +
+
+ +

+ Click to select files + or drag and drop +

+

PDF files (multiple supported)

+

+ Your files never leave your device. +

+
+ +
+ + + +
+ + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload Files

+

+ Upload one or more PDF files. Drag to reorder for multi-file + sequencing. +

+
+
+
+
+ 2 +
+
+

+ Configure Style +

+

+ Choose a preset or create a custom template with placeholders like + [BATES], [PAGE], [FILE]. +

+
+
+
+
+ 3 +
+
+

Download

+

+ Get your bates-stamped PDFs instantly. Multiple files are + delivered as a ZIP. +

+
+
+
+
+ +
+

+ Related PDF Tools +

+
+
+ +
+

+ Frequently Asked Questions +

+
+
+ + What is Bates numbering? + + +

+ Bates numbering is a method of indexing legal documents for easy + identification and retrieval. Each page receives a unique sequential + number, often combined with a prefix or suffix. +

+
+
+ + Can I number multiple files at once? + + +

+ Yes! Upload multiple PDFs and the bates numbers will continue + sequentially across all files. Drag files to reorder them. +

+
+
+ + Are my files secure? + + +

+ All processing happens entirely in your browser. Your files never + leave your device. +

+
+
+
+ + {{> footer }} + + + + + + + + + + + diff --git a/tools.html b/tools.html index 9af00e9..7f64800 100644 --- a/tools.html +++ b/tools.html @@ -508,6 +508,12 @@ category: 'editor', icon: 'list-numbers', }, + { + name: 'bates-numbering', + title: 'Bates Numbering', + category: 'editor', + icon: 'hash', + }, { name: 'background-color', title: 'Background Color', @@ -821,7 +827,7 @@ grid.innerHTML = filtered .map( (tool) => ` - +

${tool.title}

diff --git a/vite.config.ts b/vite.config.ts index 45b9017..1dbb159 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -559,6 +559,10 @@ export default defineConfig(() => { ), 'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'), 'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'), + 'bates-numbering': resolve( + __dirname, + 'src/pages/bates-numbering.html' + ), }, }, },