import React, { useState, useEffect, useRef, useContext, createContext } from 'react' import { useSortable, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { useDroppable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' import { Plus, Shield, Hash, X, Mail, Eye, EyeOff, Palette, ChevronDown, AlertTriangle, Trash2 } from 'lucide-react' import { useFormStore } from '../store/useFormStore' import FieldActions from './FieldActions' import InlineSpacingEditor from './InlineSpacingEditor' import InlineSubmitButtonEditor from './InlineSubmitButtonEditor' import InlineLabelEditor from './InlineLabelEditor' import InlineContentEditor from './InlineContentEditor' import InlineColumnBackgroundEditor from './InlineColumnBackgroundEditor' import InlineIconEditor from './InlineIconEditor' import InlineImageEditor from './InlineImageEditor' import InlineSwitchEditor from './InlineSwitchEditor' import { DropLine, ColumnAddFieldMenu, addFieldToColumn, PlaceholderEditOverlay, FieldErrorBoundary, withPriceSuffix, removeOptionUpdates } from './interaction' import { PromoBannerPreview, TrustBadgesPreview, CountdownBannerPreview } from './canvas/UtilityFieldPreview' import EditableContent from './canvas/EditableContent' import { SOCIAL_ICONS } from './canvas/socialIcons' import { shouldFieldBeVisible } from '../utils/evaluateConditions' import { applyFilters, getFieldType } from '../extensions/registry' import { useCanvasContext } from './canvas/CanvasContext' import { resolveGlobalSettings, toRendererField, labelStyle, labelText, asteriskColor, descriptionStyle, inputStyle, hoverBorder, focusBorder, placeholderColor, defaultPlaceholder, fieldMargin, submitStyle, staticContentStyle, sectionStyle, gridStyles, choiceOptionStyle, choiceColors, radioLabelStyle, or, fieldDescriptionStyle, } from '../utils/resolveFieldStyle' import { COUNTRY_LIST } from './canvas/countries' import { ksesPost } from './canvas/kses' import { NumberStepperPreview } from './canvas/NumberStepperPreview' // Instance of a repeated container being drawn: each instance gets its own preview values. Instance 0 keeps the plain // field id so conditional logic (which reads previewData[field.id]) keeps working for the first instance. export const FieldInstanceContext = createContext(0) // Field types this builder draws itself. Other types come from registerFieldType(); anything else is shown as an // unsupported-field placeholder (its data is kept untouched). export const BUILTIN_FIELD_TYPES = [ 'text', 'email', 'tel', 'phone', 'url', 'password', 'number', 'textarea', 'select', 'dropdown', 'checkbox', 'radio', 'switch', 'consent', 'html', 'section', 'static-content', 'name', 'date', 'time', 'address', 'hidden', 'image', 'captcha', 'grid', 'submit', 'promo-banner', 'trust-badges', 'faq-accordion', 'countdown-banner', ] const TEXT_INPUT_TYPES = ['text', 'email', 'tel', 'phone', 'url', 'password', 'number'] // Fields whose storefront markup has no field label above the control (they draw their own text, or none). const NO_LABEL_TYPES = ['section', 'submit', 'static-content', 'html', 'image', 'hidden', 'consent', 'promo-banner', 'trust-badges', 'faq-accordion', 'countdown-banner'] const nullishValue = (v) => v === undefined || v === null // Storefront radio inputs carry no colour variables: unchecked border #71717a, checked border the form primary colour // (--color-primary-button-background), black dot — whatever radioSelectedColor says. const RADIO_INPUT_VARS = (gs) => ({ '--choice-selected': gs.primaryColor, '--choice-unselected': '#71717a' }) const NO_DESCRIPTION_TYPES = ['section', 'submit', 'static-content', 'html', 'hidden'] const ICON_MAP = { text: Hash, email: Mail, } const getOptionLabel = (opt) => { if (opt === null || opt === undefined) return '' if (typeof opt === 'object') { return opt.label ?? opt.name ?? opt.text ?? opt.value ?? '' } return String(opt) } const getOptionValue = (opt) => { if (opt === null || opt === undefined) return '' if (typeof opt === 'object') { return opt.value ?? opt.label ?? '' } return String(opt) } export const resolveImageUrl = (imgSrc) => { if (!imgSrc) return '' if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://') || imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) { return imgSrc } if (typeof window !== 'undefined' && window.wpvfbSettings?.pluginUrl) { const cleanPlugin = window.wpvfbSettings.pluginUrl.replace(/\/$/, '') const cleanPath = imgSrc.replace(/^\//, '') return `${cleanPlugin}/dist/${cleanPath}` } return imgSrc } // The HTML field is the admin's own markup; on the canvas we still drop scripts, inline event handlers and // javascript: URLs so an imported form cannot run code inside wp-admin. export const sanitizeCanvasHtml = (html) => { if (!html || typeof window === 'undefined' || !window.DOMParser) return html || '' try { const doc = new window.DOMParser().parseFromString(`
${html}
`, 'text/html') const root = doc.body.firstChild root.querySelectorAll('script').forEach((el) => el.remove()) root.querySelectorAll('*').forEach((el) => { Array.from(el.attributes).forEach((attr) => { const name = attr.name.toLowerCase() const value = String(attr.value || '').replace(/[\s\u0000-\u001f]/g, '').toLowerCase() if (name.startsWith('on')) el.removeAttribute(attr.name) else if (['href', 'src', 'xlink:href', 'action', 'formaction', 'srcdoc'].includes(name) && (name === 'srcdoc' || value.startsWith('javascript:') || value.startsWith('vbscript:'))) el.removeAttribute(attr.name) }) }) return root.innerHTML } catch (e) { return '' } } export const getGridTemplateColumns = (field, colKeys) => { if (!colKeys || colKeys.length <= 1) return '1fr' if (colKeys.length === 2) { if (field.columnWidths === 'narrow-wide') return '38% 62%' if (field.columnWidths === 'wide-narrow') return '62% 38%' if (field.columnWidths === 'custom' && Array.isArray(field.customColumnWidths)) { return `${field.customColumnWidths[0] || 50}% ${field.customColumnWidths[1] || 50}%` } } return `repeat(${colKeys.length}, minmax(0, 1fr))` } // Column background: the reference builder `columnBackgrounds.columnN` first, then the older `columnSettings[colKey].bg*` keys. export const resolveColumnBackground = (field, colKey, colIdx) => { const colBg = field.columnBackgrounds?.[`column${colIdx + 1}`] || field.columnBackgrounds?.[colKey] || {} const old = field.columnSettings?.[colKey] || {} const pick = (a, b) => (a !== undefined && a !== null && a !== '' ? a : b) return { backgroundColor: pick(colBg.backgroundColor, old.bgColor), backgroundImageUrl: pick(colBg.backgroundImageUrl, old.bgImage), backgroundImageSize: pick(colBg.backgroundImageSize, old.bgSize), backgroundImagePosition: pick(colBg.backgroundImagePosition, old.bgPosition), backgroundImageRepeat: colBg.backgroundImageRepeat, } } export const columnBackgroundStyle = (bg) => { const style = {} if (bg.backgroundColor) style.backgroundColor = bg.backgroundColor if (bg.backgroundImageUrl) { style.backgroundImage = `url(${resolveImageUrl(bg.backgroundImageUrl)})` style.backgroundSize = bg.backgroundImageSize || 'cover' style.backgroundPosition = bg.backgroundImagePosition || 'center' style.backgroundRepeat = bg.backgroundImageRepeat || 'no-repeat' } return style } // One container column in Edit mode: the storefront column box (padding/background/radius from gridStyles) holding // the real fields. Edit chrome never changes the layout: an empty column shows a drop hint, the "Add Field" button // floats over the column while hovered, and the drop line is absolutely positioned. function GridColumn({ field, gridId, colKey, colIdx, colStyle, colOverlay, nestedList, showAddMenu, setShowAddMenu, handleAddNestedField, updateField, viewMode }) { const columnId = `${gridId}__${colKey}` const { setNodeRef, isOver } = useDroppable({ id: columnId }) const dragOverContainer = useFormStore((state) => state.dragOverContainer) const dragOverIndex = useFormStore((state) => state.dragOverIndex) const hiddenElements = useFormStore((state) => state.hiddenElements) const [isEditingBg, setIsEditingBg] = useState(false) const colRef = useRef(null) const isOverThisCol = dragOverContainer && dragOverContainer.type === 'grid' && dragOverContainer.gridId === gridId && dragOverContainer.colKey === colKey const colBgKey = `column${colIdx + 1}` const columnData = resolveColumnBackground(field, colKey, colIdx) const visibleNested = nestedList.filter((f) => !hiddenElements?.has?.(f.id)) const dropLine = return (
{ setNodeRef(node) colRef.current = node }} onClick={(e) => { if (viewMode === 'edit') { e.stopPropagation() useFormStore.getState().setActiveFieldId?.(field.id) useFormStore.getState().setSelectedColumn?.({ gridId: field.id, colKey, columnKey: colBgKey }) setIsEditingBg(true) } }} style={colStyle} data-grid-column={colBgKey} className="group/col" > {colOverlay &&
} {/* Column background: a floating button (the column itself has no spare area once it holds fields) */} {viewMode === 'edit' && ( )} {viewMode === 'edit' && isEditingBg && ( setIsEditingBg(false)} columnData={columnData} onUpdate={(updates) => { // the reference builder key (read by the storefront renderer): columnBackgrounds.columnN const current = field.columnBackgrounds || {} updateField(field.id, { columnBackgrounds: { ...current, [colBgKey]: { ...(current[colBgKey] || {}), ...updates } } }) }} anchorRef={colRef} /> )}
f.id)} strategy={verticalListSortingStrategy} > {nestedList.map((nestedField, nestedIdx) => { if (hiddenElements?.has?.(nestedField.id)) return null; const isPlaceholderHere = isOverThisCol && dragOverIndex === nestedIdx return (
{isPlaceholderHere && dropLine}
) })}
{isOverThisCol && visibleNested.length > 0 && (dragOverIndex === null || dragOverIndex >= nestedList.length) && ( )} {/* Empty column: a drop hint (edit chrome) so the column can still be targeted */} {viewMode === 'edit' && visibleNested.length === 0 && (
Drop fields here
)}
{/* Column Add Field Trigger (floats over the column, no layout impact); lists every palette field */} {viewMode === 'edit' && ( setShowAddMenu(showAddMenu === colKey ? null : colKey)} onClose={() => setShowAddMenu((cur) => (cur === colKey ? null : cur))} onAdd={(type, label) => handleAddNestedField(colKey, type, label)} /> )}
) } // Each field renders inside its own error boundary: one broken field never blanks the canvas. export default function FormField(props) { return ( ) } function FormFieldBody({ field, isNested = false }) { const viewMode = useFormStore((state) => state.viewMode) const fields = useFormStore((state) => state.fields) const removeField = useFormStore((state) => state.removeField) const duplicateField = useFormStore((state) => state.duplicateField) const activeFieldId = useFormStore((state) => state.activeFieldId) const setActiveFieldId = useFormStore((state) => state.setActiveFieldId) const addField = useFormStore((state) => state.addField) const updateField = useFormStore((state) => state.updateField) const settings = useFormStore((state) => state.settings) const previewData = useFormStore((state) => state.previewData) const setPreviewData = useFormStore((state) => state.setPreviewData) const setIsSubmittedInPreview = useFormStore((state) => state.setIsSubmittedInPreview) const hoveredFieldId = useFormStore((state) => state.hoveredFieldId) const instanceIndex = useContext(FieldInstanceContext) const typeDef = getFieldType(field.type) const { previewMode } = useCanvasContext() const isMobile = previewMode === 'mobile' // Storefront view of this field and of the form settings: every canvas style below resolves from these. const gs = resolveGlobalSettings(settings) const fp = React.useMemo(() => toRendererField(field), [field]) // Preview value key: per instance inside a repeated container (instance 0 = the field id itself). const pid = instanceIndex ? `${field.id}__row${instanceIndex}` : field.id const [isEditingPlaceholder, setIsEditingPlaceholder] = useState(false) const [isEditingSpacing, setIsEditingSpacing] = useState(false) const [isEditingSubmitButton, setIsEditingSubmitButton] = useState(false) const [isEditingContent, setIsEditingContent] = useState(false) const [isHovered, setIsHovered] = useState(false) const [showGlow, setShowGlow] = useState(false) const [showAddMenu, setShowAddMenu] = useState(null) // holds column key: 'col-0', etc. const [activeAccordion, setActiveAccordion] = useState(-1) const [showPassword, setShowPassword] = useState(false) const [isEditingIcon, setIsEditingIcon] = useState(false) const [isInputFocused, setIsInputFocused] = useState(false) const [isInputHovered, setIsInputHovered] = useState(false) const labelRef = useRef(null) const submitButtonRef = useRef(null) const contentRef = useRef(null) const iconRef = useRef(null) const spacingBtnRef = useRef(null) // Only call hooks for react-dnd if we are in edit mode to avoid warnings const sortable = useSortable({ id: field.id }) const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = sortable const isActive = activeFieldId === field.id // A registered container type may draw columns the field doesn't store yet (getColumns). const layoutColumns = field.columns || (typeDef && typeof typeDef.getColumns === 'function' ? typeDef.getColumns(field) : undefined) // Container drawing: grids, and registered container types (a filter may pick one for a field). const containerDef = applyFilters('canvas.containerType', typeDef && typeDef.container ? typeDef : null, field) useEffect(() => { if (isActive) { setShowGlow(true) const timer = setTimeout(() => setShowGlow(false), 2000) return () => clearTimeout(timer) } else { setShowGlow(false) } }, [isActive]) // the reference builder 12-column grid snapping resize handler const handleResize = (e) => { if (viewMode !== 'edit') return e.stopPropagation() e.preventDefault() const startX = e.clientX const resizeElement = e.target.closest('[data-field-id]') if (!resizeElement) return const initialWidth = resizeElement.offsetWidth // Nested fields snap to their grid column's 12 steps, top-level fields to the form's. const previewArea = (isNested && e.target.closest('[data-grid-column]')) || e.target.closest('.preview-area') if (!previewArea) return const previewStyles = getComputedStyle(previewArea) const previewAreaWidth = previewArea.offsetWidth - parseFloat(previewStyles.paddingLeft || 0) - parseFloat(previewStyles.paddingRight || 0) - parseFloat(previewStyles.borderLeftWidth || 0) - parseFloat(previewStyles.borderRightWidth || 0) - 20 const columnWidth = Math.floor(previewAreaWidth / 12) let lastCalculatedWidth = initialWidth let animationFrameId = null const onMouseMove = (moveEvent) => { if (animationFrameId) cancelAnimationFrame(animationFrameId) animationFrameId = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startX const newWidth = Math.min( Math.max(initialWidth + deltaX, columnWidth), previewAreaWidth ) const columnSteps = Math.round(newWidth / columnWidth) const snappedWidth = columnSteps * columnWidth lastCalculatedWidth = Math.min(snappedWidth, previewAreaWidth - 3) resizeElement.style.width = `${lastCalculatedWidth}px` }) } const onMouseUp = () => { if (animationFrameId) cancelAnimationFrame(animationFrameId) document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) const fieldId = resizeElement?.dataset?.fieldId const finalWidthPercent = (lastCalculatedWidth / previewArea.offsetWidth) * 100 const finalWidthPercentage = `${finalWidthPercent.toFixed(2)}%` if (fieldId) { updateField(fieldId, { width: finalWidthPercentage }) useFormStore.getState().setIsDirty?.(true) } } document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) } const noLabel = NO_LABEL_TYPES.includes(field.type) || Boolean(typeDef && typeDef.noLabel) const hasCustomWidth = Boolean(field.width) && !/^100(\.0+)?%$/.test(String(field.width)) const wrapperMargin = (() => { if (field.type === 'submit') return submitStyle(fp, gs).wrapperMargin if (field.type === 'static-content') return staticContentStyle(fp, gs).outer.margin if (field.type === 'section') return '0px 0px 16px 0px' if (field.type === 'grid' || containerDef) { const m = fp.margin && typeof fp.margin === 'object' ? fp.margin : {} return `${m.top ?? 0}px ${m.right ?? 0}px ${m.bottom ?? 0}px ${m.left ?? 0}px` } // These storefront fields have no outer margin (their own box margins are drawn inside). if (['hidden', 'image', 'promo-banner', 'trust-badges'].includes(field.type)) return '0px' return fieldMargin(fp) })() const style = { transform: CSS.Transform.toString(transform), transition: transition ? `${transition}, box-shadow 0.3s ease` : 'box-shadow 0.3s ease', opacity: isDragging ? 0.4 : 1, boxSizing: 'border-box', borderWidth: '0px', position: 'relative', overflow: 'visible', boxShadow: showGlow && !(field.type === 'submit' && field.submitButtonWidth && field.submitButtonWidth > 0) ? '0 0 15px rgba(59, 130, 246, 0.45)' : undefined, // Like the storefront: a field only gets a width when it was resized (100% = auto, so side margins never // overflow the card); on mobile every field is full width. width: !isMobile && hasCustomWidth ? field.width : undefined, minWidth: !isMobile && hasCustomWidth ? 'auto' : undefined, maxWidth: field.maxWidth ? (typeof field.maxWidth === 'number' ? `${field.maxWidth}px` : field.maxWidth) : '100%', // Inner padding is drawn by the field itself (dropdown/date/name…), like the storefront. padding: '0px', margin: wrapperMargin, } // Column "Add Field": palette defaults (createFieldWithDefaults), single-submit and column rules. const handleAddNestedField = (colKey, type, label) => { addFieldToColumn(field, layoutColumns, colKey, type, label) setShowAddMenu(null) } const handleRemoveNestedField = (colKey, nestedId, e) => { e.stopPropagation() const currentColumns = layoutColumns || {} const colFields = (currentColumns[colKey] || []).filter((f) => f.id !== nestedId) updateField(field.id, { columns: { ...currentColumns, [colKey]: colFields, }, }) if (activeFieldId === nestedId) { setActiveFieldId(null) } } // Choice option labels: their own formatting only (no parent label styles, no required asterisk), like the reference builder. const optionLabelField = (opt, optLabel) => { const o = opt && typeof opt === 'object' ? opt : {} return { id: field.id, type: field.type, label: optLabel, name: optLabel, required: false, fontFamily: field.fontFamily, labelFontWeight: o.labelFontWeight, labelFontStyle: o.labelFontStyle, labelTextColor: o.labelTextColor ?? o.labelColor ?? field.textColor, fontSize: o.fontSize, lineHeight: o.lineHeight, } } // Inline option-text editor (choice cards and standard options): saves label + formatting into options[idx]. // The wrapper takes no box (display: contents) so the text is laid out exactly like the storefront's option span. const optionEditor = (opt, optLabel, idx, list, displayStyle, text) => (
e.stopPropagation()} style={{ display: 'contents' }}> { const updated = [...list] if (typeof updated[idx] === 'object' && updated[idx] !== null) { // Card titles are shown without their " — $price" suffix; the saved label keeps it. updated[idx] = { ...updated[idx], ...formatting, label: withPriceSuffix(getOptionLabel(list[idx]), (formatting && formatting.label) ?? newLabel) } } else { updated[idx] = { label: newLabel, value: newLabel, ...formatting } } updateField(field.id, { options: updated }) }} />
) const renderConsentText = (text) => { if (!text) return null; const parts = text.split(/(\[.*?\]\(.*?\))/g); return parts.map((part, i) => { const match = part.match(/\[(.*?)\]\((.*?)\)/); if (match) { return {match[1]}; } return {part}; }); } const renderInputPreview = (disabled = true) => { // Storefront input styles (resolveFieldStyle): field keys → inputSettings/colours → defaults. Hover/focus // border colours only react in Preview, like the published form. const hasIcon = Boolean(field.fieldIcon) && field.fieldIcon !== 'none' const iconSide = field.iconPosition || 'left' const baseInput = inputStyle(fp, gs, { iconLeft: hasIcon && iconSide === 'left', iconRight: hasIcon && iconSide === 'right' }) const inputCommonStyle = { ...baseInput, borderColor: isInputFocused ? focusBorder(fp, gs) : (isInputHovered && viewMode !== 'edit' ? hoverBorder(fp, gs) : baseInput.borderColor), transition: 'border-color 0.2s ease-in-out, background-color 0.2s ease-in-out', } const placeholderText = field.placeholder || defaultPlaceholder(field.type) // Font Awesome field icon like the storefront (, 14px, 12px from the edge) in a 32px click target. const renderFieldIcon = (pos, top) => (hasIcon && iconSide === pos ? ( { if (viewMode === 'edit') { e.stopPropagation() setIsEditingIcon(true) } }} title={viewMode === 'edit' ? 'Click to customize icon' : undefined} className="absolute z-10 flex items-center" style={{ [pos]: 4, width: 32, height: 32, top: top || '50%', transform: top ? undefined : 'translateY(-50%)', justifyContent: pos === 'left' ? 'flex-start' : 'flex-end', [pos === 'left' ? 'paddingLeft' : 'paddingRight']: 8, cursor: viewMode === 'edit' ? 'pointer' : undefined, pointerEvents: viewMode === 'edit' ? 'auto' : 'none', }} > ) : null) if (field.type === 'number' && fp.numberStyle === 'stepper-slider') { const lab = labelStyle(fp, gs) const stepLabel = ( updateField(field.id, updates)} setIsDirty={useFormStore.getState().setIsDirty} /> ) return ( setPreviewData({ [pid]: n })} /> ) } // Registered field types draw their own control. if (typeDef && typeDef.Canvas) { const RegisteredCanvas = typeDef.Canvas return ( ) } switch (field.type) { case 'textarea': { // The storefront textarea has no icon (the reference builder embed): same here, so icons never shift the text. return (