import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useEditor } from '@craftjs/core'; import { BLOCKS, insertBlock } from '../panels/left/blocks-registry'; /** Block ids offered in the inline popover (resolved through the registry). */ const QUICK_BLOCK_IDS = ['heading', 'text', 'button', 'image', 'columns-2', 'spacer']; const QUICK_LABELS: Record = { 'columns-2': 'Columns' }; /** * Inline "+" for an empty container/section/column: shown for the hovered or * selected canvas node that has zero children (never ROOT). Click opens a * small block picker that inserts into THAT node. Mounted from CanvasOverlays. */ export const EmptyContainerAdd: React.FC = () => { // Primitives only: ids / booleans, so unrelated store churn doesn't re-render. const { query, actions, targetId, isDragging } = useEditor((state) => { const eligible = (id: string | undefined): id is string => { if (!id || id === 'ROOT') return false; const n = state.nodes[id]; return !!n && !!n.data.isCanvas && n.data.nodes.length === 0; }; const hovered = [...state.events.hovered][0]; const selected = [...state.events.selected][0]; return { targetId: eligible(hovered) ? hovered : eligible(selected) ? selected : null, isDragging: state.events.dragged.size > 0, }; }); // The "+" lives outside the node's DOM, so moving the pointer onto it ends // Craft's hover; keep the last target alive while the button/popover is in use. const [keepId, setKeepId] = useState(null); const [open, setOpen] = useState(false); const rootRef = useRef(null); const stillEmpty = (id: string | null): id is string => { if (!id) return false; try { const n = query.node(id).get(); return !!n && !!n.data.isCanvas && n.data.nodes.length === 0; } catch { return false; } }; const nodeId = isDragging ? null : targetId ?? (stillEmpty(keepId) ? keepId : null); const dom: HTMLElement | null = nodeId ? query.node(nodeId).get()?.dom ?? null : null; const [pos, setPos] = useState<{ x: number; y: number } | null>(null); const update = useCallback(() => { if (!dom) return setPos(null); const r = dom.getBoundingClientRect(); setPos({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); }, [dom]); useLayoutEffect(() => { update(); if (!dom) return; window.addEventListener('resize', update); document.addEventListener('scroll', update, true); return () => { window.removeEventListener('resize', update); document.removeEventListener('scroll', update, true); }; }, [dom, update]); const close = useCallback(() => { setOpen(false); setKeepId(null); }, []); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) close(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open, close]); // Target vanished (filled, dragged, deselected): drop popover state. useEffect(() => { if (!nodeId && open) setOpen(false); }, [nodeId, open]); if (!nodeId || !pos) return null; const choose = (blockId: string) => { insertBlock(query, actions, blockId, { parentId: nodeId, select: true }); close(); }; const blocks = QUICK_BLOCK_IDS .map((id) => BLOCKS.find((b) => b.id === id)) .filter((b): b is NonNullable => !!b); return (
{open && (
{blocks.map((b) => ( ))}
)}
); };