| 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'; |
|
|
| |
| pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( |
| 'pdfjs-dist/build/pdf.worker.min.mjs', |
| import.meta.url |
| ).toString(); |
|
|
| import { FormField, PageData } from '../types/index.js'; |
|
|
| let fields: FormField[] = []; |
| let selectedField: FormField | null = null; |
| let fieldCounter = 0; |
| const existingFieldNames: Set<string> = new Set(); |
| const existingRadioGroups: Set<string> = 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; |
| const gotoPageInput = document.getElementById( |
| 'gotoPageInput' |
| ) as HTMLInputElement; |
| const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement; |
|
|
| const gridVInput = document.getElementById('gridVInput') as HTMLInputElement; |
| const gridHInput = document.getElementById('gridHInput') as HTMLInputElement; |
| const toggleGridBtn = document.getElementById( |
| 'toggleGridBtn' |
| ) as HTMLButtonElement; |
| const enableGridCheckbox = document.getElementById( |
| 'enableGridCheckbox' |
| ) as HTMLInputElement; |
| let gridV = 2; |
| let gridH = 2; |
| let gridAlwaysVisible = false; |
| let gridEnabled = true; |
|
|
| if (gridVInput && gridHInput) { |
| gridVInput.value = '2'; |
| gridHInput.value = '2'; |
|
|
| const updateGrid = () => { |
| let v = parseInt(gridVInput.value) || 2; |
| let h = parseInt(gridHInput.value) || 2; |
|
|
| if (v < 2) { |
| v = 2; |
| gridVInput.value = '2'; |
| } |
| if (h < 2) { |
| h = 2; |
| gridHInput.value = '2'; |
| } |
| if (v > 14) { |
| v = 14; |
| gridVInput.value = '14'; |
| } |
| if (h > 14) { |
| h = 14; |
| gridHInput.value = '14'; |
| } |
|
|
| gridV = v; |
| gridH = h; |
|
|
| if (gridAlwaysVisible && gridEnabled) { |
| renderGrid(); |
| } |
| }; |
|
|
| gridVInput.addEventListener('input', updateGrid); |
| gridHInput.addEventListener('input', updateGrid); |
| } |
|
|
| if (enableGridCheckbox) { |
| enableGridCheckbox.addEventListener('change', (e) => { |
| gridEnabled = (e.target as HTMLInputElement).checked; |
|
|
| if (!gridEnabled) { |
| removeGrid(); |
| if (gridVInput) gridVInput.disabled = true; |
| if (gridHInput) gridHInput.disabled = true; |
| if (toggleGridBtn) toggleGridBtn.disabled = true; |
| } else { |
| if (gridVInput) gridVInput.disabled = false; |
| if (gridHInput) gridHInput.disabled = false; |
| if (toggleGridBtn) toggleGridBtn.disabled = false; |
| if (gridAlwaysVisible) renderGrid(); |
| } |
| }); |
| } |
|
|
| if (toggleGridBtn) { |
| toggleGridBtn.addEventListener('click', () => { |
| gridAlwaysVisible = !gridAlwaysVisible; |
|
|
| if (gridAlwaysVisible) { |
| toggleGridBtn.classList.add('bg-indigo-600'); |
| toggleGridBtn.classList.remove('bg-gray-600'); |
| if (gridEnabled) renderGrid(); |
| } else { |
| toggleGridBtn.classList.remove('bg-indigo-600'); |
| toggleGridBtn.classList.add('bg-gray-600'); |
| removeGrid(); |
| } |
| }); |
| } |
|
|
| function renderGrid() { |
| const existingGrid = document.getElementById('pdfGrid'); |
| if (existingGrid) existingGrid.remove(); |
|
|
| const gridContainer = document.createElement('div'); |
| gridContainer.id = 'pdfGrid'; |
| gridContainer.className = 'absolute inset-0 pointer-events-none'; |
| gridContainer.style.zIndex = '1'; |
|
|
| if (gridV > 0) { |
| const stepX = canvas.offsetWidth / gridV; |
| for (let i = 0; i <= gridV; i++) { |
| const line = document.createElement('div'); |
| line.className = |
| 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60'; |
| line.style.left = i * stepX + 'px'; |
| gridContainer.appendChild(line); |
| } |
| } |
|
|
| if (gridH > 0) { |
| const stepY = canvas.offsetHeight / gridH; |
| for (let i = 0; i <= gridH; i++) { |
| const line = document.createElement('div'); |
| line.className = |
| 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60'; |
| line.style.top = i * stepY + 'px'; |
| gridContainer.appendChild(line); |
| } |
| } |
|
|
| canvas.insertBefore(gridContainer, canvas.firstChild); |
| } |
|
|
| function removeGrid() { |
| const existingGrid = document.getElementById('pdfGrid'); |
| if (existingGrid) existingGrid.remove(); |
| } |
|
|
| if (gotoPageBtn && gotoPageInput) { |
| gotoPageBtn.addEventListener('click', () => { |
| const pageNum = parseInt(gotoPageInput.value); |
| if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) { |
| currentPageIndex = pageNum - 1; |
| renderCanvas(); |
| updatePageNavigation(); |
| } else { |
| alert(`Please enter a valid page number between 1 and ${pages.length}`); |
| } |
| }); |
|
|
| gotoPageInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { |
| gotoPageBtn.click(); |
| } |
| }); |
| } |
|
|
| |
| const toolItems = document.querySelectorAll('.tool-item'); |
| toolItems.forEach((item) => { |
| |
| 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); |
| if (gridEnabled) renderGrid(); |
| } |
| }); |
|
|
| item.addEventListener('dragend', () => { |
| if (!gridAlwaysVisible && gridEnabled) removeGrid(); |
| }); |
| item.addEventListener('click', () => { |
| const type = (item as HTMLElement).dataset.type || 'text'; |
|
|
| |
| if (selectedToolType === type) { |
| |
| selectedToolType = null; |
| item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600'); |
| canvas.style.cursor = 'default'; |
| } else { |
| |
| if (selectedToolType) { |
| toolItems.forEach((t) => |
| t.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') |
| ); |
| } |
| |
| selectedToolType = type; |
| item.classList.add('ring-2', 'ring-indigo-400', 'bg-indigo-600'); |
| canvas.style.cursor = 'crosshair'; |
| } |
| }); |
|
|
| |
| 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(); |
| const touch = e.touches[0]; |
| const moveX = Math.abs(touch.clientX - touchStartX); |
| const moveY = Math.abs(touch.clientY - touchStartY); |
|
|
| |
| if (moveX > 10 || moveY > 10) { |
| isTouchDragging = true; |
| } |
| }); |
|
|
| item.addEventListener('touchend', (e) => { |
| e.preventDefault(); |
| if (!isTouchDragging) { |
| |
| (item as HTMLElement).click(); |
| return; |
| } |
|
|
| |
| const touch = e.changedTouches[0]; |
| const canvasRect = canvas.getBoundingClientRect(); |
|
|
| |
| 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.addEventListener('dragover', (e) => { |
| e.preventDefault(); |
| if (e.dataTransfer) { |
| e.dataTransfer.dropEffect = 'copy'; |
| } |
| }); |
|
|
| canvas.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| if (!gridAlwaysVisible) removeGrid(); |
| const rect = canvas.getBoundingClientRect(); |
| const x = e.clientX - rect.left - 75; |
| 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; |
| } |
|
|
| |
| 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, |
| borderColor: '#000000', |
| hideBorder: false, |
| }; |
|
|
| fields.push(field); |
| renderField(field); |
| updateFieldCount(); |
| } |
|
|
| |
| function renderField(field: FormField): void { |
| const existingField = document.getElementById(field.id); |
| if (existingField) { |
| existingField.remove(); |
| } |
|
|
| const fieldWrapper = document.createElement('div'); |
| fieldWrapper.id = field.id; |
| fieldWrapper.className = 'absolute cursor-move group'; |
| fieldWrapper.style.left = field.x + 'px'; |
| fieldWrapper.style.top = field.y + 'px'; |
| fieldWrapper.style.width = field.width + 'px'; |
| fieldWrapper.style.overflow = 'visible'; |
| fieldWrapper.style.zIndex = '10'; |
|
|
| |
| 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; |
|
|
| |
| 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'; |
|
|
| |
| 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; |
|
|
| |
| 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 |
| ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-full h-full p-1"><polyline points="20 6 9 17 4 12"></polyline></svg>' |
| : ''; |
| } else if (field.type === 'radio') { |
| fieldContainer.classList.add('rounded-full'); |
| contentEl.innerHTML = field.checked |
| ? '<div class="w-3/4 h-3/4 bg-black rounded-full"></div>' |
| : ''; |
| } else if (field.type === 'dropdown') { |
| contentEl.className = |
| 'w-full h-full flex items-center px-2 text-sm text-black'; |
| contentEl.style.backgroundColor = '#e6f0ff'; |
|
|
| |
| 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 = |
| '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="m6 9 6 6 6-6"/></svg>'; |
| 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'; |
| |
| 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; |
|
|
| |
| 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 { |
| |
| 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 = |
| '<div class="flex flex-col items-center"><i data-lucide="pen-tool" class="w-6 h-6 mb-1"></i><span class="text-[10px]">Sign Here</span></div>'; |
| 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 = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`; |
| 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 = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`; |
| setTimeout(() => (window as any).lucide?.createIcons(), 0); |
| } |
|
|
| fieldContainer.appendChild(contentEl); |
| fieldWrapper.appendChild(label); |
| fieldWrapper.appendChild(fieldContainer); |
|
|
| |
| fieldWrapper.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| selectField(field); |
| }); |
|
|
| |
| fieldWrapper.addEventListener('mousedown', (e) => { |
| |
| 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); |
| if (gridEnabled) renderGrid(); |
| e.preventDefault(); |
| }); |
|
|
| |
| 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; |
| }); |
|
|
| |
| 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`; |
| const positions: Record<string, string> = { |
| 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); |
| }); |
|
|
| |
| handle.addEventListener('touchstart', (e) => { |
| e.stopPropagation(); |
| e.preventDefault(); |
| const touch = e.touches[0]; |
| |
| 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(); |
| } |
|
|
| |
| 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'; |
| } |
| |
| 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; |
| if (!gridAlwaysVisible) removeGrid(); |
| }); |
|
|
| 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; |
| }); |
|
|
| |
| 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) { |
| |
| 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); |
| } |
|
|
| |
| 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) { |
| |
| 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(); |
| } |
|
|
| |
| function showProperties(field: FormField): void { |
| let specificProps = ''; |
|
|
| if (field.type === 'text') { |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Value</label> |
| <input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label> |
| <input type="number" id="propMaxLength" value="${field.maxLength}" min="0" ${field.combCells > 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Divide into boxes (0 to disable)</label> |
| <input type="number" id="propComb" value="${field.combCells}" min="0" 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Font Size</label> |
| <input type="number" id="propFontSize" value="${field.fontSize}" min="8" max="72" 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label> |
| <input type="color" id="propTextColor" value="${field.textColor}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label> |
| <select id="propAlignment" 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"> |
| <option value="left" ${field.alignment === 'left' ? 'selected' : ''}>Left</option> |
| <option value="center" ${field.alignment === 'center' ? 'selected' : ''}>Center</option> |
| <option value="right" ${field.alignment === 'right' ? 'selected' : ''}>Right</option> |
| </select> |
| </div> |
| <div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2"> |
| <label for="propMultiline" class="text-xs font-semibold text-gray-300">Multi-line</label> |
| <button id="propMultilineBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.multiline ? 'bg-indigo-600' : 'bg-gray-500'} relative"> |
| <span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.multiline ? 'translate-x-6' : 'translate-x-0'}"></span> |
| </button> |
| </div> |
| `; |
| } else if (field.type === 'checkbox') { |
| specificProps = ` |
| <div class="flex items-center justify-between bg-gray-600 p-2 rounded"> |
| <label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label> |
| <button id="propCheckedBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.checked ? 'bg-indigo-600' : 'bg-gray-500'} relative"> |
| <span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.checked ? 'translate-x-6' : 'translate-x-0'}"></span> |
| </button> |
| </div> |
| `; |
| } else if (field.type === 'radio') { |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label> |
| <input type="text" id="propGroupName" value="${field.groupName}" 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label> |
| <input type="text" id="propExportValue" value="${field.exportValue}" 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"> |
| </div> |
| <div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2"> |
| <label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label> |
| <button id="propCheckedBtn" class="w-12 h-6 rounded-full transition-colors duration-200 focus:outline-none ${field.checked ? 'bg-indigo-600' : 'bg-gray-500'} relative"> |
| <span class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ${field.checked ? 'translate-x-6' : 'translate-x-0'}"></span> |
| </button> |
| </div> |
| `; |
| } else if (field.type === 'dropdown' || field.type === 'optionlist') { |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label> |
| <textarea id="propOptions" 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 h-24">${field.options?.join('\n')}</textarea> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label> |
| <select id="propSelectedOption" 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"> |
| <option value="">None</option> |
| ${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')} |
| </select> |
| </div> |
| <div class="text-xs text-gray-400 italic mt-2"> |
| To actually fill or change the options, use our PDF Form Filler tool. |
| </div> |
| `; |
| } else if (field.type === 'button') { |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Label</label> |
| <input type="text" id="propLabel" value="${field.label}" 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"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Action</label> |
| <select id="propAction" 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"> |
| <option value="none" ${field.action === 'none' ? 'selected' : ''}>None</option> |
| <option value="reset" ${field.action === 'reset' ? 'selected' : ''}>Reset Form</option> |
| <option value="print" ${field.action === 'print' ? 'selected' : ''}>Print Form</option> |
| <option value="url" ${field.action === 'url' ? 'selected' : ''}>Open URL</option> |
| <option value="js" ${field.action === 'js' ? 'selected' : ''}>Run Javascript</option> |
| <option value="showHide" ${field.action === 'showHide' ? 'selected' : ''}>Show/Hide Field</option> |
| </select> |
| </div> |
| <div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}"> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">URL</label> |
| <input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" 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"> |
| </div> |
| <div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}"> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label> |
| <textarea id="propJsScript" 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 h-24 font-mono">${field.jsScript || ''}</textarea> |
| </div> |
| <div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}"> |
| <div class="mb-2"> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Target Field</label> |
| <select id="propTargetField" 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"> |
| <option value="">Select a field...</option> |
| ${fields |
| .filter((f) => f.id !== field.id) |
| .map( |
| (f) => |
| `<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>` |
| ) |
| .join('')} |
| </select> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Visibility</label> |
| <select id="propVisibilityAction" 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"> |
| <option value="show" ${field.visibilityAction === 'show' ? 'selected' : ''}>Show</option> |
| <option value="hide" ${field.visibilityAction === 'hide' ? 'selected' : ''}>Hide</option> |
| <option value="toggle" ${field.visibilityAction === 'toggle' ? 'selected' : ''}>Toggle</option> |
| </select> |
| </div> |
| </div> |
| `; |
| } else if (field.type === 'signature') { |
| specificProps = ` |
| <div class="text-xs text-gray-400 italic mb-2"> |
| Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer. |
| </div> |
| `; |
| } else if (field.type === 'date') { |
| const formats = [ |
| 'm/d', |
| 'm/d/yy', |
| 'm/d/yyyy', |
| 'mm/dd/yy', |
| 'mm/dd/yyyy', |
| 'mm/yy', |
| 'mm/yyyy', |
| 'd-mmm', |
| 'd-mmm-yy', |
| 'd-mmm-yyyy', |
| 'dd-mmm-yy', |
| 'dd-mmm-yyyy', |
| 'yy-mm-dd', |
| 'yyyy-mm-dd', |
| 'mmm-yy', |
| 'mmm-yyyy', |
| 'mmm d, yyyy', |
| 'mmmm-yy', |
| 'mmmm-yyyy', |
| 'mmmm d, yyyy', |
| 'dd/mm/yy', |
| 'dd/mm/yyyy', |
| 'yyyy/mm/dd', |
| 'dd.mm.yy', |
| 'dd.mm.yyyy', |
| 'm/d/yy h:MM tt', |
| 'm/d/yyyy h:MM tt', |
| 'm/d/yy HH:MM', |
| 'm/d/yyyy HH:MM', |
| 'yyyy-mm', |
| 'yyyy', |
| ]; |
| const isCustom = !formats.includes(field.dateFormat || 'mm/dd/yyyy'); |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Date Format</label> |
| <select id="propDateFormat" 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"> |
| ${formats.map((f) => `<option value="${f}" ${field.dateFormat === f ? 'selected' : ''}>${f}</option>`).join('')} |
| <option value="custom" ${isCustom ? 'selected' : ''}>Custom</option> |
| </select> |
| </div> |
| <div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2"> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label> |
| <input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" 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"> |
| </div> |
| <div class="mt-3 p-2 bg-gray-700 rounded"> |
| <span class="text-xs text-gray-400">Example of current format:</span> |
| <span id="dateFormatExample" class="text-sm text-white font-medium ml-2"></span> |
| </div> |
| <div class="bg-blue-900/30 border border-blue-700/50 rounded p-2 mt-2"> |
| <p class="text-xs text-blue-200"> |
| <i data-lucide="info" class="w-4 h-4 flex-shrink-0 mt-0.5"></i> |
| <span><strong>Browser Note:</strong> Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date.</span> |
| </p> |
| </div> |
| `; |
| } else if (field.type === 'image') { |
| specificProps = ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label> |
| <input type="text" id="propLabel" value="${field.label}" 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"> |
| </div> |
| <div class="text-xs text-gray-400 italic mt-2"> |
| Clicking this field in the PDF will open a file picker to upload an image. |
| </div> |
| `; |
| } |
|
|
| propertiesPanel.innerHTML = ` |
| <div class="space-y-3"> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label> |
| <input type="text" id="propName" value="${field.name}" 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"> |
| <div id="nameError" class="hidden text-red-400 text-xs mt-1"></div> |
| </div> |
| ${ |
| field.type === 'radio' && |
| (existingRadioGroups.size > 0 || |
| fields.some((f) => f.type === 'radio' && f.id !== field.id)) |
| ? ` |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Existing Radio Groups</label> |
| <select id="existingGroups" 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"> |
| <option value="">-- Select existing group --</option> |
| ${Array.from(existingRadioGroups) |
| .map((name) => `<option value="${name}">${name}</option>`) |
| .join('')} |
| ${Array.from( |
| new Set( |
| fields |
| .filter((f) => f.type === 'radio' && f.id !== field.id) |
| .map((f) => f.name) |
| ) |
| ) |
| .map((name) => |
| !existingRadioGroups.has(name) |
| ? `<option value="${name}">${name}</option>` |
| : '' |
| ) |
| .join('')} |
| </select> |
| <p class="text-xs text-gray-400 mt-1">Select to add this button to an existing group</p> |
| </div> |
| ` |
| : '' |
| } |
| ${specificProps} |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label> |
| <input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" 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"> |
| </div> |
| <div class="flex items-center"> |
| <input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2"> |
| <label for="propRequired" class="text-xs font-semibold text-gray-300">Required</label> |
| </div> |
| <div class="flex items-center"> |
| <input type="checkbox" id="propReadOnly" ${field.readOnly ? 'checked' : ''} class="mr-2"> |
| <label for="propReadOnly" class="text-xs font-semibold text-gray-300">Read Only</label> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label> |
| <input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> |
| </div> |
| <div class="flex items-center"> |
| <input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2"> |
| <label for="propHideBorder" class="text-xs font-semibold text-gray-300">Hide Border</label> |
| </div> |
| <button id="deleteBtn" class="w-full bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-sm font-semibold"> |
| Delete Field |
| </button> |
| </div> |
| `; |
|
|
| |
| 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); |
|
|
| |
| 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; |
| }); |
|
|
| const propBorderColor = document.getElementById( |
| 'propBorderColor' |
| ) as HTMLInputElement; |
| const propHideBorder = document.getElementById( |
| 'propHideBorder' |
| ) as HTMLInputElement; |
|
|
| propBorderColor.addEventListener('input', (e) => { |
| field.borderColor = (e.target as HTMLInputElement).value; |
| }); |
|
|
| propHideBorder.addEventListener('change', (e) => { |
| field.hideBorder = (e.target as HTMLInputElement).checked; |
| }); |
|
|
| deleteBtn.addEventListener('click', () => { |
| deleteField(field); |
| }); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| const fieldWrapper = document.getElementById(field.id); |
| if (fieldWrapper) { |
| |
| 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; |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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; |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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 |
| ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-full h-full p-1"><polyline points="20 6 9 17 4 12"></polyline></svg>' |
| : ''; |
| } else { |
| contentEl.innerHTML = field.checked |
| ? '<div class="w-3/4 h-3/4 bg-black rounded-full"></div>' |
| : ''; |
| } |
| } |
| } |
| }); |
|
|
| 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) => { |
| |
| 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 = |
| '<option value="">None</option>' + |
| field.options |
| ?.map( |
| (opt) => |
| `<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>` |
| ) |
| .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; |
|
|
| |
| 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; |
|
|
| |
| 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') { |
| |
| } else if (field.type === 'date') { |
| const propDateFormat = document.getElementById( |
| 'propDateFormat' |
| ) as HTMLSelectElement; |
| const customFormatContainer = document.getElementById( |
| 'customFormatContainer' |
| ) as HTMLDivElement; |
| const propCustomFormat = document.getElementById( |
| 'propCustomFormat' |
| ) as HTMLInputElement; |
| const dateFormatExample = document.getElementById( |
| 'dateFormatExample' |
| ) as HTMLSpanElement; |
|
|
| const formatDateExample = (format: string): string => { |
| const now = new Date(); |
| const d = now.getDate(); |
| const dd = d.toString().padStart(2, '0'); |
| const m = now.getMonth() + 1; |
| const mm = m.toString().padStart(2, '0'); |
| const yy = now.getFullYear().toString().slice(-2); |
| const yyyy = now.getFullYear().toString(); |
| const h = now.getHours() % 12 || 12; |
| const HH = now.getHours().toString().padStart(2, '0'); |
| const MM = now.getMinutes().toString().padStart(2, '0'); |
| const tt = now.getHours() >= 12 ? 'PM' : 'AM'; |
| const monthNames = [ |
| 'Jan', |
| 'Feb', |
| 'Mar', |
| 'Apr', |
| 'May', |
| 'Jun', |
| 'Jul', |
| 'Aug', |
| 'Sep', |
| 'Oct', |
| 'Nov', |
| 'Dec', |
| ]; |
| const monthNamesFull = [ |
| 'January', |
| 'February', |
| 'March', |
| 'April', |
| 'May', |
| 'June', |
| 'July', |
| 'August', |
| 'September', |
| 'October', |
| 'November', |
| 'December', |
| ]; |
| const mmm = monthNames[now.getMonth()]; |
| const mmmm = monthNamesFull[now.getMonth()]; |
|
|
| return format |
| .replace(/mmmm/g, mmmm) |
| .replace(/mmm/g, mmm) |
| .replace(/mm/g, mm) |
| .replace(/m/g, m.toString()) |
| .replace(/dddd/g, dd) |
| .replace(/dd/g, dd) |
| .replace(/d/g, d.toString()) |
| .replace(/yyyy/g, yyyy) |
| .replace(/yy/g, yy) |
| .replace(/HH/g, HH) |
| .replace(/h/g, h.toString()) |
| .replace(/MM/g, MM) |
| .replace(/tt/g, tt); |
| }; |
|
|
| const updateExample = () => { |
| if (dateFormatExample) { |
| dateFormatExample.textContent = formatDateExample( |
| field.dateFormat || 'mm/dd/yyyy' |
| ); |
| } |
| }; |
|
|
| updateExample(); |
|
|
| if (propDateFormat) { |
| propDateFormat.addEventListener('change', (e) => { |
| const value = (e.target as HTMLSelectElement).value; |
| if (value === 'custom') { |
| customFormatContainer?.classList.remove('hidden'); |
| if (propCustomFormat && propCustomFormat.value) { |
| field.dateFormat = propCustomFormat.value; |
| } |
| } else { |
| customFormatContainer?.classList.add('hidden'); |
| field.dateFormat = value; |
| } |
| updateExample(); |
| const fieldWrapper = document.getElementById(field.id); |
| if (fieldWrapper) { |
| const textSpan = fieldWrapper.querySelector( |
| '.date-format-text' |
| ) as HTMLElement; |
| if (textSpan) { |
| textSpan.textContent = field.dateFormat; |
| } |
| } |
| setTimeout(() => (window as any).lucide?.createIcons(), 0); |
| }); |
| } |
|
|
| if (propCustomFormat) { |
| propCustomFormat.addEventListener('input', (e) => { |
| field.dateFormat = (e.target as HTMLInputElement).value || 'mm/dd/yyyy'; |
| updateExample(); |
| const fieldWrapper = document.getElementById(field.id); |
| if (fieldWrapper) { |
| const textSpan = fieldWrapper.querySelector( |
| '.date-format-text' |
| ) as HTMLElement; |
| if (textSpan) { |
| textSpan.textContent = field.dateFormat; |
| } |
| } |
| }); |
| } |
| } 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); |
| }); |
| } |
| } |
|
|
| |
| function hideProperties(): void { |
| propertiesPanel.innerHTML = |
| '<p class="text-gray-500 text-sm">Select a field to edit properties</p>'; |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Delete' && selectedField) { |
| deleteField(selectedField); |
| } else if (e.key === 'Escape' && selectedToolType) { |
| |
| toolItems.forEach((item) => |
| item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') |
| ); |
| selectedToolType = null; |
| canvas.style.cursor = 'default'; |
| } |
| }); |
|
|
| |
| function updateFieldCount(): void { |
| fieldCountDisplay.textContent = fields.length.toString(); |
| } |
|
|
| |
| downloadBtn.addEventListener('click', async () => { |
| |
| const nameCount = new Map<string, number>(); |
| 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); |
|
|
| |
| pdfDoc.setTitle('Fillable Form'); |
| pdfDoc.setAuthor('BentoPDF'); |
| pdfDoc.setLanguage('en-US'); |
|
|
| const radioGroups = new Map<string, any>(); |
|
|
| 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); |
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
|
|
| textField.addToPage(pdfPage, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| backgroundColor: rgb(1, 1, 1), |
| textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b), |
| }); |
|
|
| textField.setText(field.defaultValue); |
| textField.setFontSize(field.fontSize); |
|
|
| |
| if (field.alignment === 'center') { |
| textField.setAlignment(TextAlignment.Center); |
| } else if (field.alignment === 'right') { |
| textField.setAlignment(TextAlignment.Right); |
| } else { |
| textField.setAlignment(TextAlignment.Left); |
| } |
|
|
| |
| if (field.combCells > 0) { |
| textField.setMaxLength(field.combCells); |
| textField.enableCombing(); |
| } else if (field.maxLength > 0) { |
| textField.setMaxLength(field.maxLength); |
| } |
|
|
| |
| if (!field.multiline) { |
| textField.disableMultiline(); |
| } else { |
| textField.enableMultiline(); |
| } |
|
|
| |
| 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); |
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
| checkBox.addToPage(pdfPage, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| 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}`); |
| } |
| } |
|
|
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
| radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| 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); |
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
| dropdown.addToPage(pdfPage, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| backgroundColor: rgb(1, 1, 1), |
| }); |
| 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); |
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
| optionList.addToPage(pdfPage, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| 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); |
| const borderRgb = hexToRgb(field.borderColor || '#000000'); |
| button.addToPage(field.label || 'Button', pdfPage, { |
| x: x, |
| y: y, |
| width: width, |
| height: height, |
| borderWidth: field.hideBorder ? 0 : 1, |
| borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b), |
| backgroundColor: rgb(0.8, 0.8, 0.8), |
| }); |
|
|
| |
| 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') { |
| |
| actionDict = pdfDoc.context.obj({ |
| Type: 'Action', |
| S: 'JavaScript', |
| JS: 'print();', |
| }); |
| } else if (field.action === 'url' && field.actionUrl) { |
| |
| let url = field.actionUrl.trim(); |
| if (!url.startsWith('http://') && !url.startsWith('https://')) { |
| url = 'https://' + url; |
| } |
|
|
| |
| 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 { |
| |
| 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), |
| }); |
|
|
| |
| 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}");`), |
| }); |
|
|
| |
| 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), |
| }); |
|
|
| |
| 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); |
|
|
| |
| |
| |
| const mkDict = pdfDoc.context.obj({ |
| TP: 1, |
| BG: [0.9, 0.9, 0.9], |
| BC: [0, 0, 0], |
| 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; |
|
|
| |
| const sigDict = context.obj({ |
| FT: PDFName.of('Sig'), |
| T: PDFString.of(field.name), |
| Kids: [], |
| }) as PDFDict; |
| const sigRef = context.register(sigDict); |
|
|
| |
| const widgetDict = context.obj({ |
| Type: PDFName.of('Annot'), |
| Subtype: PDFName.of('Widget'), |
| Rect: [x, y, x + width, y + height], |
| F: 4, |
| P: pdfPage.ref, |
| Parent: sigRef, |
| }) as PDFDict; |
|
|
| |
| const borderStyle = context.obj({ |
| W: 1, |
| S: PDFName.of('S'), |
| }) as PDFDict; |
| widgetDict.set(PDFName.of('BS'), borderStyle); |
| widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])); |
| widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])); |
|
|
| 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); |
|
|
| |
| 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; |
|
|
| |
| if ( |
| errorMessage.includes('A field already exists with the specified name') |
| ) { |
| |
| 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'); |
| } |
| } |
| }); |
|
|
| |
| const backToToolsBtns = document.querySelectorAll( |
| '[id^="back-to-tools"]' |
| ) as NodeListOf<HTMLButtonElement>; |
| backToToolsBtns.forEach((btn) => { |
| btn.addEventListener('click', () => { |
| window.location.href = import.meta.env.BASE_URL; |
| }); |
| }); |
|
|
| 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': |
| |
| 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] }; |
| } |
|
|
| |
| function resetToInitial(): void { |
| fields = []; |
| pages = []; |
| currentPageIndex = 0; |
| uploadedPdfDoc = null; |
| selectedField = null; |
|
|
| canvas.innerHTML = ''; |
|
|
| propertiesPanel.innerHTML = |
| '<p class="text-gray-500 text-sm">Select a field to edit properties</p>'; |
|
|
| updateFieldCount(); |
|
|
| |
| 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(); |
|
|
| |
| deselectAll(); |
| } |
|
|
| |
| async function renderCanvas(): Promise<void> { |
| const currentPage = pages[currentPageIndex]; |
| if (!currentPage) return; |
|
|
| |
| const 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 (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 = `${import.meta.env.BASE_URL}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; |
| } |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| existingFieldNames.clear(); |
| try { |
| const form = uploadedPdfDoc.getForm(); |
| const pdfFields = form.getFields(); |
|
|
| |
|
|
| pdfFields.forEach((field) => { |
| const name = field.getName(); |
| existingFieldNames.add(name); |
|
|
| if (field instanceof PDFRadioGroup) { |
| existingRadioGroups.add(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); |
| } |
| } |
| }); |
|
|
| |
| |
| |
| } 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(); |
|
|
| |
| uploadArea.classList.add('hidden'); |
| toolContainer.classList.remove('hidden'); |
|
|
| |
| 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' |
| ); |
| } |
| } |
|
|
| |
| 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(); |
| } |
| }); |
|
|
| |
| 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; |
| } |
| }); |
| } |
|
|
| |
| if (errorModal) { |
| errorModal.addEventListener('click', (e) => { |
| if (e.target === errorModal) { |
| errorModal.classList.add('hidden'); |
| if (modalCloseCallback) { |
| modalCloseCallback(); |
| modalCloseCallback = null; |
| } |
| } |
| }); |
| } |
|
|
| initializeGlobalShortcuts(); |
|
|