import { PDFDocument, StandardFonts, rgb, TextAlignment, PDFName, PDFString, PageSizes, PDFBool, PDFDict, PDFArray, PDFRadioGroup } from 'pdf-lib' import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js' import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js' import { createIcons, icons } from 'lucide' import * as pdfjsLib from 'pdfjs-dist' import 'pdfjs-dist/web/pdf_viewer.css' // Initialize PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString() interface FormField { id: string type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image' x: number y: number width: number height: number name: string defaultValue: string fontSize: number alignment: 'left' | 'center' | 'right' textColor: string required: boolean readOnly: boolean tooltip: string combCells: number maxLength: number options?: string[] checked?: boolean exportValue?: string groupName?: string label?: string pageIndex: number action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide' actionUrl?: string jsScript?: string targetFieldName?: string visibilityAction?: 'show' | 'hide' | 'toggle' dateFormat?: string multiline?: boolean } interface PageData { index: number width: number height: number pdfPageData?: string } let fields: FormField[] = [] let selectedField: FormField | null = null let fieldCounter = 0 let existingFieldNames: Set = new Set() let existingRadioGroups: Set = new Set() let draggedElement: HTMLElement | null = null let offsetX = 0 let offsetY = 0 let pages: PageData[] = [] let currentPageIndex = 0 let uploadedPdfDoc: PDFDocument | null = null let uploadedPdfjsDoc: any = null let pageSize: { width: number; height: number } = { width: 612, height: 792 } let currentScale = 1.333 let pdfViewerOffset = { x: 0, y: 0 } let pdfViewerScale = 1.333 let resizing = false let resizeField: FormField | null = null let resizePos: string | null = null let startX = 0 let startY = 0 let startWidth = 0 let startHeight = 0 let startLeft = 0 let startTop = 0 let selectedToolType: string | null = null const canvas = document.getElementById('pdfCanvas') as HTMLDivElement const propertiesPanel = document.getElementById('propertiesPanel') as HTMLDivElement const fieldCountDisplay = document.getElementById('fieldCount') as HTMLSpanElement const uploadArea = document.getElementById('upload-area') as HTMLDivElement const toolContainer = document.getElementById('tool-container') as HTMLDivElement const dropZone = document.getElementById('dropZone') as HTMLDivElement const pdfFileInput = document.getElementById('pdfFileInput') as HTMLInputElement const blankPdfBtn = document.getElementById('blankPdfBtn') as HTMLButtonElement const pdfUploadInput = document.getElementById('pdfUploadInput') as HTMLInputElement const pageSizeSelector = document.getElementById('pageSizeSelector') as HTMLDivElement const pageSizeSelect = document.getElementById('pageSizeSelect') as HTMLSelectElement const customDimensionsInput = document.getElementById('customDimensionsInput') as HTMLDivElement const customWidth = document.getElementById('customWidth') as HTMLInputElement const customHeight = document.getElementById('customHeight') as HTMLInputElement const confirmBlankBtn = document.getElementById('confirmBlankBtn') as HTMLButtonElement const pageIndicator = document.getElementById('pageIndicator') as HTMLSpanElement const prevPageBtn = document.getElementById('prevPageBtn') as HTMLButtonElement const nextPageBtn = document.getElementById('nextPageBtn') as HTMLButtonElement const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement | null // Tool item interactions const toolItems = document.querySelectorAll('.tool-item') toolItems.forEach(item => { // Drag from toolbar item.addEventListener('dragstart', (e) => { if (e instanceof DragEvent && e.dataTransfer) { e.dataTransfer.effectAllowed = 'copy' const type = (item as HTMLElement).dataset.type || 'text' e.dataTransfer.setData('text/plain', type) } }) // Click to select tool for placement item.addEventListener('click', () => { const type = (item as HTMLElement).dataset.type || 'text' // Toggle selection if (selectedToolType === type) { // Deselect selectedToolType = null item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') canvas.style.cursor = 'default' } else { // Deselect previous tool if (selectedToolType) { toolItems.forEach(t => t.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) } // Select new tool selectedToolType = type item.classList.add('ring-2', 'ring-indigo-400', 'bg-indigo-600') canvas.style.cursor = 'crosshair' } }) // Touch events for mobile drag let touchStartX = 0 let touchStartY = 0 let isTouchDragging = false item.addEventListener('touchstart', (e) => { const touch = e.touches[0] touchStartX = touch.clientX touchStartY = touch.clientY isTouchDragging = false }) item.addEventListener('touchmove', (e) => { e.preventDefault() // Prevent scrolling while dragging const touch = e.touches[0] const moveX = Math.abs(touch.clientX - touchStartX) const moveY = Math.abs(touch.clientY - touchStartY) // If moved more than 10px, it's a drag not a click if (moveX > 10 || moveY > 10) { isTouchDragging = true } }) item.addEventListener('touchend', (e) => { e.preventDefault() if (!isTouchDragging) { // It was a tap, treat as click (item as HTMLElement).click() return } // It was a drag, place field at touch end position const touch = e.changedTouches[0] const canvasRect = canvas.getBoundingClientRect() // Check if touch ended on canvas if (touch.clientX >= canvasRect.left && touch.clientX <= canvasRect.right && touch.clientY >= canvasRect.top && touch.clientY <= canvasRect.bottom) { const x = touch.clientX - canvasRect.left - 75 const y = touch.clientY - canvasRect.top - 15 const type = (item as HTMLElement).dataset.type || 'text' createField(type as any, x, y) } }) }) // Canvas drop zone canvas.addEventListener('dragover', (e) => { e.preventDefault() if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy' } }) canvas.addEventListener('drop', (e) => { e.preventDefault() const rect = canvas.getBoundingClientRect() const x = e.clientX - rect.left - 75 // Center the field on drop point const y = e.clientY - rect.top - 15 const type = e.dataTransfer?.getData('text/plain') || 'text' createField(type as any, x, y) }) canvas.addEventListener('click', (e) => { if (selectedToolType) { const rect = canvas.getBoundingClientRect() const x = e.clientX - rect.left - 75 const y = e.clientY - rect.top - 15 createField(selectedToolType as any, x, y) toolItems.forEach(item => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) selectedToolType = null canvas.style.cursor = 'default' return } // Existing deselect behavior (only if no tool is selected) if (e.target === canvas) { deselectAll() } }) function createField(type: FormField['type'], x: number, y: number): void { fieldCounter++ const field: FormField = { id: `field_${fieldCounter}`, type: type, x: Math.max(0, Math.min(x, 816 - 150)), y: Math.max(0, Math.min(y, 1056 - 30)), width: type === 'checkbox' || type === 'radio' ? 30 : 150, height: type === 'checkbox' || type === 'radio' ? 30 : 30, name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`, defaultValue: '', fontSize: 12, alignment: 'left', textColor: '#000000', required: false, readOnly: false, tooltip: '', combCells: 0, maxLength: 0, options: type === 'dropdown' || type === 'optionlist' ? ['Option 1', 'Option 2', 'Option 3'] : undefined, checked: type === 'radio' || type === 'checkbox' ? false : undefined, exportValue: type === 'radio' || type === 'checkbox' ? 'Yes' : undefined, groupName: type === 'radio' ? 'RadioGroup1' : undefined, label: type === 'button' ? 'Button' : (type === 'image' ? 'Click to Upload Image' : undefined), action: type === 'button' ? 'none' : undefined, jsScript: type === 'button' ? 'app.alert("Hello World!");' : undefined, visibilityAction: type === 'button' ? 'toggle' : undefined, dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined, pageIndex: currentPageIndex, multiline: type === 'text' ? false : undefined } fields.push(field) renderField(field) updateFieldCount() } // Render field on canvas function renderField(field: FormField): void { const fieldWrapper = document.createElement('div') fieldWrapper.id = field.id fieldWrapper.className = 'absolute cursor-move group' // Added group for hover effects fieldWrapper.style.left = field.x + 'px' fieldWrapper.style.top = field.y + 'px' fieldWrapper.style.width = field.width + 'px' fieldWrapper.style.overflow = 'visible' // Create label - hidden by default, shown on group hover or selection const label = document.createElement('div') label.className = 'field-label absolute left-0 w-full text-xs font-semibold pointer-events-none select-none opacity-0 group-hover:opacity-100 transition-opacity' label.style.bottom = '100%' label.style.marginBottom = '4px' label.style.color = '#374151' label.style.fontSize = '11px' label.style.lineHeight = '1' label.style.whiteSpace = 'nowrap' label.style.overflow = 'hidden' label.style.textOverflow = 'ellipsis' label.textContent = field.name // Create input container - light border by default, dashed on hover const fieldContainer = document.createElement('div') fieldContainer.className = 'field-container relative border-2 border-indigo-200 group-hover:border-dashed group-hover:border-indigo-300 bg-indigo-50/30 rounded transition-all' fieldContainer.style.width = '100%' fieldContainer.style.height = field.height + 'px' // Create content based on type const contentEl = document.createElement('div') contentEl.className = 'field-content w-full h-full flex items-center justify-center overflow-hidden' if (field.type === 'text') { contentEl.className = 'field-text w-full h-full flex items-center px-2 text-sm overflow-hidden' contentEl.style.fontSize = field.fontSize + 'px' contentEl.style.textAlign = field.alignment contentEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' contentEl.style.color = field.textColor contentEl.style.whiteSpace = field.multiline ? 'pre-wrap' : 'nowrap' contentEl.style.textOverflow = 'ellipsis' contentEl.style.alignItems = field.multiline ? 'flex-start' : 'center' contentEl.textContent = field.defaultValue // Apply combing visual if enabled if (field.combCells > 0) { contentEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))` contentEl.style.fontFamily = 'monospace' contentEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)` contentEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)` contentEl.style.overflow = 'hidden' contentEl.style.textAlign = 'left' contentEl.style.justifyContent = 'flex-start' } } else if (field.type === 'checkbox') { contentEl.innerHTML = field.checked ? '' : '' } else if (field.type === 'radio') { fieldContainer.classList.add('rounded-full') // Make container round for radio contentEl.innerHTML = field.checked ? '
' : '' } else if (field.type === 'dropdown') { contentEl.className = 'w-full h-full flex items-center px-2 text-sm text-black' contentEl.style.backgroundColor = '#e6f0ff' // Light blue background like Firefox // Show selected option or first option or placeholder let displayText = 'Select...' if (field.defaultValue && field.options && field.options.includes(field.defaultValue)) { displayText = field.defaultValue } else if (field.options && field.options.length > 0) { displayText = field.options[0] } contentEl.textContent = displayText const arrow = document.createElement('div') arrow.className = 'absolute right-1 top-1/2 -translate-y-1/2' arrow.innerHTML = '' fieldContainer.appendChild(arrow) } else if (field.type === 'optionlist') { contentEl.className = 'w-full h-full flex flex-col text-sm bg-white overflow-hidden border border-gray-300' // Render options as a list if (field.options && field.options.length > 0) { field.options.forEach((opt, index) => { const optEl = document.createElement('div') optEl.className = 'px-1 w-full truncate' optEl.textContent = opt // Highlight selected option (defaultValue) or first one if no selection const isSelected = field.defaultValue ? field.defaultValue === opt : index === 0 if (isSelected) { optEl.className += ' bg-blue-600 text-white' } else { optEl.className += ' text-black' } contentEl.appendChild(optEl) }) } else { // Empty state const optEl = document.createElement('div') optEl.className = 'px-1 w-full text-black italic' optEl.textContent = 'Item 1' contentEl.appendChild(optEl) } } else if (field.type === 'button') { contentEl.className = 'field-content w-full h-full flex items-center justify-center bg-gray-200 text-sm font-semibold' contentEl.style.color = field.textColor || '#000000' contentEl.textContent = field.label || 'Button' } else if (field.type === 'signature') { contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-50 text-gray-400' contentEl.innerHTML = '
Sign Here
' setTimeout(() => (window as any).lucide?.createIcons(), 0) } else if (field.type === 'date') { contentEl.className = 'w-full h-full flex items-center justify-center bg-white text-gray-600 border border-gray-300' contentEl.innerHTML = `
${field.dateFormat || 'mm/dd/yyyy'}
` setTimeout(() => (window as any).lucide?.createIcons(), 0) } else if (field.type === 'image') { contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300' contentEl.innerHTML = `
${field.label || 'Click to Upload Image'}
` setTimeout(() => (window as any).lucide?.createIcons(), 0) } fieldContainer.appendChild(contentEl) fieldWrapper.appendChild(label) fieldWrapper.appendChild(fieldContainer) // Click to select fieldWrapper.addEventListener('click', (e) => { e.stopPropagation() selectField(field) }) // Drag to move fieldWrapper.addEventListener('mousedown', (e) => { // Don't start drag if clicking on a resize handle if ((e.target as HTMLElement).classList.contains('resize-handle')) { return } draggedElement = fieldWrapper const rect = canvas.getBoundingClientRect() offsetX = e.clientX - rect.left - field.x offsetY = e.clientY - rect.top - field.y selectField(field) e.preventDefault() }) // Touch events for moving fields let touchMoveStarted = false fieldWrapper.addEventListener('touchstart', (e) => { if ((e.target as HTMLElement).classList.contains('resize-handle')) { return } touchMoveStarted = false const touch = e.touches[0] const rect = canvas.getBoundingClientRect() offsetX = touch.clientX - rect.left - field.x offsetY = touch.clientY - rect.top - field.y selectField(field) }, { passive: true }) fieldWrapper.addEventListener('touchmove', (e) => { e.preventDefault() touchMoveStarted = true const touch = e.touches[0] const rect = canvas.getBoundingClientRect() let newX = touch.clientX - rect.left - offsetX let newY = touch.clientY - rect.top - offsetY newX = Math.max(0, Math.min(newX, rect.width - fieldWrapper.offsetWidth)) newY = Math.max(0, Math.min(newY, rect.height - fieldWrapper.offsetHeight)) fieldWrapper.style.left = newX + 'px' fieldWrapper.style.top = newY + 'px' field.x = newX field.y = newY }) fieldWrapper.addEventListener('touchend', () => { touchMoveStarted = false }) // Add resize handles to the container - hidden by default const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'] handles.forEach((pos) => { const handle = document.createElement('div') handle.className = `absolute w-2.5 h-2.5 bg-white border border-indigo-600 z-10 cursor-${pos}-resize resize-handle hidden` // Added hidden class const positions: Record = { nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', e: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2', w: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2', } handle.className += ` ${positions[pos]}` handle.dataset.position = pos handle.addEventListener('mousedown', (e) => { e.stopPropagation() startResize(e, field, pos) }) // Touch events for resize handles handle.addEventListener('touchstart', (e) => { e.stopPropagation() e.preventDefault() const touch = e.touches[0] // Create a synthetic mouse event for startResize const syntheticEvent = { clientX: touch.clientX, clientY: touch.clientY, preventDefault: () => { } } as MouseEvent startResize(syntheticEvent, field, pos) }) fieldContainer.appendChild(handle) }) canvas.appendChild(fieldWrapper) } function startResize(e: MouseEvent, field: FormField, pos: string): void { resizing = true resizeField = field resizePos = pos startX = e.clientX startY = e.clientY startWidth = field.width startHeight = field.height startLeft = field.x startTop = field.y e.preventDefault() } // Mouse move for dragging and resizing document.addEventListener('mousemove', (e) => { if (draggedElement && !resizing) { const rect = canvas.getBoundingClientRect() let newX = e.clientX - rect.left - offsetX let newY = e.clientY - rect.top - offsetY newX = Math.max(0, Math.min(newX, rect.width - draggedElement.offsetWidth)) newY = Math.max(0, Math.min(newY, rect.height - draggedElement.offsetHeight)) draggedElement.style.left = newX + 'px' draggedElement.style.top = newY + 'px' const field = fields.find((f) => f.id === draggedElement!.id) if (field) { field.x = newX field.y = newY } } else if (resizing && resizeField) { const dx = e.clientX - startX const dy = e.clientY - startY const fieldWrapper = document.getElementById(resizeField.id) if (resizePos!.includes('e')) { resizeField.width = Math.max(50, startWidth + dx) } if (resizePos!.includes('w')) { const newWidth = Math.max(50, startWidth - dx) const widthDiff = startWidth - newWidth resizeField.width = newWidth resizeField.x = startLeft + widthDiff } if (resizePos!.includes('s')) { resizeField.height = Math.max(20, startHeight + dy) } if (resizePos!.includes('n')) { const newHeight = Math.max(20, startHeight - dy) const heightDiff = startHeight - newHeight resizeField.height = newHeight resizeField.y = startTop + heightDiff } if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement fieldWrapper.style.width = resizeField.width + 'px' fieldWrapper.style.left = resizeField.x + 'px' fieldWrapper.style.top = resizeField.y + 'px' if (container) { container.style.height = resizeField.height + 'px' } // Update combing visuals on resize if (resizeField.combCells > 0) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) { textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` } } } } }) document.addEventListener('mouseup', () => { draggedElement = null resizing = false resizeField = null }) // Touch move for dragging and resizing document.addEventListener('touchmove', (e) => { const touch = e.touches[0] if (resizing && resizeField) { const dx = touch.clientX - startX const dy = touch.clientY - startY const fieldWrapper = document.getElementById(resizeField.id) if (resizePos!.includes('e')) { resizeField.width = Math.max(50, startWidth + dx) } if (resizePos!.includes('w')) { const newWidth = Math.max(50, startWidth - dx) const widthDiff = startWidth - newWidth resizeField.width = newWidth resizeField.x = startLeft + widthDiff } if (resizePos!.includes('s')) { resizeField.height = Math.max(20, startHeight + dy) } if (resizePos!.includes('n')) { const newHeight = Math.max(20, startHeight - dy) const heightDiff = startHeight - newHeight resizeField.height = newHeight resizeField.y = startTop + heightDiff } if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement fieldWrapper.style.width = resizeField.width + 'px' fieldWrapper.style.left = resizeField.x + 'px' fieldWrapper.style.top = resizeField.y + 'px' if (container) { container.style.height = resizeField.height + 'px' } if (resizeField.combCells > 0) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) { textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` } } } } }, { passive: false }) document.addEventListener('touchend', () => { resizing = false resizeField = null }) // Select field function selectField(field: FormField): void { deselectAll() selectedField = field const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement const label = fieldWrapper.querySelector('.field-label') as HTMLElement const handles = fieldWrapper.querySelectorAll('.resize-handle') if (container) { // Remove hover classes and add selected classes container.classList.remove('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300') container.classList.add('border-dashed', 'border-indigo-500', 'bg-indigo-50') } if (label) { label.classList.remove('opacity-0', 'group-hover:opacity-100') label.classList.add('opacity-100') } handles.forEach(handle => { handle.classList.remove('hidden') }) } showProperties(field) } // Deselect all function deselectAll(): void { if (selectedField) { const fieldWrapper = document.getElementById(selectedField.id) if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement const label = fieldWrapper.querySelector('.field-label') as HTMLElement const handles = fieldWrapper.querySelectorAll('.resize-handle') if (container) { // Revert to default/hover state container.classList.remove('border-dashed', 'border-indigo-500', 'bg-indigo-50') container.classList.add('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300') } if (label) { label.classList.remove('opacity-100') label.classList.add('opacity-0', 'group-hover:opacity-100') } handles.forEach(handle => { handle.classList.add('hidden') }) } selectedField = null } hideProperties() } // Show properties panel function showProperties(field: FormField): void { let specificProps = '' if (field.type === 'text') { specificProps = `
0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
0 ? 'disabled' : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50">
` } else if (field.type === 'checkbox') { specificProps = `
` } else if (field.type === 'radio') { specificProps = `
` } else if (field.type === 'dropdown' || field.type === 'optionlist') { specificProps = `
To actually fill or change the options, use our PDF Form Filler tool.
` } else if (field.type === 'button') { specificProps = `
` } else if (field.type === 'signature') { specificProps = `
Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer.
` } else if (field.type === 'date') { const formats = ['mm/dd/yyyy', 'dd/mm/yyyy', 'mm/yy', 'dd/mm/yy', 'yyyy/mm/dd', 'mmm d, yyyy', 'd-mmm-yy', 'yy-mm-dd'] specificProps = `
The selected format will be enforced when the user types or picks a date.

