Refs #41 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
129 lines
4.7 KiB
TypeScript
129 lines
4.7 KiB
TypeScript
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<string, string> = { '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<string | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(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<typeof b> => !!b);
|
|
|
|
return (
|
|
<div ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className="empty-container-add"
|
|
style={{ left: pos.x, top: pos.y }}
|
|
aria-label="Add block here"
|
|
title="Add block here"
|
|
onMouseEnter={() => setKeepId(nodeId)}
|
|
onMouseLeave={() => { if (!open) setKeepId(null); }}
|
|
onClick={() => { setKeepId(nodeId); setOpen((o) => !o); }}
|
|
>
|
|
<i className="fa fa-plus" aria-hidden />
|
|
</button>
|
|
{open && (
|
|
<div className="empty-container-add-pop" role="menu" style={{ left: pos.x - 120, top: pos.y + 22 }}>
|
|
{blocks.map((b) => (
|
|
<button
|
|
key={b.id}
|
|
type="button"
|
|
role="menuitem"
|
|
className="empty-container-add-item"
|
|
onClick={() => choose(b.id)}
|
|
>
|
|
<i className={`fa ${b.icon}`} aria-hidden />
|
|
{QUICK_LABELS[b.id] ?? b.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|