import { PDFDocument, StandardFonts, rgb, TextAlignment, PDFName, PDFString, PageSizes, PDFBool, PDFDict, PDFArray } 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'
// 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 draggedElement: HTMLElement | null = null
let offsetX = 0
let offsetY = 0
// Multi-page state
let pages: PageData[] = []
let currentPageIndex = 0
let uploadedPdfDoc: PDFDocument | null = null
let pageSize: { width: number; height: number } = { width: 612, height: 792 }
let currentScale = 1.333
// Resize state
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, 816 - fieldWrapper.offsetWidth))
newY = Math.max(0, Math.min(newY, 1056 - 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, 816 - draggedElement.offsetWidth))
newY = Math.max(0, Math.min(newY, 1056 - 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 = `
Value
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">
Max Length (0 for unlimited)
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">
Divide into boxes (0 to disable)
Font Size
Text Color
Alignment
Left
Center
Right
Multi-line
`
} else if (field.type === 'checkbox') {
specificProps = `
Checked State
`
} else if (field.type === 'radio') {
specificProps = `
Group Name (Must be same for group)
Export Value
Checked State
`
} else if (field.type === 'dropdown' || field.type === 'optionlist') {
specificProps = `
Options (One per line or comma separated)
Selected Option
None
${field.options?.map(opt => `${opt} `).join('')}
To actually fill or change the options, use our PDF Form Filler tool.
`
} else if (field.type === 'button') {
specificProps = `
Label
Action
None
Reset Form
Print Form
Open URL
Run Javascript
Show/Hide Field
URL
Javascript Code
Target Field
Select a field...
${fields.filter(f => f.id !== field.id).map(f => `${f.name} (${f.type}) `).join('')}
Visibility
Show
Hide
Toggle
`
} 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 = `
Date Format
${formats.map(f => `${f} `).join('')}
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 = `
Label / Prompt
Clicking this field in the PDF will open a file picker to upload an image.
`
}
propertiesPanel.innerHTML = `
`
// Common listeners
const propName = document.getElementById('propName') as HTMLInputElement
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
propName.addEventListener('change', (e) => {
const newName = (e.target as HTMLInputElement).value.trim()
if (!newName) {
showModal('Invalid Name', 'Field name cannot be empty.', 'warning');
(e.target as HTMLInputElement).value = field.name
return
}
// Check for duplicate name
const isDuplicate = fields.some(f => f.id !== field.id && f.name === newName)
if (isDuplicate) {
showModal('Duplicate Name', `A field with the name "${newName}" already exists. Please choose a unique name.`, 'error');
(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
})
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 = 'None ' +
field.options?.map(opt => `${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[] = []
fields.forEach(field => {
const count = nameCount.get(field.name) || 0
nameCount.set(field.name, count + 1)
})
nameCount.forEach((count, name) => {
if (count > 1) {
duplicates.push(name)
}
})
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 / currentScale
const scaleY = 1 / currentScale
const x = field.x * scaleX
const y = pageHeight - field.y * scaleY - field.height * scaleY // PDF coordinates from bottom
const width = field.width * scaleX
const height = field.height * scaleY
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.groupName || 'RadioGroup1'
let radioGroup
if (radioGroups.has(groupName)) {
radioGroup = radioGroups.get(groupName)
} else {
radioGroup = form.createRadioGroup(groupName)
radioGroups.set(groupName, radioGroup)
}
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')
} 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'
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
function renderCanvas(): void {
const currentPage = pages[currentPageIndex]
if (!currentPage) return
// Fixed scale for better visibility
let scale = 1.333
currentScale = scale
const canvasWidth = currentPage.width * scale
const canvasHeight = currentPage.height * scale
canvas.style.width = `${canvasWidth}px`
canvas.style.height = `${canvasHeight}px`
canvas.innerHTML = ''
if (currentPage.pdfPageData) {
const img = document.createElement('img')
img.src = currentPage.pdfPageData as string
img.style.position = 'absolute'
img.style.top = '0'
img.style.left = '0'
img.style.width = '100%'
img.style.height = '100%'
img.style.pointerEvents = 'none'
img.style.opacity = '0.8' // Slightly transparent so fields are visible
canvas.appendChild(img)
}
// Render fields for current page
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
try {
const form = uploadedPdfDoc.getForm()
const fields = form.getFields()
fields.forEach(field => {
const name = field.getName()
// Check if name matches pattern Type_Number
const match = name.match(/([a-zA-Z]+)_(\d+)/)
if (match) {
const num = parseInt(match[2])
if (!isNaN(num) && num >= fieldCounter) {
fieldCounter = num
}
}
})
} catch (e) {
// No form or error getting fields, ignore
}
const pdfjsDoc = 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()
const pdfjsPage = await pdfjsDoc.getPage(i + 1)
const viewport = pdfjsPage.getViewport({ scale: 1.333 })
const tempCanvas = document.createElement('canvas')
tempCanvas.width = viewport.width
tempCanvas.height = viewport.height
const context = tempCanvas.getContext('2d')!
await pdfjsPage.render({
canvasContext: context,
viewport,
canvas: tempCanvas,
}).promise
const dataUrl = tempCanvas.toDataURL('image/png')
pages.push({
index: i,
width,
height,
pdfPageData: dataUrl as any
})
}
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')
function showModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') {
if (!errorModal || !errorModalTitle || !errorModalMessage) return
errorModalTitle.textContent = title
errorModalMessage.textContent = message
errorModal.classList.remove('hidden')
}
if (errorModalClose) {
errorModalClose.addEventListener('click', () => {
errorModal?.classList.add('hidden')
})
}
// Close modal on backdrop click
if (errorModal) {
errorModal.addEventListener('click', (e) => {
if (e.target === errorModal) {
errorModal.classList.add('hidden')
}
})
}
initializeGlobalShortcuts()