3300 lines
115 KiB
TypeScript
3300 lines
115 KiB
TypeScript
import {
|
|
PDFDocument,
|
|
StandardFonts,
|
|
rgb,
|
|
TextAlignment,
|
|
PDFName,
|
|
PDFString,
|
|
PageSizes,
|
|
PDFDict,
|
|
PDFArray,
|
|
PDFRadioGroup,
|
|
} from 'pdf-lib';
|
|
|
|
type FormFieldAction = NonNullable<FormField['action']>;
|
|
type FormFieldVisibilityAction = NonNullable<FormField['visibilityAction']>;
|
|
type LucideWindow = Window & {
|
|
lucide?: {
|
|
createIcons(): void;
|
|
};
|
|
};
|
|
type PdfViewerApplicationLike = {
|
|
pdfViewer?: {
|
|
pagesCount: number;
|
|
};
|
|
};
|
|
type PdfViewerWindow = Window & {
|
|
PDFViewerApplication?: PdfViewerApplicationLike;
|
|
};
|
|
|
|
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
|
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
|
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
|
import { createIcons, icons } from 'lucide';
|
|
import * as pdfjsLib from 'pdfjs-dist';
|
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
|
import * as bwipjs from 'bwip-js/browser';
|
|
import 'pdfjs-dist/web/pdf_viewer.css';
|
|
|
|
// Initialize PDF.js worker
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
import.meta.url
|
|
).toString();
|
|
|
|
import {
|
|
ExtractExistingFieldsResult,
|
|
FormCreatorFieldType,
|
|
FormField,
|
|
PageData,
|
|
} from '@/types';
|
|
import { extractExistingFields as extractExistingPdfFields } from './form-creator-extraction.js';
|
|
import { loadPdfDocument } from '../utils/load-pdf-document.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 pendingFieldExtraction = false;
|
|
|
|
let pages: PageData[] = [];
|
|
let currentPageIndex = 0;
|
|
let uploadedPdfDoc: PDFDocument | null = null;
|
|
let uploadedPdfjsDoc: PDFDocumentProxy | null = null;
|
|
let uploadedFileName: string | null = 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Tool item interactions
|
|
const toolItems = document.querySelectorAll('.tool-item');
|
|
toolItems.forEach((item) => {
|
|
// Drag from toolbar
|
|
item.addEventListener('dragstart', (e) => {
|
|
if (e instanceof DragEvent && e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
const type = (item as HTMLElement).dataset.type || 'text';
|
|
e.dataTransfer.setData('text/plain', type);
|
|
if (gridEnabled) renderGrid();
|
|
}
|
|
});
|
|
|
|
item.addEventListener('dragend', () => {
|
|
if (!gridAlwaysVisible && gridEnabled) removeGrid();
|
|
});
|
|
item.addEventListener('click', () => {
|
|
const type = (item as HTMLElement).dataset.type || 'text';
|
|
|
|
// Toggle selection
|
|
if (selectedToolType === type) {
|
|
// Deselect
|
|
selectedToolType = null;
|
|
item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600');
|
|
canvas.style.cursor = 'default';
|
|
} else {
|
|
// Deselect previous tool
|
|
if (selectedToolType) {
|
|
toolItems.forEach((t) =>
|
|
t.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')
|
|
);
|
|
}
|
|
// Select new tool
|
|
selectedToolType = type;
|
|
item.classList.add('ring-2', 'ring-indigo-400', 'bg-indigo-600');
|
|
canvas.style.cursor = 'crosshair';
|
|
}
|
|
});
|
|
|
|
// Touch events for mobile drag
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
let isTouchDragging = false;
|
|
|
|
item.addEventListener('touchstart', (e: TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
touchStartX = touch.clientX;
|
|
touchStartY = touch.clientY;
|
|
isTouchDragging = false;
|
|
});
|
|
|
|
item.addEventListener('touchmove', (e: TouchEvent) => {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
const moveX = Math.abs(touch.clientX - touchStartX);
|
|
const moveY = Math.abs(touch.clientY - touchStartY);
|
|
|
|
// If moved more than 10px, it's a drag not a click
|
|
if (moveX > 10 || moveY > 10) {
|
|
isTouchDragging = true;
|
|
}
|
|
});
|
|
|
|
item.addEventListener('touchend', (e: TouchEvent) => {
|
|
e.preventDefault();
|
|
if (!isTouchDragging) {
|
|
// It was a tap, treat as click
|
|
(item as HTMLElement).click();
|
|
return;
|
|
}
|
|
|
|
// It was a drag, place field at touch end position
|
|
const touch = e.changedTouches[0];
|
|
const canvasRect = canvas.getBoundingClientRect();
|
|
|
|
// Check if touch ended on canvas
|
|
if (
|
|
touch.clientX >= canvasRect.left &&
|
|
touch.clientX <= canvasRect.right &&
|
|
touch.clientY >= canvasRect.top &&
|
|
touch.clientY <= canvasRect.bottom
|
|
) {
|
|
const x = touch.clientX - canvasRect.left - 75;
|
|
const y = touch.clientY - canvasRect.top - 15;
|
|
const type = ((item as HTMLElement).dataset.type ||
|
|
'text') as FormCreatorFieldType;
|
|
createField(type, x, y);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Canvas drop zone
|
|
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') as FormCreatorFieldType;
|
|
createField(type, 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 FormCreatorFieldType, x, y);
|
|
|
|
toolItems.forEach((item) =>
|
|
item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')
|
|
);
|
|
selectedToolType = null;
|
|
canvas.style.cursor = 'default';
|
|
return;
|
|
}
|
|
|
|
// Existing deselect behavior (only if no tool is selected)
|
|
if (e.target === canvas) {
|
|
deselectAll();
|
|
}
|
|
});
|
|
|
|
function createField(type: FormField['type'], x: number, y: number): void {
|
|
fieldCounter++;
|
|
const field: FormField = {
|
|
id: `field_${fieldCounter}`,
|
|
type: type,
|
|
x: Math.max(0, Math.min(x, 816 - 150)),
|
|
y: Math.max(0, Math.min(y, 1056 - 30)),
|
|
width:
|
|
type === 'checkbox' || type === 'radio'
|
|
? 30
|
|
: type === 'barcode'
|
|
? 150
|
|
: 150,
|
|
height:
|
|
type === 'checkbox' || type === 'radio'
|
|
? 30
|
|
: type === 'barcode'
|
|
? 150
|
|
: 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,
|
|
transparentBackground: false,
|
|
barcodeFormat: type === 'barcode' ? 'qrcode' : undefined,
|
|
barcodeValue: type === 'barcode' ? 'https://example.com' : undefined,
|
|
};
|
|
|
|
fields.push(field);
|
|
renderField(field);
|
|
updateFieldCount();
|
|
}
|
|
|
|
function hasTransparentBackground(field: FormField): boolean {
|
|
return Boolean(field.transparentBackground);
|
|
}
|
|
|
|
function applyFieldContainerState(
|
|
container: HTMLElement,
|
|
field: FormField,
|
|
selected: boolean
|
|
): void {
|
|
container.classList.remove(
|
|
'border-indigo-200',
|
|
'group-hover:border-dashed',
|
|
'group-hover:border-indigo-300',
|
|
'border-dashed',
|
|
'border-indigo-500',
|
|
'bg-indigo-50',
|
|
'bg-indigo-50/30',
|
|
'bg-transparent'
|
|
);
|
|
|
|
if (selected) {
|
|
container.classList.add('border-dashed', 'border-indigo-500');
|
|
container.classList.add(
|
|
hasTransparentBackground(field) ? 'bg-transparent' : 'bg-indigo-50'
|
|
);
|
|
return;
|
|
}
|
|
|
|
container.classList.add(
|
|
'border-indigo-200',
|
|
'group-hover:border-dashed',
|
|
'group-hover:border-indigo-300'
|
|
);
|
|
container.classList.add(
|
|
hasTransparentBackground(field) ? 'bg-transparent' : 'bg-indigo-50/30'
|
|
);
|
|
}
|
|
|
|
function getPreviewBackgroundColor(
|
|
field: FormField,
|
|
fallbackColor: string
|
|
): string {
|
|
return hasTransparentBackground(field) ? 'transparent' : fallbackColor;
|
|
}
|
|
|
|
function getPdfBackgroundOptions(
|
|
field: FormField,
|
|
red: number,
|
|
green: number,
|
|
blue: number
|
|
): { backgroundColor?: ReturnType<typeof rgb> } {
|
|
if (hasTransparentBackground(field)) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
backgroundColor: rgb(red, green, blue),
|
|
};
|
|
}
|
|
|
|
function clearTransparentWidgetBackground(
|
|
field: FormField,
|
|
widgetDict: PDFDict,
|
|
pdfDoc: PDFDocument
|
|
): void {
|
|
if (!hasTransparentBackground(field)) {
|
|
return;
|
|
}
|
|
|
|
widgetDict.delete(PDFName.of('BG'));
|
|
|
|
const mk = widgetDict.get(PDFName.of('MK'));
|
|
const mkDict = mk ? pdfDoc.context.lookupMaybe(mk, PDFDict) : undefined;
|
|
mkDict?.delete(PDFName.of('BG'));
|
|
}
|
|
|
|
function clearTransparentFieldWidgetBackgrounds(
|
|
field: FormField,
|
|
widgets: Array<{ dict: PDFDict }>,
|
|
pdfDoc: PDFDocument
|
|
): void {
|
|
if (!hasTransparentBackground(field)) {
|
|
return;
|
|
}
|
|
|
|
widgets.forEach((widget) => {
|
|
clearTransparentWidgetBackground(field, widget.dict, pdfDoc);
|
|
});
|
|
}
|
|
|
|
function rerenderSelectedField(field: FormField): void {
|
|
const shouldReselect = selectedField?.id === field.id;
|
|
renderField(field);
|
|
|
|
if (shouldReselect) {
|
|
selectField(field);
|
|
}
|
|
}
|
|
|
|
// Render field on canvas
|
|
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'; // Added group for hover effects
|
|
fieldWrapper.style.left = field.x + 'px';
|
|
fieldWrapper.style.top = field.y + 'px';
|
|
fieldWrapper.style.width = field.width + 'px';
|
|
fieldWrapper.style.overflow = 'visible';
|
|
fieldWrapper.style.zIndex = '10'; // Ensure fields are above grid and PDF
|
|
|
|
// Create label - hidden by default, shown on group hover or selection
|
|
const label = document.createElement('div');
|
|
label.className =
|
|
'field-label absolute left-0 w-full text-xs font-semibold pointer-events-none select-none opacity-0 group-hover:opacity-100 transition-opacity';
|
|
label.style.bottom = '100%';
|
|
label.style.marginBottom = '4px';
|
|
label.style.color = '#374151';
|
|
label.style.fontSize = '11px';
|
|
label.style.lineHeight = '1';
|
|
label.style.whiteSpace = 'nowrap';
|
|
label.style.overflow = 'hidden';
|
|
label.style.textOverflow = 'ellipsis';
|
|
label.textContent = field.name;
|
|
|
|
// Create input container - light border by default, dashed on hover
|
|
const fieldContainer = document.createElement('div');
|
|
fieldContainer.className =
|
|
'field-container relative border-2 rounded transition-all';
|
|
fieldContainer.style.width = '100%';
|
|
fieldContainer.style.height = field.height + 'px';
|
|
applyFieldContainerState(fieldContainer, field, false);
|
|
|
|
// Create content based on type
|
|
const contentEl = document.createElement('div');
|
|
contentEl.className =
|
|
'field-content w-full h-full flex items-center justify-center overflow-hidden';
|
|
|
|
if (field.type === 'text') {
|
|
contentEl.className =
|
|
'field-text w-full h-full flex items-center px-2 text-sm overflow-hidden';
|
|
contentEl.style.fontSize = field.fontSize + 'px';
|
|
contentEl.style.textAlign = field.alignment;
|
|
contentEl.style.justifyContent =
|
|
field.alignment === 'left'
|
|
? 'flex-start'
|
|
: field.alignment === 'right'
|
|
? 'flex-end'
|
|
: 'center';
|
|
contentEl.style.color = field.textColor;
|
|
contentEl.style.whiteSpace = field.multiline ? 'pre-wrap' : 'nowrap';
|
|
contentEl.style.textOverflow = 'ellipsis';
|
|
contentEl.style.alignItems = field.multiline ? 'flex-start' : 'center';
|
|
contentEl.textContent = field.defaultValue;
|
|
|
|
// Apply combing visual if enabled
|
|
if (field.combCells > 0) {
|
|
contentEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`;
|
|
contentEl.style.fontFamily = 'monospace';
|
|
contentEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`;
|
|
contentEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`;
|
|
contentEl.style.overflow = 'hidden';
|
|
contentEl.style.textAlign = 'left';
|
|
contentEl.style.justifyContent = 'flex-start';
|
|
}
|
|
} else if (field.type === 'checkbox') {
|
|
contentEl.innerHTML = field.checked
|
|
? '<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'); // Make container round for radio
|
|
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 = getPreviewBackgroundColor(
|
|
field,
|
|
'#e6f0ff'
|
|
);
|
|
|
|
// Show selected option or first option or placeholder
|
|
let displayText = 'Select...';
|
|
if (
|
|
field.defaultValue &&
|
|
field.options &&
|
|
field.options.includes(field.defaultValue)
|
|
) {
|
|
displayText = field.defaultValue;
|
|
} else if (field.options && field.options.length > 0) {
|
|
displayText = field.options[0];
|
|
}
|
|
contentEl.textContent = displayText;
|
|
|
|
const arrow = document.createElement('div');
|
|
arrow.className = 'absolute right-1 top-1/2 -translate-y-1/2';
|
|
arrow.innerHTML =
|
|
'<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 overflow-hidden border border-gray-300';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#ffffff'
|
|
);
|
|
// Render options as a list
|
|
if (field.options && field.options.length > 0) {
|
|
field.options.forEach((opt, index) => {
|
|
const optEl = document.createElement('div');
|
|
optEl.className = 'px-1 w-full truncate';
|
|
optEl.textContent = opt;
|
|
|
|
// Highlight selected option (defaultValue) or first one if no selection
|
|
const isSelected = field.defaultValue
|
|
? field.defaultValue === opt
|
|
: index === 0;
|
|
|
|
if (isSelected) {
|
|
optEl.className += ' bg-blue-600 text-white';
|
|
} else {
|
|
optEl.className += ' text-black';
|
|
}
|
|
contentEl.appendChild(optEl);
|
|
});
|
|
} else {
|
|
// Empty state
|
|
const optEl = document.createElement('div');
|
|
optEl.className = 'px-1 w-full text-black italic';
|
|
optEl.textContent = 'Item 1';
|
|
contentEl.appendChild(optEl);
|
|
}
|
|
} else if (field.type === 'button') {
|
|
contentEl.className =
|
|
'field-content w-full h-full flex items-center justify-center text-sm font-semibold';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#e5e7eb'
|
|
);
|
|
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 text-gray-400';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#f9fafb'
|
|
);
|
|
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 LucideWindow).lucide?.createIcons(), 0);
|
|
} else if (field.type === 'date') {
|
|
contentEl.className =
|
|
'w-full h-full flex items-center justify-center text-gray-600 border border-gray-300';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#ffffff'
|
|
);
|
|
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 LucideWindow).lucide?.createIcons(), 0);
|
|
} else if (field.type === 'image') {
|
|
contentEl.className =
|
|
'w-full h-full flex items-center justify-center text-gray-500 border border-gray-300';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#f3f4f6'
|
|
);
|
|
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 LucideWindow).lucide?.createIcons(), 0);
|
|
} else if (field.type === 'barcode') {
|
|
contentEl.className = 'w-full h-full flex items-center justify-center';
|
|
contentEl.style.backgroundColor = getPreviewBackgroundColor(
|
|
field,
|
|
'#ffffff'
|
|
);
|
|
if (field.barcodeValue) {
|
|
try {
|
|
const offscreen = document.createElement('canvas');
|
|
bwipjs.toCanvas(offscreen, {
|
|
bcid: field.barcodeFormat || 'qrcode',
|
|
text: field.barcodeValue,
|
|
scale: 2,
|
|
includetext:
|
|
field.barcodeFormat !== 'qrcode' &&
|
|
field.barcodeFormat !== 'datamatrix',
|
|
});
|
|
const img = document.createElement('img');
|
|
img.src = offscreen.toDataURL('image/png');
|
|
img.className = 'max-w-full max-h-full object-contain';
|
|
contentEl.appendChild(img);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to render barcode preview for field "${field.name}":`,
|
|
error
|
|
);
|
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
|
|
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
|
}
|
|
} else {
|
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Barcode</span></div>`;
|
|
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
|
}
|
|
}
|
|
|
|
fieldContainer.appendChild(contentEl);
|
|
fieldWrapper.appendChild(label);
|
|
fieldWrapper.appendChild(fieldContainer);
|
|
|
|
// Click to select
|
|
fieldWrapper.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
selectField(field);
|
|
});
|
|
|
|
// Drag to move
|
|
fieldWrapper.addEventListener('mousedown', (e) => {
|
|
// Don't start drag if clicking on a resize handle
|
|
if ((e.target as HTMLElement).classList.contains('resize-handle')) {
|
|
return;
|
|
}
|
|
draggedElement = fieldWrapper;
|
|
const rect = canvas.getBoundingClientRect();
|
|
offsetX = e.clientX - rect.left - field.x;
|
|
offsetY = e.clientY - rect.top - field.y;
|
|
selectField(field);
|
|
if (gridEnabled) renderGrid();
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Touch events for moving fields
|
|
let touchMoveStarted = false;
|
|
fieldWrapper.addEventListener(
|
|
'touchstart',
|
|
(e) => {
|
|
if ((e.target as HTMLElement).classList.contains('resize-handle')) {
|
|
return;
|
|
}
|
|
touchMoveStarted = false;
|
|
const touch = e.touches[0];
|
|
const rect = canvas.getBoundingClientRect();
|
|
offsetX = touch.clientX - rect.left - field.x;
|
|
offsetY = touch.clientY - rect.top - field.y;
|
|
selectField(field);
|
|
},
|
|
{ passive: true }
|
|
);
|
|
|
|
fieldWrapper.addEventListener('touchmove', (e) => {
|
|
e.preventDefault();
|
|
touchMoveStarted = true;
|
|
const touch = e.touches[0];
|
|
const rect = canvas.getBoundingClientRect();
|
|
let newX = touch.clientX - rect.left - offsetX;
|
|
let newY = touch.clientY - rect.top - offsetY;
|
|
|
|
newX = Math.max(0, Math.min(newX, rect.width - fieldWrapper.offsetWidth));
|
|
newY = Math.max(0, Math.min(newY, rect.height - fieldWrapper.offsetHeight));
|
|
|
|
fieldWrapper.style.left = newX + 'px';
|
|
fieldWrapper.style.top = newY + 'px';
|
|
|
|
field.x = newX;
|
|
field.y = newY;
|
|
});
|
|
|
|
fieldWrapper.addEventListener('touchend', () => {
|
|
touchMoveStarted = false;
|
|
});
|
|
|
|
// Add resize handles to the container - hidden by default
|
|
const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'];
|
|
handles.forEach((pos) => {
|
|
const handle = document.createElement('div');
|
|
handle.className = `absolute w-2.5 h-2.5 bg-white border border-indigo-600 z-10 cursor-${pos}-resize resize-handle hidden`; // Added hidden class
|
|
const positions: Record<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;
|
|
const cursorMap: Record<string, string> = {
|
|
nw: 'nwse-resize',
|
|
ne: 'nesw-resize',
|
|
sw: 'nesw-resize',
|
|
se: 'nwse-resize',
|
|
n: 'ns-resize',
|
|
s: 'ns-resize',
|
|
e: 'ew-resize',
|
|
w: 'ew-resize',
|
|
};
|
|
handle.style.cursor = cursorMap[pos] || 'pointer';
|
|
|
|
handle.addEventListener('mousedown', (e) => {
|
|
e.stopPropagation();
|
|
startResize(e, field, pos);
|
|
});
|
|
|
|
// Touch events for resize handles
|
|
handle.addEventListener('touchstart', (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
// Create a synthetic mouse event for startResize
|
|
const syntheticEvent = {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
preventDefault: () => {},
|
|
} as MouseEvent;
|
|
startResize(syntheticEvent, field, pos);
|
|
});
|
|
|
|
fieldContainer.appendChild(handle);
|
|
});
|
|
|
|
canvas.appendChild(fieldWrapper);
|
|
}
|
|
|
|
function startResize(e: MouseEvent, field: FormField, pos: string): void {
|
|
resizing = true;
|
|
resizeField = field;
|
|
resizePos = pos;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
startWidth = field.width;
|
|
startHeight = field.height;
|
|
startLeft = field.x;
|
|
startTop = field.y;
|
|
e.preventDefault();
|
|
}
|
|
|
|
function applyResizeWithConstraints(
|
|
field: FormField,
|
|
pos: string,
|
|
dx: number,
|
|
dy: number
|
|
): void {
|
|
const isSquareField = field.type === 'checkbox' || field.type === 'radio';
|
|
const minWidth = isSquareField ? 12 : 50;
|
|
const minHeight = isSquareField ? 12 : 20;
|
|
|
|
if (pos.includes('e')) {
|
|
field.width = Math.max(minWidth, startWidth + dx);
|
|
}
|
|
if (pos.includes('w')) {
|
|
const newWidth = Math.max(minWidth, startWidth - dx);
|
|
const widthDiff = startWidth - newWidth;
|
|
field.width = newWidth;
|
|
field.x = startLeft + widthDiff;
|
|
}
|
|
if (pos.includes('s')) {
|
|
field.height = Math.max(minHeight, startHeight + dy);
|
|
}
|
|
if (pos.includes('n')) {
|
|
const newHeight = Math.max(minHeight, startHeight - dy);
|
|
const heightDiff = startHeight - newHeight;
|
|
field.height = newHeight;
|
|
field.y = startTop + heightDiff;
|
|
}
|
|
|
|
if (isSquareField) {
|
|
const size = Math.max(minWidth, Math.min(field.width, field.height));
|
|
|
|
if (pos.includes('w')) {
|
|
field.x = startLeft + (startWidth - size);
|
|
}
|
|
if (pos.includes('n')) {
|
|
field.y = startTop + (startHeight - size);
|
|
}
|
|
|
|
field.width = size;
|
|
field.height = size;
|
|
}
|
|
}
|
|
|
|
// Mouse move for dragging and resizing
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (draggedElement && !resizing) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
let newX = e.clientX - rect.left - offsetX;
|
|
let newY = e.clientY - rect.top - offsetY;
|
|
|
|
newX = Math.max(0, Math.min(newX, rect.width - draggedElement.offsetWidth));
|
|
newY = Math.max(
|
|
0,
|
|
Math.min(newY, rect.height - draggedElement.offsetHeight)
|
|
);
|
|
|
|
draggedElement.style.left = newX + 'px';
|
|
draggedElement.style.top = newY + 'px';
|
|
|
|
const field = fields.find((f) => f.id === draggedElement!.id);
|
|
if (field) {
|
|
field.x = newX;
|
|
field.y = newY;
|
|
}
|
|
} else if (resizing && resizeField) {
|
|
const dx = e.clientX - startX;
|
|
const dy = e.clientY - startY;
|
|
const fieldWrapper = document.getElementById(resizeField.id);
|
|
|
|
applyResizeWithConstraints(resizeField, resizePos!, dx, dy);
|
|
|
|
if (fieldWrapper) {
|
|
const container = fieldWrapper.querySelector(
|
|
'.field-container'
|
|
) as HTMLElement;
|
|
fieldWrapper.style.width = resizeField.width + 'px';
|
|
fieldWrapper.style.left = resizeField.x + 'px';
|
|
fieldWrapper.style.top = resizeField.y + 'px';
|
|
if (container) {
|
|
container.style.height = resizeField.height + 'px';
|
|
}
|
|
// Update combing visuals on resize
|
|
if (resizeField.combCells > 0) {
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) {
|
|
textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`;
|
|
textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
draggedElement = null;
|
|
resizing = false;
|
|
resizeField = null;
|
|
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);
|
|
|
|
applyResizeWithConstraints(resizeField, resizePos!, dx, dy);
|
|
|
|
if (fieldWrapper) {
|
|
const container = fieldWrapper.querySelector(
|
|
'.field-container'
|
|
) as HTMLElement;
|
|
fieldWrapper.style.width = resizeField.width + 'px';
|
|
fieldWrapper.style.left = resizeField.x + 'px';
|
|
fieldWrapper.style.top = resizeField.y + 'px';
|
|
if (container) {
|
|
container.style.height = resizeField.height + 'px';
|
|
}
|
|
if (resizeField.combCells > 0) {
|
|
const textEl = fieldWrapper.querySelector(
|
|
'.field-text'
|
|
) as HTMLElement;
|
|
if (textEl) {
|
|
textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`;
|
|
textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
document.addEventListener('touchend', () => {
|
|
resizing = false;
|
|
resizeField = null;
|
|
});
|
|
|
|
// Select field
|
|
function selectField(field: FormField): void {
|
|
deselectAll();
|
|
selectedField = field;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const container = fieldWrapper.querySelector(
|
|
'.field-container'
|
|
) as HTMLElement;
|
|
const label = fieldWrapper.querySelector('.field-label') as HTMLElement;
|
|
const handles = fieldWrapper.querySelectorAll('.resize-handle');
|
|
|
|
if (container) {
|
|
applyFieldContainerState(container, field, true);
|
|
}
|
|
|
|
if (label) {
|
|
label.classList.remove('opacity-0', 'group-hover:opacity-100');
|
|
label.classList.add('opacity-100');
|
|
}
|
|
|
|
handles.forEach((handle) => {
|
|
handle.classList.remove('hidden');
|
|
});
|
|
}
|
|
showProperties(field);
|
|
}
|
|
|
|
// Deselect all
|
|
function deselectAll(): void {
|
|
if (selectedField) {
|
|
const fieldWrapper = document.getElementById(selectedField.id);
|
|
if (fieldWrapper) {
|
|
const container = fieldWrapper.querySelector(
|
|
'.field-container'
|
|
) as HTMLElement;
|
|
const label = fieldWrapper.querySelector('.field-label') as HTMLElement;
|
|
const handles = fieldWrapper.querySelectorAll('.resize-handle');
|
|
|
|
if (container) {
|
|
applyFieldContainerState(container, selectedField, false);
|
|
}
|
|
|
|
if (label) {
|
|
label.classList.remove('opacity-100');
|
|
label.classList.add('opacity-0', 'group-hover:opacity-100');
|
|
}
|
|
|
|
handles.forEach((handle) => {
|
|
handle.classList.add('hidden');
|
|
});
|
|
}
|
|
selectedField = null;
|
|
}
|
|
hideProperties();
|
|
}
|
|
|
|
// Show properties panel
|
|
function showProperties(field: FormField): void {
|
|
let specificProps = '';
|
|
|
|
if (field.type === 'text') {
|
|
specificProps = `
|
|
<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}">
|
|
</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>
|
|
`;
|
|
} else if (field.type === 'barcode') {
|
|
specificProps = `
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Format</label>
|
|
<select id="propBarcodeFormat" 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="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>QR Code</option>
|
|
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>Code 128</option>
|
|
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>Code 39</option>
|
|
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>EAN-13</option>
|
|
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>UPC-A</option>
|
|
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>DataMatrix</option>
|
|
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>PDF417</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
|
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" 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="barcodeFormatHint" class="text-xs text-gray-400 italic"></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'}">
|
|
</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>
|
|
<div class="flex items-center">
|
|
<input type="checkbox" id="propTransparentBackground" ${field.transparentBackground ? 'checked' : ''} class="mr-2">
|
|
<label for="propTransparentBackground" class="text-xs font-semibold text-gray-300">Transparent Background</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>
|
|
`;
|
|
|
|
// Common listeners
|
|
const propName = document.getElementById('propName') as HTMLInputElement;
|
|
const nameError = document.getElementById('nameError') as HTMLDivElement;
|
|
const propTooltip = document.getElementById(
|
|
'propTooltip'
|
|
) as HTMLInputElement;
|
|
const propRequired = document.getElementById(
|
|
'propRequired'
|
|
) as HTMLInputElement;
|
|
const propReadOnly = document.getElementById(
|
|
'propReadOnly'
|
|
) as HTMLInputElement;
|
|
const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement;
|
|
|
|
const validateName = (newName: string): boolean => {
|
|
if (!newName) {
|
|
nameError.textContent = 'Field name cannot be empty';
|
|
nameError.classList.remove('hidden');
|
|
propName.classList.add('border-red-500');
|
|
return false;
|
|
}
|
|
|
|
if (field.type === 'radio') {
|
|
nameError.classList.add('hidden');
|
|
propName.classList.remove('border-red-500');
|
|
return true;
|
|
}
|
|
|
|
const isDuplicateInFields = fields.some(
|
|
(f) => f.id !== field.id && f.name === newName
|
|
);
|
|
const isDuplicateInPdf = existingFieldNames.has(newName);
|
|
|
|
if (isDuplicateInFields || isDuplicateInPdf) {
|
|
nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.`;
|
|
nameError.classList.remove('hidden');
|
|
propName.classList.add('border-red-500');
|
|
return false;
|
|
}
|
|
|
|
nameError.classList.add('hidden');
|
|
propName.classList.remove('border-red-500');
|
|
return true;
|
|
};
|
|
|
|
propName.addEventListener('input', (e) => {
|
|
const newName = (e.target as HTMLInputElement).value.trim();
|
|
validateName(newName);
|
|
});
|
|
|
|
propName.addEventListener('change', (e) => {
|
|
const newName = (e.target as HTMLInputElement).value.trim();
|
|
|
|
if (!validateName(newName)) {
|
|
(e.target as HTMLInputElement).value = field.name;
|
|
return;
|
|
}
|
|
|
|
field.name = newName;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const label = fieldWrapper.querySelector('.field-label') as HTMLElement;
|
|
if (label) label.textContent = field.name;
|
|
}
|
|
});
|
|
|
|
propTooltip.addEventListener('input', (e) => {
|
|
field.tooltip = (e.target as HTMLInputElement).value;
|
|
});
|
|
|
|
if (field.type === 'radio') {
|
|
const existingGroupsSelect = document.getElementById(
|
|
'existingGroups'
|
|
) as HTMLSelectElement;
|
|
if (existingGroupsSelect) {
|
|
existingGroupsSelect.addEventListener('change', (e) => {
|
|
const selectedGroup = (e.target as HTMLSelectElement).value;
|
|
if (selectedGroup) {
|
|
propName.value = selectedGroup;
|
|
field.name = selectedGroup;
|
|
validateName(selectedGroup);
|
|
|
|
// Update field label
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const label = fieldWrapper.querySelector(
|
|
'.field-label'
|
|
) as HTMLElement;
|
|
if (label) label.textContent = field.name;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
propRequired.addEventListener('change', (e) => {
|
|
field.required = (e.target as HTMLInputElement).checked;
|
|
});
|
|
|
|
propReadOnly.addEventListener('change', (e) => {
|
|
field.readOnly = (e.target as HTMLInputElement).checked;
|
|
});
|
|
|
|
const propBorderColor = document.getElementById(
|
|
'propBorderColor'
|
|
) as HTMLInputElement;
|
|
const propHideBorder = document.getElementById(
|
|
'propHideBorder'
|
|
) as HTMLInputElement;
|
|
const propTransparentBackground = document.getElementById(
|
|
'propTransparentBackground'
|
|
) as HTMLInputElement;
|
|
|
|
propBorderColor.addEventListener('input', (e) => {
|
|
field.borderColor = (e.target as HTMLInputElement).value;
|
|
});
|
|
|
|
propHideBorder.addEventListener('change', (e) => {
|
|
field.hideBorder = (e.target as HTMLInputElement).checked;
|
|
rerenderSelectedField(field);
|
|
});
|
|
|
|
propTransparentBackground.addEventListener('change', (e) => {
|
|
field.transparentBackground = (e.target as HTMLInputElement).checked;
|
|
rerenderSelectedField(field);
|
|
});
|
|
|
|
deleteBtn.addEventListener('click', () => {
|
|
deleteField(field);
|
|
});
|
|
|
|
// Specific listeners
|
|
if (field.type === 'text') {
|
|
const propValue = document.getElementById('propValue') as HTMLInputElement;
|
|
const propMaxLength = document.getElementById(
|
|
'propMaxLength'
|
|
) as HTMLInputElement;
|
|
const propComb = document.getElementById('propComb') as HTMLInputElement;
|
|
const propFontSize = document.getElementById(
|
|
'propFontSize'
|
|
) as HTMLInputElement;
|
|
const propTextColor = document.getElementById(
|
|
'propTextColor'
|
|
) as HTMLInputElement;
|
|
const propAlignment = document.getElementById(
|
|
'propAlignment'
|
|
) as HTMLSelectElement;
|
|
|
|
propValue.addEventListener('input', (e) => {
|
|
field.defaultValue = (e.target as HTMLInputElement).value;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) textEl.textContent = field.defaultValue;
|
|
}
|
|
});
|
|
|
|
propMaxLength.addEventListener('input', (e) => {
|
|
const val = parseInt((e.target as HTMLInputElement).value);
|
|
field.maxLength = isNaN(val) ? 0 : Math.max(0, val);
|
|
if (field.maxLength > 0) {
|
|
propValue.maxLength = field.maxLength;
|
|
if (field.defaultValue.length > field.maxLength) {
|
|
field.defaultValue = field.defaultValue.substring(0, field.maxLength);
|
|
propValue.value = field.defaultValue;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector(
|
|
'.field-text'
|
|
) as HTMLElement;
|
|
if (textEl) textEl.textContent = field.defaultValue;
|
|
}
|
|
}
|
|
} else {
|
|
propValue.removeAttribute('maxLength');
|
|
}
|
|
});
|
|
|
|
propComb.addEventListener('input', (e) => {
|
|
const val = parseInt((e.target as HTMLInputElement).value);
|
|
field.combCells = isNaN(val) ? 0 : Math.max(0, val);
|
|
|
|
if (field.combCells > 0) {
|
|
propValue.maxLength = field.combCells;
|
|
propMaxLength.value = field.combCells.toString();
|
|
propMaxLength.disabled = true;
|
|
field.maxLength = field.combCells;
|
|
|
|
if (field.defaultValue.length > field.combCells) {
|
|
field.defaultValue = field.defaultValue.substring(0, field.combCells);
|
|
propValue.value = field.defaultValue;
|
|
}
|
|
} else {
|
|
propMaxLength.disabled = false;
|
|
propValue.removeAttribute('maxLength');
|
|
if (field.maxLength > 0) {
|
|
propValue.maxLength = field.maxLength;
|
|
}
|
|
}
|
|
|
|
// Re-render field visual only, NOT the properties panel
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
// Update text content
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) {
|
|
textEl.textContent = field.defaultValue;
|
|
if (field.combCells > 0) {
|
|
textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`;
|
|
textEl.style.fontFamily = 'monospace';
|
|
textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`;
|
|
textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`;
|
|
textEl.style.overflow = 'hidden';
|
|
textEl.style.textAlign = 'left';
|
|
textEl.style.justifyContent = 'flex-start';
|
|
} else {
|
|
textEl.style.backgroundImage = 'none';
|
|
textEl.style.fontFamily = 'inherit';
|
|
textEl.style.letterSpacing = 'normal';
|
|
textEl.style.textAlign = field.alignment;
|
|
textEl.style.justifyContent =
|
|
field.alignment === 'left'
|
|
? 'flex-start'
|
|
: field.alignment === 'right'
|
|
? 'flex-end'
|
|
: 'center';
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
propFontSize.addEventListener('input', (e) => {
|
|
field.fontSize = parseInt((e.target as HTMLInputElement).value);
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) textEl.style.fontSize = field.fontSize + 'px';
|
|
}
|
|
});
|
|
|
|
propTextColor.addEventListener('input', (e) => {
|
|
field.textColor = (e.target as HTMLInputElement).value;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) textEl.style.color = field.textColor;
|
|
}
|
|
});
|
|
|
|
propAlignment.addEventListener('change', (e) => {
|
|
field.alignment = (e.target as HTMLSelectElement).value as
|
|
| 'left'
|
|
| 'center'
|
|
| 'right';
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
|
|
if (textEl) {
|
|
textEl.style.textAlign = field.alignment;
|
|
textEl.style.justifyContent =
|
|
field.alignment === 'left'
|
|
? 'flex-start'
|
|
: field.alignment === 'right'
|
|
? 'flex-end'
|
|
: 'center';
|
|
}
|
|
}
|
|
});
|
|
|
|
const propMultilineBtn = document.getElementById(
|
|
'propMultilineBtn'
|
|
) as HTMLButtonElement;
|
|
if (propMultilineBtn) {
|
|
propMultilineBtn.addEventListener('click', () => {
|
|
field.multiline = !field.multiline;
|
|
|
|
// Update Toggle Button UI
|
|
const span = propMultilineBtn.querySelector('span');
|
|
if (field.multiline) {
|
|
propMultilineBtn.classList.remove('bg-gray-500');
|
|
propMultilineBtn.classList.add('bg-indigo-600');
|
|
span?.classList.remove('translate-x-0');
|
|
span?.classList.add('translate-x-6');
|
|
} else {
|
|
propMultilineBtn.classList.remove('bg-indigo-600');
|
|
propMultilineBtn.classList.add('bg-gray-500');
|
|
span?.classList.remove('translate-x-6');
|
|
span?.classList.add('translate-x-0');
|
|
}
|
|
|
|
// Update Canvas UI
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const textEl = fieldWrapper.querySelector(
|
|
'.field-text'
|
|
) as HTMLElement;
|
|
if (textEl) {
|
|
if (field.multiline) {
|
|
textEl.style.whiteSpace = 'pre-wrap';
|
|
textEl.style.alignItems = 'flex-start';
|
|
textEl.style.overflow = 'hidden';
|
|
} else {
|
|
textEl.style.whiteSpace = 'nowrap';
|
|
textEl.style.alignItems = 'center';
|
|
textEl.style.overflow = 'hidden';
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} else if (field.type === 'checkbox' || field.type === 'radio') {
|
|
const propCheckedBtn = document.getElementById(
|
|
'propCheckedBtn'
|
|
) as HTMLButtonElement;
|
|
|
|
propCheckedBtn.addEventListener('click', () => {
|
|
field.checked = !field.checked;
|
|
|
|
// Update Toggle Button UI
|
|
const span = propCheckedBtn.querySelector('span');
|
|
if (field.checked) {
|
|
propCheckedBtn.classList.remove('bg-gray-500');
|
|
propCheckedBtn.classList.add('bg-indigo-600');
|
|
span?.classList.remove('translate-x-0');
|
|
span?.classList.add('translate-x-6');
|
|
} else {
|
|
propCheckedBtn.classList.remove('bg-indigo-600');
|
|
propCheckedBtn.classList.add('bg-gray-500');
|
|
span?.classList.remove('translate-x-6');
|
|
span?.classList.add('translate-x-0');
|
|
}
|
|
|
|
// Update Canvas UI
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const contentEl = fieldWrapper.querySelector(
|
|
'.field-content'
|
|
) as HTMLElement;
|
|
if (contentEl) {
|
|
if (field.type === 'checkbox') {
|
|
contentEl.innerHTML = field.checked
|
|
? '<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) => {
|
|
// We split by newline OR comma for the actual options array
|
|
const val = (e.target as HTMLTextAreaElement).value;
|
|
field.options = val
|
|
.split(/[\n,]/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
|
|
const propSelectedOption = document.getElementById(
|
|
'propSelectedOption'
|
|
) as HTMLSelectElement;
|
|
if (propSelectedOption) {
|
|
const currentVal = field.defaultValue;
|
|
propSelectedOption.innerHTML =
|
|
'<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;
|
|
|
|
// Update visual on canvas
|
|
renderField(field);
|
|
});
|
|
} else if (field.type === 'button') {
|
|
const propLabel = document.getElementById('propLabel') as HTMLInputElement;
|
|
propLabel.addEventListener('input', (e) => {
|
|
field.label = (e.target as HTMLInputElement).value;
|
|
const fieldWrapper = document.getElementById(field.id);
|
|
if (fieldWrapper) {
|
|
const contentEl = fieldWrapper.querySelector(
|
|
'.field-content'
|
|
) as HTMLElement;
|
|
if (contentEl) contentEl.textContent = field.label || 'Button';
|
|
}
|
|
});
|
|
|
|
const propAction = document.getElementById(
|
|
'propAction'
|
|
) as HTMLSelectElement;
|
|
const propUrlContainer = document.getElementById(
|
|
'propUrlContainer'
|
|
) as HTMLDivElement;
|
|
const propJsContainer = document.getElementById(
|
|
'propJsContainer'
|
|
) as HTMLDivElement;
|
|
const propShowHideContainer = document.getElementById(
|
|
'propShowHideContainer'
|
|
) as HTMLDivElement;
|
|
|
|
propAction.addEventListener('change', (e) => {
|
|
const actionValue = (e.target as HTMLSelectElement)
|
|
.value as FormFieldAction;
|
|
field.action = actionValue;
|
|
|
|
// Show/hide containers
|
|
propUrlContainer.classList.add('hidden');
|
|
propJsContainer.classList.add('hidden');
|
|
propShowHideContainer.classList.add('hidden');
|
|
|
|
if (field.action === 'url') {
|
|
propUrlContainer.classList.remove('hidden');
|
|
} else if (field.action === 'js') {
|
|
propJsContainer.classList.remove('hidden');
|
|
} else if (field.action === 'showHide') {
|
|
propShowHideContainer.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
const propActionUrl = document.getElementById(
|
|
'propActionUrl'
|
|
) as HTMLInputElement;
|
|
propActionUrl.addEventListener('input', (e) => {
|
|
field.actionUrl = (e.target as HTMLInputElement).value;
|
|
});
|
|
|
|
const propJsScript = document.getElementById(
|
|
'propJsScript'
|
|
) as HTMLTextAreaElement;
|
|
if (propJsScript) {
|
|
propJsScript.addEventListener('input', (e) => {
|
|
field.jsScript = (e.target as HTMLTextAreaElement).value;
|
|
});
|
|
}
|
|
|
|
const propTargetField = document.getElementById(
|
|
'propTargetField'
|
|
) as HTMLSelectElement;
|
|
if (propTargetField) {
|
|
propTargetField.addEventListener('change', (e) => {
|
|
field.targetFieldName = (e.target as HTMLSelectElement).value;
|
|
});
|
|
}
|
|
|
|
const propVisibilityAction = document.getElementById(
|
|
'propVisibilityAction'
|
|
) as HTMLSelectElement;
|
|
if (propVisibilityAction) {
|
|
propVisibilityAction.addEventListener('change', (e) => {
|
|
field.visibilityAction = (e.target as HTMLSelectElement)
|
|
.value as FormFieldVisibilityAction;
|
|
});
|
|
}
|
|
} else if (field.type === 'signature') {
|
|
// No specific listeners for signature fields yet
|
|
} 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 LucideWindow).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);
|
|
});
|
|
} else if (field.type === 'barcode') {
|
|
const propBarcodeFormat = document.getElementById(
|
|
'propBarcodeFormat'
|
|
) as HTMLSelectElement;
|
|
const propBarcodeValue = document.getElementById(
|
|
'propBarcodeValue'
|
|
) as HTMLInputElement;
|
|
|
|
const barcodeSampleValues: Record<string, string> = {
|
|
qrcode: 'https://example.com',
|
|
code128: 'ABC-123',
|
|
code39: 'ABC123',
|
|
ean13: '590123412345',
|
|
upca: '01234567890',
|
|
datamatrix: 'https://example.com',
|
|
pdf417: 'https://example.com',
|
|
};
|
|
|
|
const barcodeFormatHints: Record<string, string> = {
|
|
qrcode: 'Any text, URL, or data',
|
|
code128: 'ASCII characters (letters, numbers, symbols)',
|
|
code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE',
|
|
ean13: 'Exactly 12 or 13 digits',
|
|
upca: 'Exactly 11 or 12 digits',
|
|
datamatrix: 'Any text, URL, or data',
|
|
pdf417: 'Any text, URL, or data',
|
|
};
|
|
|
|
const hintEl = document.getElementById('barcodeFormatHint');
|
|
if (hintEl)
|
|
hintEl.textContent =
|
|
barcodeFormatHints[field.barcodeFormat || 'qrcode'] || '';
|
|
|
|
if (propBarcodeFormat) {
|
|
propBarcodeFormat.addEventListener('change', (e) => {
|
|
const newFormat = (e.target as HTMLSelectElement).value;
|
|
field.barcodeFormat = newFormat;
|
|
field.barcodeValue = barcodeSampleValues[newFormat] || 'hello';
|
|
if (propBarcodeValue) propBarcodeValue.value = field.barcodeValue;
|
|
if (hintEl) hintEl.textContent = barcodeFormatHints[newFormat] || '';
|
|
renderField(field);
|
|
});
|
|
}
|
|
|
|
if (propBarcodeValue) {
|
|
propBarcodeValue.addEventListener('input', (e) => {
|
|
field.barcodeValue = (e.target as HTMLInputElement).value;
|
|
renderField(field);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hide properties panel
|
|
function hideProperties(): void {
|
|
propertiesPanel.innerHTML =
|
|
'<p class="text-gray-500 text-sm">Select a field to edit properties</p>';
|
|
}
|
|
|
|
// Delete field
|
|
function deleteField(field: FormField): void {
|
|
const fieldEl = document.getElementById(field.id);
|
|
if (fieldEl) {
|
|
fieldEl.remove();
|
|
}
|
|
fields = fields.filter((f) => f.id !== field.id);
|
|
deselectAll();
|
|
updateFieldCount();
|
|
}
|
|
|
|
// Delete key handler
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Delete' && selectedField) {
|
|
deleteField(selectedField);
|
|
} else if (e.key === 'Escape' && selectedToolType) {
|
|
// Cancel tool selection
|
|
toolItems.forEach((item) =>
|
|
item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')
|
|
);
|
|
selectedToolType = null;
|
|
canvas.style.cursor = 'default';
|
|
}
|
|
});
|
|
|
|
// Update field count
|
|
function updateFieldCount(): void {
|
|
fieldCountDisplay.textContent = fields.length.toString();
|
|
}
|
|
|
|
// Download PDF
|
|
downloadBtn.addEventListener('click', async () => {
|
|
// Check for duplicate field names before generating PDF
|
|
const nameCount = new Map<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))) {
|
|
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();
|
|
|
|
if (extractedFieldNames.size > 0) {
|
|
for (const fieldName of extractedFieldNames) {
|
|
try {
|
|
const existingField = form.getFieldMaybe(fieldName);
|
|
if (existingField) {
|
|
form.removeField(existingField);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to remove existing field "${fieldName}":`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
|
|
// Set document metadata for accessibility
|
|
pdfDoc.setTitle('Fillable Form');
|
|
pdfDoc.setAuthor('BentoPDF');
|
|
pdfDoc.setLanguage('en-US');
|
|
|
|
const radioGroups = new Map<
|
|
string,
|
|
ReturnType<typeof form.createRadioGroup>
|
|
>();
|
|
|
|
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),
|
|
...getPdfBackgroundOptions(field, 1, 1, 1),
|
|
textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b),
|
|
});
|
|
|
|
textField.setText(field.defaultValue);
|
|
textField.setFontSize(field.fontSize);
|
|
|
|
// Set alignment
|
|
if (field.alignment === 'center') {
|
|
textField.setAlignment(TextAlignment.Center);
|
|
} else if (field.alignment === 'right') {
|
|
textField.setAlignment(TextAlignment.Right);
|
|
} else {
|
|
textField.setAlignment(TextAlignment.Left);
|
|
}
|
|
|
|
// Handle combing
|
|
if (field.combCells > 0) {
|
|
textField.setMaxLength(field.combCells);
|
|
textField.enableCombing();
|
|
} else if (field.maxLength > 0) {
|
|
textField.setMaxLength(field.maxLength);
|
|
}
|
|
|
|
// Disable multiline to prevent RTL issues (unless explicitly enabled)
|
|
if (!field.multiline) {
|
|
textField.disableMultiline();
|
|
} else {
|
|
textField.enableMultiline();
|
|
}
|
|
|
|
// Common properties
|
|
if (field.required) textField.enableRequired();
|
|
if (field.readOnly) textField.enableReadOnly();
|
|
if (field.tooltip) {
|
|
textField.acroField.getWidgets().forEach((widget) => {
|
|
widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
textField.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} 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),
|
|
...getPdfBackgroundOptions(field, 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));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
checkBox.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} 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 as PDFRadioGroup;
|
|
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, {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
borderWidth: field.hideBorder ? 0 : 1,
|
|
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
|
...getPdfBackgroundOptions(field, 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) => {
|
|
widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
radioGroup.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} 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),
|
|
...getPdfBackgroundOptions(field, 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));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
dropdown.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} 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),
|
|
...getPdfBackgroundOptions(field, 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));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
optionList.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} 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),
|
|
...getPdfBackgroundOptions(field, 0.8, 0.8, 0.8),
|
|
});
|
|
|
|
// Add Action
|
|
if (field.action && field.action !== 'none') {
|
|
const widgets = button.acroField.getWidgets();
|
|
|
|
widgets.forEach((widget) => {
|
|
let actionDict: PDFDict | PDFArray | undefined;
|
|
|
|
if (field.action === 'reset') {
|
|
actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'ResetForm',
|
|
});
|
|
} else if (field.action === 'print') {
|
|
// Print action using JavaScript
|
|
actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: 'print();',
|
|
});
|
|
} else if (field.action === 'url' && field.actionUrl) {
|
|
// Validate URL
|
|
let url = field.actionUrl.trim();
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
url = 'https://' + url;
|
|
}
|
|
|
|
// Encode URL to handle special characters (RFC3986)
|
|
try {
|
|
url = encodeURI(url);
|
|
} catch (e) {
|
|
console.warn('Failed to encode URL:', e);
|
|
}
|
|
|
|
actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'URI',
|
|
URI: PDFString.of(url),
|
|
});
|
|
} else if (field.action === 'js' && field.jsScript) {
|
|
actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: field.jsScript,
|
|
});
|
|
} else if (field.action === 'showHide' && field.targetFieldName) {
|
|
const target = field.targetFieldName;
|
|
let script: string;
|
|
|
|
if (field.visibilityAction === 'show') {
|
|
script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`;
|
|
} else if (field.visibilityAction === 'hide') {
|
|
script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;`;
|
|
} else {
|
|
// Toggle
|
|
script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;`;
|
|
}
|
|
|
|
actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: script,
|
|
});
|
|
}
|
|
|
|
if (actionDict) {
|
|
widget.dict.set(PDFName.of('A'), actionDict);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (field.tooltip) {
|
|
button.acroField.getWidgets().forEach((widget) => {
|
|
widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
button.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} else if (field.type === 'date') {
|
|
const dateField = form.createTextField(field.name);
|
|
const borderRgb = hexToRgb(field.borderColor || '#000000');
|
|
dateField.addToPage(pdfPage, {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
borderWidth: field.hideBorder ? 0 : 1,
|
|
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
|
...getPdfBackgroundOptions(field, 1, 1, 1),
|
|
});
|
|
|
|
// Add Date Format and Keystroke Actions to the FIELD (not widget)
|
|
const dateFormat = field.dateFormat || 'mm/dd/yyyy';
|
|
|
|
const formatAction = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`),
|
|
});
|
|
|
|
const keystrokeAction = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`),
|
|
});
|
|
|
|
// Attach AA (Additional Actions) to the field dictionary
|
|
const additionalActions = pdfDoc.context.obj({
|
|
F: formatAction,
|
|
K: keystrokeAction,
|
|
});
|
|
dateField.acroField.dict.set(PDFName.of('AA'), additionalActions);
|
|
|
|
if (field.required) dateField.enableRequired();
|
|
if (field.readOnly) dateField.enableReadOnly();
|
|
if (field.tooltip) {
|
|
dateField.acroField.getWidgets().forEach((widget) => {
|
|
widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
dateField.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} else if (field.type === 'image') {
|
|
const imageBtn = form.createButton(field.name);
|
|
const borderRgb = hexToRgb(field.borderColor || '#000000');
|
|
imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
borderWidth: field.hideBorder ? 0 : 1,
|
|
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
|
...getPdfBackgroundOptions(field, 0.9, 0.9, 0.9),
|
|
});
|
|
|
|
// Add Import Icon Action
|
|
const widgets = imageBtn.acroField.getWidgets();
|
|
widgets.forEach((widget) => {
|
|
const actionDict = pdfDoc.context.obj({
|
|
Type: 'Action',
|
|
S: 'JavaScript',
|
|
JS: 'event.target.buttonImportIcon();',
|
|
});
|
|
widget.dict.set(PDFName.of('A'), actionDict);
|
|
|
|
// Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only)
|
|
// This ensures the image replaces the text when uploaded
|
|
// IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill)
|
|
const mkDict = pdfDoc.context.obj({
|
|
TP: 1,
|
|
BC: [0, 0, 0], // Border color (Black)
|
|
IF: {
|
|
SW: PDFName.of('A'),
|
|
S: PDFName.of('A'),
|
|
FB: true,
|
|
},
|
|
});
|
|
if (!hasTransparentBackground(field)) {
|
|
mkDict.set(PDFName.of('BG'), pdfDoc.context.obj([0.9, 0.9, 0.9]));
|
|
}
|
|
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));
|
|
});
|
|
}
|
|
clearTransparentFieldWidgetBackgrounds(
|
|
field,
|
|
imageBtn.acroField.getWidgets(),
|
|
pdfDoc
|
|
);
|
|
} else if (field.type === 'signature') {
|
|
const context = pdfDoc.context;
|
|
|
|
// Create the signature field dictionary with FT = Sig
|
|
const sigDict = context.obj({
|
|
FT: PDFName.of('Sig'),
|
|
T: PDFString.of(field.name),
|
|
Kids: [],
|
|
}) as PDFDict;
|
|
const sigRef = context.register(sigDict);
|
|
|
|
// Create the widget annotation for the signature field
|
|
const widgetDict = context.obj({
|
|
Type: PDFName.of('Annot'),
|
|
Subtype: PDFName.of('Widget'),
|
|
Rect: [x, y, x + width, y + height],
|
|
F: 4, // Print flag
|
|
P: pdfPage.ref,
|
|
Parent: sigRef,
|
|
}) as PDFDict;
|
|
|
|
// Add border and background appearance
|
|
const borderStyle = context.obj({
|
|
W: field.hideBorder ? 0 : 1, // Border width
|
|
S: PDFName.of('S'), // Solid border
|
|
}) as PDFDict;
|
|
widgetDict.set(PDFName.of('BS'), borderStyle);
|
|
const borderRgb = hexToRgb(field.borderColor || '#000000');
|
|
widgetDict.set(
|
|
PDFName.of('BC'),
|
|
context.obj([borderRgb.r, borderRgb.g, borderRgb.b])
|
|
); // Border color
|
|
if (!hasTransparentBackground(field)) {
|
|
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);
|
|
|
|
// Add tooltip if specified
|
|
if (field.tooltip) {
|
|
widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
|
}
|
|
} else if (field.type === 'barcode') {
|
|
if (field.barcodeValue) {
|
|
try {
|
|
const offscreen = document.createElement('canvas');
|
|
bwipjs.toCanvas(offscreen, {
|
|
bcid: field.barcodeFormat || 'qrcode',
|
|
text: field.barcodeValue,
|
|
scale: 3,
|
|
includetext:
|
|
field.barcodeFormat !== 'qrcode' &&
|
|
field.barcodeFormat !== 'datamatrix',
|
|
});
|
|
const dataUrl = offscreen.toDataURL('image/png');
|
|
const base64 = dataUrl.split(',')[1];
|
|
const pngBytes = Uint8Array.from(atob(base64), (c) =>
|
|
c.charCodeAt(0)
|
|
);
|
|
const pngImage = await pdfDoc.embedPng(pngBytes);
|
|
pdfPage.drawImage(pngImage, { x, y, width, height });
|
|
} catch (e) {
|
|
console.warn(
|
|
`Failed to generate barcode for field "${field.name}":`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
form.updateFieldAppearances(helveticaFont);
|
|
|
|
const pdfBytes = await pdfDoc.save();
|
|
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
|
type: 'application/pdf',
|
|
});
|
|
downloadFile(blob, uploadedFileName || 'document.pdf');
|
|
showModal(
|
|
'Success',
|
|
'Your PDF has been downloaded successfully.',
|
|
'info',
|
|
() => {
|
|
resetToInitial();
|
|
},
|
|
'Okay'
|
|
);
|
|
} catch (error) {
|
|
console.error('Error generating PDF:', error);
|
|
const errorMessage = (error as Error).message;
|
|
|
|
// Check if it's a duplicate field name error
|
|
if (
|
|
errorMessage.includes('A field already exists with the specified name')
|
|
) {
|
|
// Extract the field name from the error message
|
|
const match = errorMessage.match(
|
|
/A field already exists with the specified name: "(.+?)"/
|
|
);
|
|
const fieldName = match ? match[1] : 'unknown';
|
|
|
|
if (existingRadioGroups.has(fieldName)) {
|
|
console.log(`Adding to existing radio group: ${fieldName}`);
|
|
} else {
|
|
showModal(
|
|
'Duplicate Field Name',
|
|
`A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`,
|
|
'error'
|
|
);
|
|
}
|
|
} else {
|
|
showModal('Error', 'Error generating PDF: ' + errorMessage, 'error');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Back to tools button
|
|
const backToToolsBtns = document.querySelectorAll(
|
|
'[id^="back-to-tools"]'
|
|
) as NodeListOf<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] };
|
|
}
|
|
|
|
// Reset to initial state
|
|
function resetToInitial(): void {
|
|
fields = [];
|
|
pages = [];
|
|
currentPageIndex = 0;
|
|
uploadedPdfDoc = null;
|
|
selectedField = null;
|
|
extractedFieldNames.clear();
|
|
pendingFieldExtraction = false;
|
|
|
|
canvas.innerHTML = '';
|
|
|
|
propertiesPanel.innerHTML =
|
|
'<p class="text-gray-500 text-sm">Select a field to edit properties</p>';
|
|
|
|
updateFieldCount();
|
|
|
|
// Show upload area and hide tool container
|
|
uploadArea.classList.remove('hidden');
|
|
toolContainer.classList.add('hidden');
|
|
pageSizeSelector.classList.add('hidden');
|
|
setTimeout(() => createIcons({ icons }), 100);
|
|
}
|
|
|
|
function createBlankPage(): void {
|
|
pages.push({
|
|
index: pages.length,
|
|
width: pageSize.width,
|
|
height: pageSize.height,
|
|
});
|
|
updatePageNavigation();
|
|
}
|
|
|
|
function switchToPage(pageIndex: number): void {
|
|
if (pageIndex < 0 || pageIndex >= pages.length) return;
|
|
|
|
currentPageIndex = pageIndex;
|
|
renderCanvas();
|
|
updatePageNavigation();
|
|
|
|
// Deselect any selected field when switching pages
|
|
deselectAll();
|
|
}
|
|
|
|
// Render the canvas for the current page
|
|
async function renderCanvas(): Promise<void> {
|
|
const currentPage = pages[currentPageIndex];
|
|
if (!currentPage) return;
|
|
|
|
// Fixed scale for better visibility
|
|
const scale = 1.333;
|
|
|
|
currentScale = scale;
|
|
|
|
// Use actual PDF page dimensions (not scaled)
|
|
const canvasWidth = currentPage.width * scale;
|
|
const canvasHeight = currentPage.height * scale;
|
|
|
|
canvas.style.width = `${canvasWidth}px`;
|
|
canvas.style.height = `${canvasHeight}px`;
|
|
|
|
canvas.innerHTML = '';
|
|
|
|
if (uploadedPdfDoc) {
|
|
try {
|
|
const arrayBuffer = await uploadedPdfDoc.save();
|
|
const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], {
|
|
type: 'application/pdf',
|
|
});
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.src = `${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 PdfViewerWindow | null;
|
|
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<HTMLElement>('.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,
|
|
},
|
|
});
|
|
|
|
if (pendingFieldExtraction && uploadedPdfDoc) {
|
|
pendingFieldExtraction = false;
|
|
extractExistingFields(uploadedPdfDoc);
|
|
extractedFieldNames.forEach((name) =>
|
|
existingFieldNames.delete(name)
|
|
);
|
|
|
|
const form = uploadedPdfDoc.getForm();
|
|
for (const name of extractedFieldNames) {
|
|
try {
|
|
const existingField = form.getFieldMaybe(name);
|
|
if (existingField) {
|
|
form.removeField(existingField);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to remove extracted field "${name}" after import:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
renderCanvas();
|
|
updateFieldCount();
|
|
}
|
|
}, 50);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error accessing iframe content:', e);
|
|
}
|
|
};
|
|
|
|
canvas.appendChild(iframe);
|
|
|
|
console.log('Canvas dimensions:', {
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
scale: currentScale,
|
|
});
|
|
console.log('PDF page dimensions:', {
|
|
width: currentPage.width,
|
|
height: currentPage.height,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error rendering PDF:', error);
|
|
}
|
|
}
|
|
|
|
fields
|
|
.filter((f) => f.pageIndex === currentPageIndex)
|
|
.forEach((field) => {
|
|
renderField(field);
|
|
});
|
|
}
|
|
|
|
function updatePageNavigation(): void {
|
|
pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}`;
|
|
prevPageBtn.disabled = currentPageIndex === 0;
|
|
nextPageBtn.disabled = currentPageIndex === pages.length - 1;
|
|
}
|
|
|
|
// Drag and drop handlers for upload area
|
|
dropZone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('border-indigo-500', 'bg-gray-600');
|
|
});
|
|
|
|
dropZone.addEventListener('dragleave', () => {
|
|
dropZone.classList.remove('border-indigo-500', 'bg-gray-600');
|
|
});
|
|
|
|
dropZone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('border-indigo-500', 'bg-gray-600');
|
|
const files = e.dataTransfer?.files;
|
|
if (files && files.length > 0 && files[0].type === 'application/pdf') {
|
|
handlePdfUpload(files[0]);
|
|
}
|
|
});
|
|
|
|
pdfFileInput.addEventListener('change', async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
handlePdfUpload(file);
|
|
}
|
|
});
|
|
|
|
blankPdfBtn.addEventListener('click', () => {
|
|
pageSizeSelector.classList.remove('hidden');
|
|
});
|
|
|
|
pageSizeSelect.addEventListener('change', () => {
|
|
if (pageSizeSelect.value === 'custom') {
|
|
customDimensionsInput.classList.remove('hidden');
|
|
} else {
|
|
customDimensionsInput.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
confirmBlankBtn.addEventListener('click', () => {
|
|
const selectedSize = pageSizeSelect.value;
|
|
pageSize = getPageDimensions(selectedSize);
|
|
|
|
createBlankPage();
|
|
switchToPage(0);
|
|
|
|
// Hide upload area and show tool container
|
|
uploadArea.classList.add('hidden');
|
|
toolContainer.classList.remove('hidden');
|
|
setTimeout(() => createIcons({ icons }), 100);
|
|
});
|
|
|
|
const extractedFieldNames: Set<string> = new Set();
|
|
|
|
function extractExistingFields(pdfDoc: PDFDocument): void {
|
|
try {
|
|
const extractionResult: ExtractExistingFieldsResult =
|
|
extractExistingPdfFields({
|
|
pdfDoc,
|
|
fieldCounterStart: fieldCounter,
|
|
metrics: {
|
|
pdfViewerOffset,
|
|
pdfViewerScale,
|
|
},
|
|
});
|
|
|
|
fields.push(...extractionResult.fields);
|
|
fieldCounter = extractionResult.nextFieldCounter;
|
|
|
|
extractionResult.extractedFieldNames.forEach((name) => {
|
|
extractedFieldNames.add(name);
|
|
});
|
|
|
|
console.log(
|
|
`Extracted ${extractionResult.extractedFieldNames.size} existing fields for editing`
|
|
);
|
|
} catch (error) {
|
|
console.warn('Error extracting existing fields:', error);
|
|
}
|
|
}
|
|
|
|
async function handlePdfUpload(file: File) {
|
|
try {
|
|
const result = await loadPdfWithPasswordPrompt(file);
|
|
if (!result) return;
|
|
const arrayBuffer = result.bytes;
|
|
uploadedPdfjsDoc = result.pdf;
|
|
uploadedPdfDoc = await loadPdfDocument(arrayBuffer);
|
|
uploadedFileName = file.name;
|
|
|
|
// Check for existing fields and update counter
|
|
existingFieldNames.clear();
|
|
try {
|
|
const form = uploadedPdfDoc.getForm();
|
|
const pdfFields = form.getFields();
|
|
|
|
// console.log('📋 Found', pdfFields.length, 'existing fields in uploaded PDF')
|
|
|
|
pdfFields.forEach((field) => {
|
|
const name = field.getName();
|
|
existingFieldNames.add(name); // Track all existing field names
|
|
|
|
if (field instanceof PDFRadioGroup) {
|
|
existingRadioGroups.add(name);
|
|
}
|
|
|
|
// console.log(' Field:', name, '| Type:', field.constructor.name)
|
|
|
|
const match = name.match(/([a-zA-Z]+)_(\d+)/);
|
|
if (match) {
|
|
const num = parseInt(match[2]);
|
|
if (!isNaN(num) && num > fieldCounter) {
|
|
fieldCounter = num;
|
|
console.log(' → Updated field counter to:', fieldCounter);
|
|
}
|
|
}
|
|
});
|
|
|
|
// TODO@ALAM: DEBUGGER
|
|
// console.log('Field counter after upload:', fieldCounter)
|
|
// console.log('Existing field names:', Array.from(existingFieldNames))
|
|
} catch (e) {
|
|
console.log('No form fields found or error reading fields:', e);
|
|
}
|
|
|
|
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;
|
|
|
|
pendingFieldExtraction = true;
|
|
|
|
renderCanvas();
|
|
updatePageNavigation();
|
|
|
|
// Hide upload area and show tool container
|
|
uploadArea.classList.add('hidden');
|
|
toolContainer.classList.remove('hidden');
|
|
|
|
// Init icons
|
|
setTimeout(() => createIcons({ icons }), 100);
|
|
} catch (error) {
|
|
console.error('Error loading PDF:', error);
|
|
showModal(
|
|
'Error',
|
|
'Error loading PDF file. Please try again with a valid PDF.',
|
|
'error'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Page navigation
|
|
prevPageBtn.addEventListener('click', () => {
|
|
if (currentPageIndex > 0) {
|
|
switchToPage(currentPageIndex - 1);
|
|
}
|
|
});
|
|
|
|
nextPageBtn.addEventListener('click', () => {
|
|
if (currentPageIndex < pages.length - 1) {
|
|
switchToPage(currentPageIndex + 1);
|
|
}
|
|
});
|
|
|
|
addPageBtn.addEventListener('click', () => {
|
|
createBlankPage();
|
|
switchToPage(pages.length - 1);
|
|
});
|
|
|
|
resetBtn.addEventListener('click', () => {
|
|
if (fields.length > 0 || pages.length > 0) {
|
|
if (
|
|
confirm('Are you sure you want to reset? All your work will be lost.')
|
|
) {
|
|
resetToInitial();
|
|
}
|
|
} else {
|
|
resetToInitial();
|
|
}
|
|
});
|
|
|
|
// Custom Modal Logic
|
|
const errorModal = document.getElementById('errorModal');
|
|
const errorModalTitle = document.getElementById('errorModalTitle');
|
|
const errorModalMessage = document.getElementById('errorModalMessage');
|
|
const errorModalClose = document.getElementById('errorModalClose');
|
|
|
|
let modalCloseCallback: (() => void) | null = null;
|
|
|
|
function showModal(
|
|
title: string,
|
|
message: string,
|
|
type: 'error' | 'warning' | 'info' = 'error',
|
|
onClose?: () => void,
|
|
buttonText: string = 'Close'
|
|
) {
|
|
if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose)
|
|
return;
|
|
|
|
errorModalTitle.textContent = title;
|
|
errorModalMessage.textContent = message;
|
|
errorModalClose.textContent = buttonText;
|
|
|
|
modalCloseCallback = onClose || null;
|
|
errorModal.classList.remove('hidden');
|
|
}
|
|
|
|
if (errorModalClose) {
|
|
errorModalClose.addEventListener('click', () => {
|
|
errorModal?.classList.add('hidden');
|
|
if (modalCloseCallback) {
|
|
modalCloseCallback();
|
|
modalCloseCallback = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Close modal on backdrop click
|
|
if (errorModal) {
|
|
errorModal.addEventListener('click', (e) => {
|
|
if (e.target === errorModal) {
|
|
errorModal.classList.add('hidden');
|
|
if (modalCloseCallback) {
|
|
modalCloseCallback();
|
|
modalCloseCallback = null;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
initializeGlobalShortcuts();
|