import React, { useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Bold, Italic, Underline, Type, Heading1, ChevronDown, Check, Minus, Plus, AlignLeft, AlignCenter, AlignRight, AlignJustify, RotateCcw } from "lucide-react"; import { HexColorPicker } from "react-colorful"; import { systemFonts, googleFonts } from "./FontFamilyToggle"; import { loadFormFont } from "../utils/resolveFieldStyle"; import { useFormStore } from "../store/useFormStore"; export default function InlineLabelEditor({ field, viewMode = "edit", mode = "label", // "label" | "description" | "placeholder" | "heading" | "option" headingFormat, defaultFieldName = "Unnamed Field", onSave, onUpdate, setIsDirty, suffix = null, anchorRef, isOpen: externalIsOpen, onClose: externalOnClose, // Storefront-resolved display (src/utils/resolveFieldStyle.js). When given, the label/description/heading is shown // exactly with these styles and this text; the editor toolbar still edits the field keys. displayStyle = null, displayText = null, asteriskColor = null, }) { const isControlled = externalIsOpen !== undefined; // A field-level font (labelFontFamily/descriptionFontFamily/section fontFamily) beats the form font, like the // storefront's per-field !important rules: the canvas CSS reads it from --wpvfb-font on [data-font]. const fontAttr = displayStyle && displayStyle.fontFamily ? { "data-font": "" } : {}; if (displayStyle && displayStyle.fontFamily) displayStyle = { ...displayStyle, "--wpvfb-font": displayStyle.fontFamily }; const [internalIsEditing, setInternalIsEditing] = useState(false); const isEditing = isControlled ? externalIsOpen : internalIsEditing; const setIsEditing = (val) => { if (isControlled) { if (!val && externalOnClose) externalOnClose(); } else { setInternalIsEditing(val); } }; // Get current text value based on mode const getCurrentValue = () => { if (mode === "description") return field.description || ""; if (mode === "placeholder") return field.placeholder || ""; if (mode === "option") return field.name || field.label || defaultFieldName; return field.label || field.name || defaultFieldName; }; const settings = useFormStore((state) => state.settings) || {}; const resolvedDefaultFont = field.labelFontFamily || field.fontFamily || settings?.fontFamily || "inherit"; // Valid heading tag (the reference builder headingFormat first; a bad saved value falls back to h3). const HEADING_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; const validTag = (t) => (HEADING_TAGS.includes(t) ? t : null); const isHeading = Boolean(headingFormat) || mode === "heading"; const headingTag = validTag(headingFormat) || validTag(field.headingFormat) || "h3"; const headingDefaultSize = (tag) => Number(settings?.headingFontSizes?.[tag]) || Number(settings?.[`heading${tag.toUpperCase()}Size`]) || { h1: 32, h2: 28, h3: 24, h4: 20, h5: 18, h6: 16 }[tag] || 20; // What the canvas shows when the field has no value of its own. Labels fall back to the global Styling settings // (settings.label*/descriptionColor), like the storefront renderer. const pickSet = (...vals) => vals.find((v) => v !== undefined && v !== null && v !== ""); const display = (() => { if (mode === "description") { return { color: pickSet(field.descriptionColor, settings.descriptionColor, "#6B7280"), fontSize: Number(pickSet(field.descriptionFontSize, settings.descriptionFontSize, settings.fontSize, 14)), lineHeight: Number(pickSet(field.descriptionLineHeight, settings.lineHeight > 3 ? settings.lineHeight : null, 20)), fontWeight: pickSet(field.descriptionFontWeight, 400), fontStyle: pickSet(field.descriptionFontStyle, "normal"), fontFamily: pickSet(field.descriptionFontFamily, "inherit"), }; } if (mode === "option") { return { color: pickSet(field.labelTextColor, field.labelColor, settings.labelColor, "#374151"), fontSize: Number(pickSet(field.fontSize, 14)), lineHeight: Number(pickSet(field.lineHeight, 20)), fontWeight: pickSet(field.labelFontWeight, settings.labelFontWeight, 500), fontStyle: pickSet(field.labelFontStyle, "normal"), fontFamily: resolvedDefaultFont, }; } if (isHeading) { // Section headings: field.fontSize, else the theme size of the heading level (headingFontSizes). return { color: pickSet(field.labelTextColor, field.labelColor, field.textColor, settings.labelColor, "#111827"), fontSize: Number(pickSet(field.fontSize, headingDefaultSize(headingTag))), lineHeight: Number(pickSet(field.lineHeight, settings.headingLineHeight > 3 ? settings.headingLineHeight : null, 28)), fontWeight: pickSet(field.labelFontWeight, 400), fontStyle: pickSet(field.labelFontStyle, "normal"), fontFamily: pickSet(field.fontFamily, resolvedDefaultFont), }; } if (mode === "placeholder") { return { color: pickSet(field.placeholderTextColor, field.placeholderColor, settings.placeholderTextColor, "#9CA3AF"), fontSize: 14, fontFamily: resolvedDefaultFont }; } return { color: pickSet(field.labelTextColor, field.labelColor, settings.labelColor, "#374151"), fontSize: Number(pickSet(field.labelFontSize, settings.labelFontSize, 14)), lineHeight: Number(pickSet(field.labelLineHeight, settings.labelLineHeight, 20)), fontWeight: pickSet(field.labelFontWeight, settings.labelFontWeight, 500), fontStyle: pickSet(field.labelFontStyle, "normal"), fontFamily: field.labelFontFamily || resolvedDefaultFont, }; })(); // Editor state: null = "not set on the field, use the default". Only keys the user changes are written. const initialState = () => { if (mode === "description") { return { color: field.descriptionColor ?? null, fontSize: field.descriptionFontSize ?? null, fontFamily: field.descriptionFontFamily ?? null, lineHeight: field.descriptionLineHeight ?? null, fontWeight: field.descriptionFontWeight ?? null, fontStyle: field.descriptionFontStyle ?? null }; } if (mode === "placeholder") { return { color: field.placeholderTextColor ?? field.placeholderColor ?? null, fontSize: null, fontFamily: null, lineHeight: null, fontWeight: null, fontStyle: null }; } if (isHeading) { // the reference builder section keys (read by the storefront section renderer): fontSize, lineHeight, fontFamily. return { color: field.labelTextColor ?? field.labelColor ?? null, fontSize: field.fontSize ?? null, lineHeight: field.lineHeight ?? null, fontWeight: field.labelFontWeight ?? null, fontStyle: field.labelFontStyle ?? null, fontFamily: field.fontFamily ?? null, }; } if (mode === "option") { return { color: field.labelTextColor ?? null, fontSize: field.fontSize ?? null, lineHeight: field.lineHeight ?? null, fontWeight: field.labelFontWeight ?? null, fontStyle: field.labelFontStyle ?? null, fontFamily: null }; } return { color: field.labelTextColor ?? field.labelColor ?? null, fontSize: field.labelFontSize ?? null, lineHeight: field.labelLineHeight ?? null, fontWeight: field.labelFontWeight ?? null, fontStyle: field.labelFontStyle ?? null, fontFamily: field.labelFontFamily ?? null, }; }; const init = initialState(); // State management const [editingValue, setEditingValue] = useState(getCurrentValue()); const [editingFontFamily, setEditingFontFamily] = useState(init.fontFamily); const [editingFontSize, setEditingFontSize] = useState(init.fontSize); const [editingLineHeight, setEditingLineHeight] = useState(init.lineHeight); const [editingFontWeight, setEditingFontWeight] = useState(init.fontWeight); const [editingFontStyle, setEditingFontStyle] = useState(init.fontStyle); const [editingUnderline, setEditingUnderline] = useState(field.labelUnderline || false); const [editingColor, setEditingColor] = useState(init.color); const [editingHeadingFormat, setEditingHeadingFormat] = useState(headingTag); const [editingAlignment, setEditingAlignment] = useState(field.labelAlignment || "left"); // Effective values shown in the toolbar / input while editing. // Headings without their own size follow the theme size of the level being edited (H1–H4 → headingFontSizes). const shownFontSize = Number(editingFontSize ?? (isHeading && field.fontSize == null ? headingDefaultSize(editingHeadingFormat) : display.fontSize) ?? 14); const shownLineHeight = Number(editingLineHeight ?? display.lineHeight ?? 20); const shownFontWeight = editingFontWeight ?? display.fontWeight ?? 500; const shownFontStyle = editingFontStyle ?? display.fontStyle ?? "normal"; const shownColor = editingColor ?? display.color ?? "#111827"; const shownFontFamily = editingFontFamily ?? display.fontFamily ?? "inherit"; const isBoldWeight = (w) => w === "bold" || Number(w) >= 600; const resetEditingState = () => { const i = initialState(); setEditingValue(getCurrentValue()); setEditingFontFamily(i.fontFamily); setEditingFontSize(i.fontSize); setEditingLineHeight(i.lineHeight); setEditingFontWeight(i.fontWeight); setEditingFontStyle(i.fontStyle); setEditingUnderline(field.labelUnderline || false); setEditingColor(i.color); setEditingHeadingFormat(validTag(headingFormat) || validTag(field.headingFormat) || "h3"); setEditingAlignment(field.labelAlignment || "left"); }; // Dropdown / Popover states const [showColorPicker, setShowColorPicker] = useState(false); const [showFontFamilyDropdown, setShowFontFamilyDropdown] = useState(false); const [showHeadingDropdown, setShowHeadingDropdown] = useState(false); const [fontSearch, setFontSearch] = useState(""); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); // Refs const inputRef = useRef(null); const toolbarRef = useRef(null); const containerRef = useRef(null); // Sync state when field prop changes // Not while editing: parents rebuild option field objects on every render. useEffect(() => { if (!isEditing) resetEditingState(); }, [field, mode, isEditing]); // Position calculation useEffect(() => { if (isEditing) { const updatePos = () => { const targetEl = anchorRef?.current || containerRef.current || inputRef.current; if (!targetEl) return; const rect = targetEl.getBoundingClientRect(); const toolbarWidth = 420; let left = rect.left; let top = rect.top - 46; if (top < 10) top = rect.bottom + 8; if (left < 10) left = 10; if (left + toolbarWidth > window.innerWidth - 10) left = window.innerWidth - toolbarWidth - 10; setToolbarPosition({ top, left }); }; updatePos(); window.addEventListener("scroll", updatePos, true); window.addEventListener("resize", updatePos); return () => { window.removeEventListener("scroll", updatePos, true); window.removeEventListener("resize", updatePos); }; } }, [isEditing, anchorRef]); // Outside click commits and closes (latest commit function via ref, so no stale state). const commitRef = useRef(null); useEffect(() => { if (isEditing) { const handleOutside = (e) => { const targetEl = anchorRef?.current || containerRef.current; if (targetEl && targetEl.contains(e.target)) return; if (toolbarRef.current && toolbarRef.current.contains(e.target)) return; if (commitRef.current) commitRef.current(); setIsEditing(false); }; document.addEventListener("mousedown", handleOutside); return () => document.removeEventListener("mousedown", handleOutside); } }, [isEditing]); const handleCommitSave = () => { const updates = {}; const changed = (now, before) => now !== before; // null in the editor = "use the theme default": the key is removed from the field. const put = (key, value) => { updates[key] = value === null ? undefined : value; }; const i = initialState(); const valueChanged = editingValue !== getCurrentValue(); if (mode === "description") { if (valueChanged) updates.description = editingValue; if (changed(editingColor, i.color)) put("descriptionColor", editingColor); if (changed(editingFontSize, i.fontSize)) put("descriptionFontSize", editingFontSize); if (changed(editingFontFamily, i.fontFamily)) put("descriptionFontFamily", editingFontFamily); if (changed(editingLineHeight, i.lineHeight)) put("descriptionLineHeight", editingLineHeight); if (changed(editingFontWeight, i.fontWeight)) put("descriptionFontWeight", editingFontWeight); if (changed(editingFontStyle, i.fontStyle)) put("descriptionFontStyle", editingFontStyle); } else if (mode === "placeholder") { if (valueChanged) updates.placeholder = editingValue; // the reference builder key read by the storefront (older placeholderColor stays a fallback). if (changed(editingColor, i.color)) put("placeholderTextColor", editingColor); } else if (mode === "option") { // Option formatting uses the keys the storefront reads per option (labelTextColor, fontSize, lineHeight). if (valueChanged) { updates.label = editingValue; updates.name = editingValue; } if (changed(editingFontWeight, i.fontWeight)) put("labelFontWeight", editingFontWeight); if (changed(editingFontStyle, i.fontStyle)) put("labelFontStyle", editingFontStyle); if (changed(editingColor, i.color)) put("labelTextColor", editingColor); if (changed(editingFontSize, i.fontSize)) put("fontSize", editingFontSize); if (changed(editingLineHeight, i.lineHeight)) put("lineHeight", editingLineHeight); } else if (isHeading) { // Section heading: the reference builder keys the storefront section renderer reads. if (valueChanged) { updates.label = editingValue; updates.name = editingValue; } if (changed(editingColor, i.color)) put("labelTextColor", editingColor); if (changed(editingFontSize, i.fontSize)) put("fontSize", editingFontSize); if (changed(editingLineHeight, i.lineHeight)) put("lineHeight", editingLineHeight); if (changed(editingFontFamily, i.fontFamily)) put("fontFamily", editingFontFamily); if (changed(editingFontWeight, i.fontWeight)) put("labelFontWeight", editingFontWeight); if (changed(editingFontStyle, i.fontStyle)) put("labelFontStyle", editingFontStyle); if (changed(editingHeadingFormat, headingTag)) updates.headingFormat = editingHeadingFormat; } else { if (valueChanged) { updates.label = editingValue; updates.name = editingValue; } // Label colour: the reference builder key labelTextColor (read by the storefront); older labelColor stays as a fallback. if (changed(editingColor, i.color)) { put("labelTextColor", editingColor); if (editingColor === null && field.labelColor !== undefined) updates.labelColor = undefined; } if (changed(editingFontSize, i.fontSize)) put("labelFontSize", editingFontSize); if (changed(editingFontFamily, i.fontFamily)) put("labelFontFamily", editingFontFamily); if (changed(editingFontWeight, i.fontWeight)) put("labelFontWeight", editingFontWeight); if (changed(editingFontStyle, i.fontStyle)) put("labelFontStyle", editingFontStyle); if (changed(editingLineHeight, i.lineHeight)) put("labelLineHeight", editingLineHeight); if (changed(editingUnderline, field.labelUnderline || false)) updates.labelUnderline = editingUnderline; if (changed(editingAlignment, field.labelAlignment || "left")) updates.labelAlignment = editingAlignment; } if (Object.keys(updates).length === 0) return; if (setIsDirty) setIsDirty(true); if (onSave) onSave(editingValue, updates); if (onUpdate) onUpdate(updates); }; commitRef.current = handleCommitSave; // Clicking a label selects its field (and opens its settings), like the reference builder. const selectOwnerField = () => { if (field?.id) useFormStore.getState().setActiveFieldId?.(field.id); }; const filteredFonts = [ ...systemFonts.map(f => ({ name: f, type: 'System' })), ...googleFonts.map(f => ({ name: f, type: 'Google' })) ].filter(f => f.name.toLowerCase().includes(fontSearch.toLowerCase())); // Render Display Mode (when not editing) if (!isEditing) { if (isHeading) { const Tag = headingTag; return ( { if (viewMode === 'preview') return; e.stopPropagation(); selectOwnerField(); setIsEditing(true); }} className={`inline-editor-trigger cursor-pointer hover:outline hover:outline-1 hover:outline-blue-500 rounded transition-all${displayStyle ? '' : ' px-1'}`} style={displayStyle || { fontFamily: display.fontFamily, fontSize: `${display.fontSize}px`, fontWeight: display.fontWeight, fontStyle: display.fontStyle, textDecoration: field.labelUnderline ? "underline" : "none", color: display.color, lineHeight: `${display.lineHeight}px`, textAlign: field.labelAlignment || "left" }} > {displayText ?? getCurrentValue()} {field.required && !displayStyle && *} {suffix} ); } if (mode === "description") { if (!field.description && !isEditing) { return null; } return (

{ if (viewMode === 'preview') return; e.stopPropagation(); selectOwnerField(); setIsEditing(true); }} className={displayStyle ? 'inline-editor-trigger cursor-pointer hover:outline hover:outline-1 hover:outline-blue-500 rounded transition-all' : 'inline-editor-trigger cursor-pointer hover:outline hover:outline-1 hover:outline-blue-500 rounded px-1 transition-all text-xs text-gray-500'} style={displayStyle || { color: display.color, fontSize: `${display.fontSize}px`, fontFamily: display.fontFamily }} > {getCurrentValue() || Add field description...}

); } if (mode === "placeholder") { return (
{ if (viewMode === 'preview') return; e.stopPropagation(); selectOwnerField(); setIsEditing(true); }} className="inline-editor-trigger cursor-pointer hover:outline hover:outline-1 hover:outline-blue-500 rounded transition-all w-full" > {getCurrentValue() || "Enter placeholder..."}
); } // Storefront label: block