import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; // `level` is settable via the AI `update_props` path and from deserialized // saved state -- neither type-checked at runtime -- and is interpolated // directly into the tag position (`React.createElement(level, ...)` / // `<${tag}` in `toHtml`). A malicious value like `h2> (ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2'; interface HeadingProps { text?: string; level?: HeadingLevel; style?: CSSProperties; cssId?: string; cssClass?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; animation?: string; animationDelay?: string; } export const Heading: UserComponent = ({ text = 'Heading', level = 'h2', style = {}, }) => { const { connectors: { connect, drag }, selected, actions: { setProp }, } = useNode((node) => ({ selected: node.events.selected, })); const safeLevel = sanitizeHeadingLevel(level); const elRef = useRef(null); const editedTextRef = useRef(null); const commitText = useCallback(() => { if (elRef.current) { const newText = elRef.current.innerText; editedTextRef.current = newText; setProp((p: HeadingProps) => { p.text = newText; }); } }, [setProp]); // Commit on blur const handleBlur = useCallback(() => { commitText(); }, [commitText]); // Also commit on deselect via effect useEffect(() => { if (!selected && editedTextRef.current !== null) { setProp((p: HeadingProps) => { p.text = editedTextRef.current!; }); editedTextRef.current = null; } }, [selected, setProp]); // Set DOM text on mount and when text prop changes externally (not during editing) useEffect(() => { if (elRef.current && !selected && editedTextRef.current === null) { elRef.current.innerText = text || ''; } }, [text, selected]); return React.createElement(safeLevel, { ref: (ref: HTMLElement | null): void => { elRef.current = ref; if (ref) connect(drag(ref)); }, contentEditable: selected, suppressContentEditableWarning: true, onBlur: handleBlur, onInput: () => { // Track that we have unsaved edits if (elRef.current) { editedTextRef.current = elRef.current.innerText; } }, style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style }, }); }; Heading.craft = { displayName: 'Heading', props: { text: 'Your Heading', level: 'h2' as HeadingLevel, style: { fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px', }, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; (Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { const tag = sanitizeHeadingLevel(props.level); const safeText = (props.text || '').replace(//g, '>'); const styleStr = cssPropsToString(props.style); return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}` }; };