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={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 (