Browser Note: Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date. This is normal browser behavior and not an issue.

` } else if (field.type === 'image') { specificProps = `
Clicking this field in the PDF will open a file picker to upload an image.
` } propertiesPanel.innerHTML = `
${field.type === 'radio' && (existingRadioGroups.size > 0 || fields.some(f => f.type === 'radio' && f.id !== field.id)) ? `

Select to add this button to an existing group

` : ''} ${specificProps}
` // Common listeners const propName = document.getElementById('propName') as HTMLInputElement const nameError = document.getElementById('nameError') as HTMLDivElement const propTooltip = document.getElementById('propTooltip') as HTMLInputElement const propRequired = document.getElementById('propRequired') as HTMLInputElement const propReadOnly = document.getElementById('propReadOnly') as HTMLInputElement const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement const validateName = (newName: string): boolean => { if (!newName) { nameError.textContent = 'Field name cannot be empty' nameError.classList.remove('hidden') propName.classList.add('border-red-500') return false } if (field.type === 'radio') { nameError.classList.add('hidden') propName.classList.remove('border-red-500') return true } const isDuplicateInFields = fields.some(f => f.id !== field.id && f.name === newName) const isDuplicateInPdf = existingFieldNames.has(newName) if (isDuplicateInFields || isDuplicateInPdf) { nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.` nameError.classList.remove('hidden') propName.classList.add('border-red-500') return false } nameError.classList.add('hidden') propName.classList.remove('border-red-500') return true } propName.addEventListener('input', (e) => { const newName = (e.target as HTMLInputElement).value.trim() validateName(newName) }) propName.addEventListener('change', (e) => { const newName = (e.target as HTMLInputElement).value.trim() if (!validateName(newName)) { (e.target as HTMLInputElement).value = field.name return } field.name = newName const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const label = fieldWrapper.querySelector('.field-label') as HTMLElement if (label) label.textContent = field.name } }) propTooltip.addEventListener('input', (e) => { field.tooltip = (e.target as HTMLInputElement).value }) if (field.type === 'radio') { const existingGroupsSelect = document.getElementById('existingGroups') as HTMLSelectElement if (existingGroupsSelect) { existingGroupsSelect.addEventListener('change', (e) => { const selectedGroup = (e.target as HTMLSelectElement).value if (selectedGroup) { propName.value = selectedGroup field.name = selectedGroup validateName(selectedGroup) // Update field label const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const label = fieldWrapper.querySelector('.field-label') as HTMLElement if (label) label.textContent = field.name } } }) } } propRequired.addEventListener('change', (e) => { field.required = (e.target as HTMLInputElement).checked }) propReadOnly.addEventListener('change', (e) => { field.readOnly = (e.target as HTMLInputElement).checked }) deleteBtn.addEventListener('click', () => { deleteField(field) }) // Specific listeners if (field.type === 'text') { const propValue = document.getElementById('propValue') as HTMLInputElement const propMaxLength = document.getElementById('propMaxLength') as HTMLInputElement const propComb = document.getElementById('propComb') as HTMLInputElement const propFontSize = document.getElementById('propFontSize') as HTMLInputElement const propTextColor = document.getElementById('propTextColor') as HTMLInputElement const propAlignment = document.getElementById('propAlignment') as HTMLSelectElement propValue.addEventListener('input', (e) => { field.defaultValue = (e.target as HTMLInputElement).value const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) textEl.textContent = field.defaultValue } }) propMaxLength.addEventListener('input', (e) => { const val = parseInt((e.target as HTMLInputElement).value) field.maxLength = isNaN(val) ? 0 : Math.max(0, val) if (field.maxLength > 0) { propValue.maxLength = field.maxLength if (field.defaultValue.length > field.maxLength) { field.defaultValue = field.defaultValue.substring(0, field.maxLength) propValue.value = field.defaultValue const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) textEl.textContent = field.defaultValue } } } else { propValue.removeAttribute('maxLength') } }) propComb.addEventListener('input', (e) => { const val = parseInt((e.target as HTMLInputElement).value) field.combCells = isNaN(val) ? 0 : Math.max(0, val) if (field.combCells > 0) { propValue.maxLength = field.combCells propMaxLength.value = field.combCells.toString() propMaxLength.disabled = true field.maxLength = field.combCells if (field.defaultValue.length > field.combCells) { field.defaultValue = field.defaultValue.substring(0, field.combCells) propValue.value = field.defaultValue } } else { propMaxLength.disabled = false propValue.removeAttribute('maxLength') if (field.maxLength > 0) { propValue.maxLength = field.maxLength } } // Re-render field visual only, NOT the properties panel const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { // Update text content const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) { textEl.textContent = field.defaultValue if (field.combCells > 0) { textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))` textEl.style.fontFamily = 'monospace' textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)` textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)` textEl.style.overflow = 'hidden' textEl.style.textAlign = 'left' textEl.style.justifyContent = 'flex-start' } else { textEl.style.backgroundImage = 'none' textEl.style.fontFamily = 'inherit' textEl.style.letterSpacing = 'normal' textEl.style.textAlign = field.alignment textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' } } } }) propFontSize.addEventListener('input', (e) => { field.fontSize = parseInt((e.target as HTMLInputElement).value) const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) textEl.style.fontSize = field.fontSize + 'px' } }) propTextColor.addEventListener('input', (e) => { field.textColor = (e.target as HTMLInputElement).value const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) textEl.style.color = field.textColor } }) propAlignment.addEventListener('change', (e) => { field.alignment = (e.target as HTMLSelectElement).value as 'left' | 'center' | 'right' const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) { textEl.style.textAlign = field.alignment textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center' } } }) const propMultilineBtn = document.getElementById('propMultilineBtn') as HTMLButtonElement if (propMultilineBtn) { propMultilineBtn.addEventListener('click', () => { field.multiline = !field.multiline // Update Toggle Button UI const span = propMultilineBtn.querySelector('span') if (field.multiline) { propMultilineBtn.classList.remove('bg-gray-500') propMultilineBtn.classList.add('bg-indigo-600') span?.classList.remove('translate-x-0') span?.classList.add('translate-x-6') } else { propMultilineBtn.classList.remove('bg-indigo-600') propMultilineBtn.classList.add('bg-gray-500') span?.classList.remove('translate-x-6') span?.classList.add('translate-x-0') } // Update Canvas UI const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement if (textEl) { if (field.multiline) { textEl.style.whiteSpace = 'pre-wrap' textEl.style.alignItems = 'flex-start' textEl.style.overflow = 'hidden' } else { textEl.style.whiteSpace = 'nowrap' textEl.style.alignItems = 'center' textEl.style.overflow = 'hidden' } } } }) } } else if (field.type === 'checkbox' || field.type === 'radio') { const propCheckedBtn = document.getElementById('propCheckedBtn') as HTMLButtonElement propCheckedBtn.addEventListener('click', () => { field.checked = !field.checked // Update Toggle Button UI const span = propCheckedBtn.querySelector('span') if (field.checked) { propCheckedBtn.classList.remove('bg-gray-500') propCheckedBtn.classList.add('bg-indigo-600') span?.classList.remove('translate-x-0') span?.classList.add('translate-x-6') } else { propCheckedBtn.classList.remove('bg-indigo-600') propCheckedBtn.classList.add('bg-gray-500') span?.classList.remove('translate-x-6') span?.classList.add('translate-x-0') } // Update Canvas UI const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement if (contentEl) { if (field.type === 'checkbox') { contentEl.innerHTML = field.checked ? '' : '' } else { contentEl.innerHTML = field.checked ? '
' : '' } } } }) if (field.type === 'radio') { const propGroupName = document.getElementById('propGroupName') as HTMLInputElement const propExportValue = document.getElementById('propExportValue') as HTMLInputElement propGroupName.addEventListener('input', (e) => { field.groupName = (e.target as HTMLInputElement).value }) propExportValue.addEventListener('input', (e) => { field.exportValue = (e.target as HTMLInputElement).value }) } } else if (field.type === 'dropdown' || field.type === 'optionlist') { const propOptions = document.getElementById('propOptions') as HTMLTextAreaElement propOptions.addEventListener('input', (e) => { // We split by newline OR comma for the actual options array const val = (e.target as HTMLTextAreaElement).value field.options = val.split(/[\n,]/).map(s => s.trim()).filter(s => s.length > 0) const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement if (propSelectedOption) { const currentVal = field.defaultValue propSelectedOption.innerHTML = '' + field.options?.map(opt => ``).join('') if (currentVal && field.options && !field.options.includes(currentVal)) { field.defaultValue = '' propSelectedOption.value = '' } } renderField(field) }) const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement propSelectedOption.addEventListener('change', (e) => { field.defaultValue = (e.target as HTMLSelectElement).value // Update visual on canvas renderField(field) }) } else if (field.type === 'button') { const propLabel = document.getElementById('propLabel') as HTMLInputElement propLabel.addEventListener('input', (e) => { field.label = (e.target as HTMLInputElement).value const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement if (contentEl) contentEl.textContent = field.label || 'Button' } }) const propAction = document.getElementById('propAction') as HTMLSelectElement const propUrlContainer = document.getElementById('propUrlContainer') as HTMLDivElement const propJsContainer = document.getElementById('propJsContainer') as HTMLDivElement const propShowHideContainer = document.getElementById('propShowHideContainer') as HTMLDivElement propAction.addEventListener('change', (e) => { field.action = (e.target as HTMLSelectElement).value as any // Show/hide containers propUrlContainer.classList.add('hidden') propJsContainer.classList.add('hidden') propShowHideContainer.classList.add('hidden') if (field.action === 'url') { propUrlContainer.classList.remove('hidden') } else if (field.action === 'js') { propJsContainer.classList.remove('hidden') } else if (field.action === 'showHide') { propShowHideContainer.classList.remove('hidden') } }) const propActionUrl = document.getElementById('propActionUrl') as HTMLInputElement propActionUrl.addEventListener('input', (e) => { field.actionUrl = (e.target as HTMLInputElement).value }) const propJsScript = document.getElementById('propJsScript') as HTMLTextAreaElement if (propJsScript) { propJsScript.addEventListener('input', (e) => { field.jsScript = (e.target as HTMLTextAreaElement).value }) } const propTargetField = document.getElementById('propTargetField') as HTMLSelectElement if (propTargetField) { propTargetField.addEventListener('change', (e) => { field.targetFieldName = (e.target as HTMLSelectElement).value }) } const propVisibilityAction = document.getElementById('propVisibilityAction') as HTMLSelectElement if (propVisibilityAction) { propVisibilityAction.addEventListener('change', (e) => { field.visibilityAction = (e.target as HTMLSelectElement).value as any }) } } else if (field.type === 'signature') { // No specific listeners for signature fields yet } else if (field.type === 'date') { const propDateFormat = document.getElementById('propDateFormat') as HTMLSelectElement if (propDateFormat) { propDateFormat.addEventListener('change', (e) => { field.dateFormat = (e.target as HTMLSelectElement).value // Update canvas preview const fieldWrapper = document.getElementById(field.id) if (fieldWrapper) { const textSpan = fieldWrapper.querySelector('.date-format-text') as HTMLElement if (textSpan) { textSpan.textContent = field.dateFormat } } // Re-initialize lucide icons in the properties panel setTimeout(() => (window as any).lucide?.createIcons(), 0) }) } } else if (field.type === 'image') { const propLabel = document.getElementById('propLabel') as HTMLInputElement propLabel.addEventListener('input', (e) => { field.label = (e.target as HTMLInputElement).value renderField(field) }) } } // Hide properties panel function hideProperties(): void { propertiesPanel.innerHTML = '

Select a field to edit properties

' } // Delete field function deleteField(field: FormField): void { const fieldEl = document.getElementById(field.id) if (fieldEl) { fieldEl.remove() } fields = fields.filter((f) => f.id !== field.id) deselectAll() updateFieldCount() } // Delete key handler document.addEventListener('keydown', (e) => { if (e.key === 'Delete' && selectedField) { deleteField(selectedField) } else if (e.key === 'Escape' && selectedToolType) { // Cancel tool selection toolItems.forEach(item => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')) selectedToolType = null canvas.style.cursor = 'default' } }) // Update field count function updateFieldCount(): void { fieldCountDisplay.textContent = fields.length.toString() } // Download PDF downloadBtn.addEventListener('click', async () => { // Check for duplicate field names before generating PDF const nameCount = new Map() const duplicates: string[] = [] const conflictsWithPdf: string[] = [] fields.forEach(field => { const count = nameCount.get(field.name) || 0 nameCount.set(field.name, count + 1) if (existingFieldNames.has(field.name)) { if (field.type === 'radio' && existingRadioGroups.has(field.name)) { } else { conflictsWithPdf.push(field.name) } } }) nameCount.forEach((count, name) => { if (count > 1) { const fieldsWithName = fields.filter(f => f.name === name) const allRadio = fieldsWithName.every(f => f.type === 'radio') if (!allRadio) { duplicates.push(name) } } }) if (conflictsWithPdf.length > 0) { const conflictList = [...new Set(conflictsWithPdf)].map(name => `"${name}"`).join(', ') showModal( 'Field Name Conflict', `The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`, 'error' ) return } if (duplicates.length > 0) { const duplicateList = duplicates.map(name => `"${name}"`).join(', ') showModal( 'Duplicate Field Names', `The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`, 'error' ) return } if (fields.length === 0) { alert('Please add at least one field before downloading.') return } if (pages.length === 0) { alert('No pages found. Please create a blank PDF or upload one.') return } try { let pdfDoc: PDFDocument if (uploadedPdfDoc) { pdfDoc = uploadedPdfDoc } else { pdfDoc = await PDFDocument.create() for (const pageData of pages) { pdfDoc.addPage([pageData.width, pageData.height]) } } const form = pdfDoc.getForm() const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica) // Set document metadata for accessibility pdfDoc.setTitle('Fillable Form') pdfDoc.setAuthor('BentoPDF') pdfDoc.setLanguage('en-US') const radioGroups = new Map() // Track created radio groups for (const field of fields) { const pageData = pages[field.pageIndex] if (!pageData) continue const pdfPage = pdfDoc.getPage(field.pageIndex) const { height: pageHeight } = pdfPage.getSize() const scaleX = 1 / pdfViewerScale const scaleY = 1 / pdfViewerScale const adjustedX = field.x - pdfViewerOffset.x const adjustedY = field.y - pdfViewerOffset.y const x = adjustedX * scaleX const y = pageHeight - (adjustedY * scaleY) - (field.height * scaleY) const width = field.width * scaleX const height = field.height * scaleY console.log(`Field "${field.name}":`, { screenPos: { x: field.x, y: field.y }, adjustedPos: { x: adjustedX, y: adjustedY }, pdfPos: { x, y, width, height }, metrics: { offset: pdfViewerOffset, scale: pdfViewerScale } }) if (field.type === 'text') { const textField = form.createTextField(field.name) const rgbColor = hexToRgb(field.textColor) textField.addToPage(pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b), }) textField.setText(field.defaultValue) textField.setFontSize(field.fontSize) // Set alignment if (field.alignment === 'center') { textField.setAlignment(TextAlignment.Center) } else if (field.alignment === 'right') { textField.setAlignment(TextAlignment.Right) } else { textField.setAlignment(TextAlignment.Left) } // Handle combing if (field.combCells > 0) { textField.setMaxLength(field.combCells) textField.enableCombing() } else if (field.maxLength > 0) { textField.setMaxLength(field.maxLength) } // Disable multiline to prevent RTL issues (unless explicitly enabled) if (!field.multiline) { textField.disableMultiline() } else { textField.enableMultiline() } // Common properties if (field.required) textField.enableRequired() if (field.readOnly) textField.enableReadOnly() if (field.tooltip) { textField.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'checkbox') { const checkBox = form.createCheckBox(field.name) checkBox.addToPage(pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), }) if (field.checked) checkBox.check() if (field.required) checkBox.enableRequired() if (field.readOnly) checkBox.enableReadOnly() if (field.tooltip) { checkBox.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'radio') { const groupName = field.name let radioGroup if (radioGroups.has(groupName)) { radioGroup = radioGroups.get(groupName) } else { const existingField = form.getFieldMaybe(groupName) if (existingField) { radioGroup = existingField radioGroups.set(groupName, radioGroup) console.log(`Using existing radio group from PDF: ${groupName}`) } else { radioGroup = form.createRadioGroup(groupName) radioGroups.set(groupName, radioGroup) console.log(`Created new radio group: ${groupName}`) } } radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), }) if (field.checked) radioGroup.select(field.exportValue || 'Yes') if (field.required) radioGroup.enableRequired() if (field.readOnly) radioGroup.enableReadOnly() if (field.tooltip) { radioGroup.acroField.getWidgets().forEach((widget: any) => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'dropdown') { const dropdown = form.createDropdown(field.name) dropdown.addToPage(pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams }) if (field.options) dropdown.setOptions(field.options) if (field.defaultValue && field.options?.includes(field.defaultValue)) dropdown.select(field.defaultValue) else if (field.options && field.options.length > 0) dropdown.select(field.options[0]) const rgbColor = hexToRgb(field.textColor) dropdown.acroField.setFontSize(field.fontSize) dropdown.acroField.setDefaultAppearance( `0 0 0 rg /Helv ${field.fontSize} Tf` ) if (field.required) dropdown.enableRequired() if (field.readOnly) dropdown.enableReadOnly() if (field.tooltip) { dropdown.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'optionlist') { const optionList = form.createOptionList(field.name) optionList.addToPage(pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), }) if (field.options) optionList.setOptions(field.options) if (field.defaultValue && field.options?.includes(field.defaultValue)) optionList.select(field.defaultValue) else if (field.options && field.options.length > 0) optionList.select(field.options[0]) const rgbColor = hexToRgb(field.textColor) optionList.acroField.setFontSize(field.fontSize) optionList.acroField.setDefaultAppearance( `0 0 0 rg /Helv ${field.fontSize} Tf` ) if (field.required) optionList.enableRequired() if (field.readOnly) optionList.enableReadOnly() if (field.tooltip) { optionList.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'button') { const button = form.createButton(field.name) button.addToPage(field.label || 'Button', pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray }) // Add Action if (field.action && field.action !== 'none') { const widgets = button.acroField.getWidgets() widgets.forEach(widget => { let actionDict: any if (field.action === 'reset') { actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'ResetForm' }) } else if (field.action === 'print') { // Print action using JavaScript actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: 'print();' }) } else if (field.action === 'url' && field.actionUrl) { // Validate URL let url = field.actionUrl.trim() if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://' + url } // Encode URL to handle special characters (RFC3986) try { url = encodeURI(url) } catch (e) { console.warn('Failed to encode URL:', e) } actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'URI', URI: PDFString.of(url) }) } else if (field.action === 'js' && field.jsScript) { actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: field.jsScript }) } else if (field.action === 'showHide' && field.targetFieldName) { const target = field.targetFieldName let script = '' if (field.visibilityAction === 'show') { script = `var f = this.getField("${target}"); if(f) f.display = display.visible;` } else if (field.visibilityAction === 'hide') { script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;` } else { // Toggle script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;` } actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: script }) } if (actionDict) { widget.dict.set(PDFName.of('A'), actionDict) } }) } if (field.tooltip) { button.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'date') { const dateField = form.createTextField(field.name) dateField.addToPage(pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(1, 1, 1), }) // Add Date Format and Keystroke Actions to the FIELD (not widget) const dateFormat = field.dateFormat || 'mm/dd/yyyy' const formatAction = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`) }) const keystrokeAction = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`) }) // Attach AA (Additional Actions) to the field dictionary const additionalActions = pdfDoc.context.obj({ F: formatAction, K: keystrokeAction }) dateField.acroField.dict.set(PDFName.of('AA'), additionalActions) if (field.required) dateField.enableRequired() if (field.readOnly) dateField.enableReadOnly() if (field.tooltip) { dateField.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'image') { const imageBtn = form.createButton(field.name) imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, { x: x, y: y, width: width, height: height, borderWidth: 1, borderColor: rgb(0, 0, 0), backgroundColor: rgb(0.9, 0.9, 0.9), }) // Add Import Icon Action const widgets = imageBtn.acroField.getWidgets() widgets.forEach(widget => { const actionDict = pdfDoc.context.obj({ Type: 'Action', S: 'JavaScript', JS: 'event.target.buttonImportIcon();' }) widget.dict.set(PDFName.of('A'), actionDict) // Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only) // This ensures the image replaces the text when uploaded // IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill) const mkDict = pdfDoc.context.obj({ TP: 1, BG: [0.9, 0.9, 0.9], // Background color (Light Gray) BC: [0, 0, 0], // Border color (Black) IF: { SW: PDFName.of('A'), S: PDFName.of('A'), FB: true } }) widget.dict.set(PDFName.of('MK'), mkDict) }) if (field.tooltip) { imageBtn.acroField.getWidgets().forEach(widget => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) }) } } else if (field.type === 'signature') { const context = pdfDoc.context // Create the signature field dictionary with FT = Sig const sigDict = context.obj({ FT: PDFName.of('Sig'), T: PDFString.of(field.name), Kids: [], }) as PDFDict const sigRef = context.register(sigDict) // Create the widget annotation for the signature field const widgetDict = context.obj({ Type: PDFName.of('Annot'), Subtype: PDFName.of('Widget'), Rect: [x, y, x + width, y + height], F: 4, // Print flag P: pdfPage.ref, Parent: sigRef, }) as PDFDict // Add border and background appearance const borderStyle = context.obj({ W: 1, // Border width S: PDFName.of('S'), // Solid border }) as PDFDict widgetDict.set(PDFName.of('BS'), borderStyle) widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])) // Border color (black) widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])) // Background color const widgetRef = context.register(widgetDict) const kidsArray = sigDict.get(PDFName.of('Kids')) as PDFArray kidsArray.push(widgetRef) pdfPage.node.addAnnot(widgetRef) const acroForm = form.acroForm acroForm.addField(sigRef) // Add tooltip if specified if (field.tooltip) { widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip)) } } } form.updateFieldAppearances(helveticaFont) const pdfBytes = await pdfDoc.save() const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }) downloadFile(blob, 'fillable-form.pdf') showModal('Success', 'Your PDF has been downloaded successfully.', 'info', () => { resetToInitial() }, 'Okay') } catch (error) { console.error('Error generating PDF:', error) const errorMessage = (error as Error).message // Check if it's a duplicate field name error if (errorMessage.includes('A field already exists with the specified name')) { // Extract the field name from the error message const match = errorMessage.match(/A field already exists with the specified name: "(.+?)"/) const fieldName = match ? match[1] : 'unknown' if (existingRadioGroups.has(fieldName)) { console.log(`Adding to existing radio group: ${fieldName}`) } else { showModal('Duplicate Field Name', `A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`, 'error') } } else { showModal('Error', 'Error generating PDF: ' + errorMessage, 'error') } } }) // Back to tools button const backToToolsBtns = document.querySelectorAll('[id^="back-to-tools"]') as NodeListOf backToToolsBtns.forEach(btn => { btn.addEventListener('click', () => { window.location.href = '/' }) }) function getPageDimensions(size: string): { width: number; height: number } { let dimensions: [number, number] switch (size) { case 'letter': dimensions = PageSizes.Letter break case 'a4': dimensions = PageSizes.A4 break case 'a5': dimensions = PageSizes.A5 break case 'legal': dimensions = PageSizes.Legal break case 'tabloid': dimensions = PageSizes.Tabloid break case 'a3': dimensions = PageSizes.A3 break case 'custom': // Get custom dimensions from inputs const width = parseInt(customWidth.value) || 612 const height = parseInt(customHeight.value) || 792 return { width, height } default: dimensions = PageSizes.Letter } return { width: dimensions[0], height: dimensions[1] } } // Reset to initial state function resetToInitial(): void { fields = [] pages = [] currentPageIndex = 0 uploadedPdfDoc = null selectedField = null canvas.innerHTML = '' propertiesPanel.innerHTML = '

