import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, AlignJustify, Minus, Plus, ChevronDown, Check, RotateCcw } from 'lucide-react'; import { HexColorPicker } from 'react-colorful'; import { systemFonts, googleFonts } from './FontFamilyToggle'; import { loadFormFont } from '../utils/resolveFieldStyle'; import { HexInput } from './InlineLabelEditor'; // Selected text inside the edited content element (the reference builder: bold/italic/underline/colour apply to the selection). const selectionInside = (el) => { if (!el || typeof window === 'undefined') return null; const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null; const range = sel.getRangeAt(0); return el.contains(range.commonAncestorContainer) ? range : null; }; export default function InlineContentEditor({ isOpen, onClose, field, onUpdate, anchorRef, setIsDirty }) { // the reference builder keys (fontSize, textColor, textAlign, textStyle{}, fontFamily, lineHeight) are what the storefront reads; // the older content* keys are read as a fallback for forms saved before. const ts = field.textStyle && typeof field.textStyle === 'object' ? field.textStyle : {}; // Canvas convention: numbers above 3 are px, smaller ones a multiplier of the font size. const rawLh = parseFloat(field.lineHeight) || field.contentLineHeight || 20; const initialLineHeight = rawLh <= 3 ? Math.round(rawLh * (Number(field.fontSize ?? field.contentFontSize) || 14)) : rawLh; const [fontSize, setFontSize] = useState(Number(field.fontSize ?? field.contentFontSize) || 14); const [fontFamily, setFontFamily] = useState(field.fontFamily || field.contentFontFamily || 'inherit'); const [lineHeight, setLineHeight] = useState(initialLineHeight); const [textColor, setTextColor] = useState(field.textColor || field.contentColor || '#374151'); const [textAlign, setTextAlign] = useState(field.textAlign || field.contentAlign || 'left'); const [isBold, setIsBold] = useState(typeof ts.bold === 'boolean' ? ts.bold : (field.contentFontWeight === 'bold' || field.contentFontWeight >= 600)); const [isItalic, setIsItalic] = useState(typeof ts.italic === 'boolean' ? ts.italic : field.contentFontStyle === 'italic'); const [isUnderline, setIsUnderline] = useState(typeof ts.underline === 'boolean' ? ts.underline : field.contentDecoration === 'underline'); const [showColorPicker, setShowColorPicker] = useState(false); const [showFontDropdown, setShowFontDropdown] = useState(false); const [fontSearch, setFontSearch] = useState(''); const toolbarRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); // Last selection inside the content, restored before a toolbar command (inputs in the toolbar take the focus). const savedRange = useRef(null); useEffect(() => { if (!isOpen) return undefined; const track = () => { const r = selectionInside(anchorRef?.current); if (r) savedRange.current = r.cloneRange(); }; document.addEventListener('selectionchange', track); return () => document.removeEventListener('selectionchange', track); }, [isOpen, anchorRef]); // Closing commits the edited HTML: the content element saves on blur. const close = () => { setShowColorPicker(false); setShowFontDropdown(false); const el = anchorRef?.current; if (el && (document.activeElement === el || el.contains(document.activeElement))) el.blur(); savedRange.current = null; onClose(); }; // Escape closes the toolbar (and any open picker) from anywhere. useEffect(() => { if (!isOpen) return undefined; const onKey = (e) => { if (e.key !== 'Escape') return; e.stopPropagation(); close(); }; document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); }); /** * Formatting command on the selected text (execCommand, saved into the content HTML), or null when nothing is * selected (the caller then formats the whole block with the field keys). */ const execOnSelection = (command, value = null) => { const el = anchorRef?.current; let range = selectionInside(el); if (!range && savedRange.current && el && el.contains(savedRange.current.commonAncestorContainer) && !savedRange.current.collapsed) { range = savedRange.current; const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } if (!range) return false; el.focus({ preventScroll: true }); try { document.execCommand('styleWithCSS', false, true); } catch (e) { /* not supported */ } document.execCommand(command, false, value); const after = selectionInside(el); if (after) savedRange.current = after.cloneRange(); if (setIsDirty) setIsDirty(true); return true; }; // Toolbar buttons must not take the focus (the selection in the content would be lost). const keepSelection = (e) => e.preventDefault(); useEffect(() => { if (isOpen && anchorRef?.current) { const rect = anchorRef.current.getBoundingClientRect(); const toolbarWidth = 440; let left = rect.left; let top = rect.top - 48; if (top < 10) top = rect.bottom + 8; if (left < 10) left = 10; if (left + toolbarWidth > window.innerWidth - 10) left = window.innerWidth - toolbarWidth - 10; setPosition({ top, left }); } }, [isOpen, anchorRef]); useEffect(() => { if (!isOpen) return; const handler = (e) => { if (toolbarRef.current && !toolbarRef.current.contains(e.target) && !anchorRef?.current?.contains(e.target)) { savedRange.current = null; onClose(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [isOpen, onClose, anchorRef]); // Only the changed keys (the content itself is committed separately by the canvas on blur). const applyChanges = (updates) => { if (setIsDirty) setIsDirty(true); onUpdate(updates); }; const applyColor = (c) => { if (execOnSelection('foreColor', c)) return; setTextColor(c); applyChanges({ textColor: c }); }; const styleUpdate = (patch) => ({ textStyle: { bold: isBold, italic: isItalic, underline: isUnderline, ...patch } }); const filteredFonts = [ ...systemFonts.map(f => ({ name: f, type: 'System' })), ...googleFonts.map(f => ({ name: f, type: 'Google' })) ].filter(f => f.name.toLowerCase().includes(fontSearch.toLowerCase())); if (!isOpen) return null; return createPortal(