Select a field to edit properties

' updateFieldCount() // Show upload area and hide tool container uploadArea.classList.remove('hidden') toolContainer.classList.add('hidden') pageSizeSelector.classList.add('hidden') setTimeout(() => createIcons({ icons }), 100) } function createBlankPage(): void { pages.push({ index: pages.length, width: pageSize.width, height: pageSize.height }) updatePageNavigation() } function switchToPage(pageIndex: number): void { if (pageIndex < 0 || pageIndex >= pages.length) return currentPageIndex = pageIndex renderCanvas() updatePageNavigation() // Deselect any selected field when switching pages deselectAll() } // Render the canvas for the current page async function renderCanvas(): Promise { const currentPage = pages[currentPageIndex] if (!currentPage) return // Fixed scale for better visibility let scale = 1.333 currentScale = scale // Use actual PDF page dimensions (not scaled) const canvasWidth = currentPage.width * scale const canvasHeight = currentPage.height * scale canvas.style.width = `${canvasWidth}px` canvas.style.height = `${canvasHeight}px` canvas.innerHTML = '' if (uploadedPdfDoc) { try { const arrayBuffer = await uploadedPdfDoc.save() const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], { type: 'application/pdf' }) const blobUrl = URL.createObjectURL(blob) const iframe = document.createElement('iframe') iframe.src = `/pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0` iframe.style.width = '100%' iframe.style.height = `${canvasHeight}px` iframe.style.border = 'none' iframe.style.position = 'absolute' iframe.style.top = '0' iframe.style.left = '0' iframe.style.pointerEvents = 'none' iframe.style.opacity = '0.8' iframe.onload = () => { try { const viewerWindow = iframe.contentWindow as any if (viewerWindow && viewerWindow.PDFViewerApplication) { const app = viewerWindow.PDFViewerApplication const style = viewerWindow.document.createElement('style') style.textContent = ` * { margin: 0 !important; padding: 0 !important; } html, body { margin: 0 !important; padding: 0 !important; background-color: transparent !important; overflow: hidden !important; } #toolbarContainer { display: none !important; } #mainContainer { top: 0 !important; position: absolute !important; left: 0 !important; margin: 0 !important; padding: 0 !important; } #outerContainer { background-color: transparent !important; margin: 0 !important; padding: 0 !important; } #viewerContainer { top: 0 !important; background-color: transparent !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; } .toolbar { display: none !important; } .pdfViewer { padding: 0 !important; margin: 0 !important; } .page { margin: 0 !important; padding: 0 !important; border: none !important; box-shadow: none !important; } ` viewerWindow.document.head.appendChild(style) const checkRender = setInterval(() => { if (app.pdfViewer && app.pdfViewer.pagesCount > 0) { clearInterval(checkRender) const pageContainer = viewerWindow.document.querySelector('.page') if (pageContainer) { const initialRect = pageContainer.getBoundingClientRect() const offsetX = -initialRect.left const offsetY = -initialRect.top pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)` setTimeout(() => { const rect = pageContainer.getBoundingClientRect() const style = viewerWindow.getComputedStyle(pageContainer) const borderLeft = parseFloat(style.borderLeftWidth) || 0 const borderTop = parseFloat(style.borderTopWidth) || 0 const borderRight = parseFloat(style.borderRightWidth) || 0 pdfViewerOffset = { x: rect.left + borderLeft, y: rect.top + borderTop } const contentWidth = rect.width - borderLeft - borderRight pdfViewerScale = contentWidth / currentPage.width console.log('📏 Calibrated Metrics (force positioned):', { initialPosition: { left: initialRect.left, top: initialRect.top }, appliedTransform: { x: offsetX, y: offsetY }, finalRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, computedBorders: { left: borderLeft, top: borderTop, right: borderRight }, finalOffset: pdfViewerOffset, finalScale: pdfViewerScale, pdfDimensions: { width: currentPage.width, height: currentPage.height } }) }, 50) } } }, 100) } } catch (e) { console.error('Error accessing iframe content:', e) } } canvas.appendChild(iframe) console.log('Canvas dimensions:', { width: canvasWidth, height: canvasHeight, scale: currentScale }) console.log('PDF page dimensions:', { width: currentPage.width, height: currentPage.height }) } catch (error) { console.error('Error rendering PDF:', error) } } fields.filter(f => f.pageIndex === currentPageIndex).forEach(field => { renderField(field) }) } function updatePageNavigation(): void { pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}` prevPageBtn.disabled = currentPageIndex === 0 nextPageBtn.disabled = currentPageIndex === pages.length - 1 } // Drag and drop handlers for upload area dropZone.addEventListener('dragover', (e) => { e.preventDefault() dropZone.classList.add('border-indigo-500', 'bg-gray-600') }) dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-indigo-500', 'bg-gray-600') }) dropZone.addEventListener('drop', (e) => { e.preventDefault() dropZone.classList.remove('border-indigo-500', 'bg-gray-600') const files = e.dataTransfer?.files if (files && files.length > 0 && files[0].type === 'application/pdf') { handlePdfUpload(files[0]) } }) pdfFileInput.addEventListener('change', async (e) => { const file = (e.target as HTMLInputElement).files?.[0] if (file) { handlePdfUpload(file) } }) blankPdfBtn.addEventListener('click', () => { pageSizeSelector.classList.remove('hidden') }) pageSizeSelect.addEventListener('change', () => { if (pageSizeSelect.value === 'custom') { customDimensionsInput.classList.remove('hidden') } else { customDimensionsInput.classList.add('hidden') } }) confirmBlankBtn.addEventListener('click', () => { const selectedSize = pageSizeSelect.value pageSize = getPageDimensions(selectedSize) createBlankPage() switchToPage(0) // Hide upload area and show tool container uploadArea.classList.add('hidden') toolContainer.classList.remove('hidden') setTimeout(() => createIcons({ icons }), 100) }) async function handlePdfUpload(file: File) { try { const arrayBuffer = await file.arrayBuffer() uploadedPdfDoc = await PDFDocument.load(arrayBuffer) // Check for existing fields and update counter existingFieldNames.clear() try { const form = uploadedPdfDoc.getForm() const pdfFields = form.getFields() // console.log('📋 Found', pdfFields.length, 'existing fields in uploaded PDF') pdfFields.forEach(field => { const name = field.getName() existingFieldNames.add(name) // Track all existing field names if (field instanceof PDFRadioGroup) { existingRadioGroups.add(name) } // console.log(' Field:', name, '| Type:', field.constructor.name) const match = name.match(/([a-zA-Z]+)_(\d+)/) if (match) { const num = parseInt(match[2]) if (!isNaN(num) && num > fieldCounter) { fieldCounter = num console.log(' → Updated field counter to:', fieldCounter) } } }) // TODO@ALAM: DEBUGGER // console.log('Field counter after upload:', fieldCounter) // console.log('Existing field names:', Array.from(existingFieldNames)) } catch (e) { console.log('No form fields found or error reading fields:', e) } uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise const pageCount = uploadedPdfDoc.getPageCount() pages = [] for (let i = 0; i < pageCount; i++) { const page = uploadedPdfDoc.getPage(i) const { width, height } = page.getSize() pages.push({ index: i, width, height, pdfPageData: undefined }) } currentPageIndex = 0 renderCanvas() updatePageNavigation() // Hide upload area and show tool container uploadArea.classList.add('hidden') toolContainer.classList.remove('hidden') // Init icons setTimeout(() => createIcons({ icons }), 100) } catch (error) { console.error('Error loading PDF:', error) showModal('Error', 'Error loading PDF file. Please try again with a valid PDF.', 'error') } } // Page navigation prevPageBtn.addEventListener('click', () => { if (currentPageIndex > 0) { switchToPage(currentPageIndex - 1) } }) nextPageBtn.addEventListener('click', () => { if (currentPageIndex < pages.length - 1) { switchToPage(currentPageIndex + 1) } }) addPageBtn.addEventListener('click', () => { createBlankPage() switchToPage(pages.length - 1) }) resetBtn.addEventListener('click', () => { if (fields.length > 0 || pages.length > 0) { if (confirm('Are you sure you want to reset? All your work will be lost.')) { resetToInitial() } } else { resetToInitial() } }) // Custom Modal Logic const errorModal = document.getElementById('errorModal') const errorModalTitle = document.getElementById('errorModalTitle') const errorModalMessage = document.getElementById('errorModalMessage') const errorModalClose = document.getElementById('errorModalClose') let modalCloseCallback: (() => void) | null = null function showModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error', onClose?: () => void, buttonText: string = 'Close') { if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose) return errorModalTitle.textContent = title errorModalMessage.textContent = message errorModalClose.textContent = buttonText modalCloseCallback = onClose || null errorModal.classList.remove('hidden') } if (errorModalClose) { errorModalClose.addEventListener('click', () => { errorModal?.classList.add('hidden') if (modalCloseCallback) { modalCloseCallback() modalCloseCallback = null } }) } // Close modal on backdrop click if (errorModal) { errorModal.addEventListener('click', (e) => { if (e.target === errorModal) { errorModal.classList.add('hidden') if (modalCloseCallback) { modalCloseCallback() modalCloseCallback = null } } }) } initializeGlobalShortcuts()