import { createElement, useEffect, useMemo, useRef, useState } from 'react' import type { Block, Controller } from '../controller' import { AttrEditor } from '../editors/AttrEditor' import { InsertPicker } from '../editors/builtin' import { getIn, parsePath } from '../page/infer' import { editorsFor } from '../registry' import { BoxHandles } from '../style/BoxHandles' import { describeStyle } from '../style/model' import { Icon, TypeIcon } from './Icon' import { useCtrl, useEditorState, useRegistryVersion } from './hooks' const rectOf = (el: HTMLElement) => el.getBoundingClientRect() /** Hover + selection frames drawn over the page, the selected block's toolbar, drop ghost, slots and chips. */ export function Overlay() { const { hovered, selected, mode, blocks } = useEditorState((s) => ({ hovered: s.hovered, selected: s.selected, mode: s.mode, blocks: s.blocks })) useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation useRegistryVersion() useEditorState((s) => s.schemasVersion) useEditorState((s) => s.labelsVersion) const ctrl = useCtrl() const inline = useEditorState((s) => s.inline) const signedIn = useEditorState((s) => s.auth.status === 'signed-in') const drag = useEditorState((s) => s.drag) if (mode !== 'edit' || !signedIn) return null const sel = blocks.filter((b) => b.id === selected) const hov = hovered && hovered !== selected ? blocks.filter((b) => b.id === hovered) : [] const idle = !inline && !drag return (
{hov.map((b, i) => ( ))} {sel.map((b, i) => ( ))} {idle && sel[0] && } {idle && } {idle && } {idle && sel[0] && } {idle && sel[0] && } {drag && } {drag && }
) } function Frame({ el, kind, type, title, group }: { el: HTMLElement; kind: string; type: string; title?: string; group?: string }) { const r = rectOf(el) return (
{kind === 'hover' && ( {title ?? type} {title && {type}} {group && group !== 'Other' && {group}} )}
) } // --- toolbar ---------------------------------------------------------------------------------- function ActionBar({ block }: { block: Block }) { const ctrl = useCtrl() const r = rectOf(block.el) const defs = editorsFor(block) const toolbar = defs.filter((d) => d.placement !== 'menu') const menu = defs.filter((d) => d.placement === 'menu') const [pos, setPos] = useState<{ index: number; count: number } | null>(null) const [open, setOpen] = useState(false) const menuRef = useRef(null) useEffect(() => { let live = true setPos(null) setOpen(false) ctrl .position(block.id) .then((p) => live && setPos(p)) .catch(() => {}) return () => { live = false } }, [block.id, ctrl, block.el]) // Refresh the position after any edit settles (move, undo/redo, drag) so the arrows' disabled state tracks it. const busy = useEditorState((s) => s.busy) const rev = useEditorState((s) => `${s.canUndo}${s.canRedo}`) useEffect(() => { if (busy) return let live = true ctrl .position(block.id) .then((p) => live && setPos((o) => (o && o.index === p.index && o.count === p.count ? o : p))) .catch(() => {}) return () => { live = false } }, [busy, rev, block.id, ctrl]) useEffect(() => { if (!open) return const away = (e: Event) => !e.composedPath().includes(menuRef.current!) && setOpen(false) document.addEventListener('pointerdown', away, true) return () => document.removeEventListener('pointerdown', away, true) }, [open]) const wrapper = ctrl.wrapperType() const canWrap = !!wrapper && !!block.schema && block.type !== wrapper && describeStyle(block.schema).length === 0 const below = r.top < 48 const style = { left: Math.max(4, Math.min(r.left, window.innerWidth - 420)), top: below ? Math.min(r.bottom + 6, window.innerHeight - 44) : r.top - 42 } return (
{ e.preventDefault() ctrl.beginDrag(block.id) }} > {ctrl.labelFor(block.id) ?? block.type} {toolbar.map((d) => ( ))} {canWrap && ( )}
{open && (
{menu.map((d) => ( ))}
)}
) } // --- drop ghost: the "virtual element" that shows where a dragged / new block will land --------- function DropGhost() { const drag = useEditorState((s) => s.drag)! const ctrl = useCtrl() useEditorState((s) => s.tick) // re-measure the slot zone on scroll/resize/mutation const t = drag.target const vertical = !!t && t.line.h > t.line.w // a vertical line = siblings side by side const refused = slotRefusal(ctrl, drag) return ( <> {t?.snaps?.map((r, i) => (
))} {t && ( <>
{drag.source === 'palette' ? `New ${drag.label}` : drag.source === 'existing' ? `Link ${drag.label}` : `Move ${drag.label}`}
)} {refused && (
{refused.why}
)} {drag.pointer && (
{drag.label} {!t ? ' · not here' : refused ? ` · not in ${refused.handle}` : drag.source === 'block' ? (drag.nesting ? ' · nesting (release Alt to sort)' : t.snaps ? ' · sorting · Alt to nest' : '') : ''}
)} ) } // --- dropzone highlights: while dragging, outline every zone the dragged item can land in -------- // Palette blueprints, searched nodes and moving blocks share one engine (`accepts` / // `acceptsInSlot` in the controller); the highlights use the same predicates so what // glows is exactly where a drop commits. Refused slots stay visible but dimmed // (hatched, so the state is not color-only), so it is clear they exist and // just do not take this item. // // Matching runs once per drag: the page cannot restructure mid-drag, so renders // triggered by scroll/resize ticks only re-measure the cached rects. export interface ZoneDefs { containers: Block[] slots: { key: string; parentId: string; handle: string; el: HTMLElement; refused: boolean }[] } export function matchZones( ctrl: Controller, blocks: Block[], drag: { source: string; id: string | null; type?: string; blueprint?: string }, ): ZoneDefs { const type = drag.type // A moving block can never land inside itself: mirror the engine, which // excludes the dragged block and its descendants from every hit test. const excluded = new Set() if (drag.source === 'block' && drag.id) { excluded.add(drag.id) for (let grew = true; grew; ) { grew = false for (const b of blocks) { if (b.parentId && excluded.has(b.parentId) && !excluded.has(b.id)) { excluded.add(b.id) grew = true } } } } // Same predicate the drag engine targets with: plain acceptance, or a slot // fallback for types a container only takes through a named slot. A // blueprint-only drag carries no type and fits every container. const containers = blocks.filter( (b) => b.container && b.el.isConnected && !excluded.has(b.id) && (!type || ctrl.accepts(b.id, type) || ctrl.handleForDrop(b.id, type) !== undefined), ) // Slot zones come from the controller's last scan (no second DOM walk; the // query never pierces the editor's shadow root, so editor UI is excluded). // Blueprints cannot land in slots (the outline refuses them too); a type // fits when the slot's own `to` list takes it. const slots = ctrl.slotZones().flatMap((s, i) => { if (drag.source === 'block' && s.parentId === drag.id) return [] // engine never targets the dragged block's own zones const refused = !type || !ctrl.acceptsInSlot(s.parentId, s.handle, type) return [{ key: `${s.parentId}:${s.handle}:${i}`, parentId: s.parentId, handle: s.handle, el: s.el, refused }] }) return { containers, slots } } /** Slot handles are template keys (`3d-accessories`); show them as words. */ export function slotLabel(handle: string): string { return handle.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim() } function DropZones() { const ctrl = useCtrl() const drag = useEditorState((s) => s.drag)! const blocks = useEditorState((s) => s.blocks) useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation const schemasVersion = useEditorState((s) => s.schemasVersion) // The drag object is rebuilt every pointermove; key on what matching reads. const spec = drag ? `${drag.source}:${drag.id ?? ''}:${drag.type ?? ''}:${drag.blueprint ?? ''}` : '' const zones = useMemo(() => (drag ? matchZones(ctrl, blocks, drag) : null), [ctrl, blocks, schemasVersion, spec]) if (!drag || !zones) return null return ( <> {zones.containers.map((b) => { const r = b.el.getBoundingClientRect() return
})} {zones.slots.map((z) => { const r = z.el.getBoundingClientRect() return (
{slotLabel(z.handle)}
) })} ) } /** Why the tagged slot under the pointer refuses the dragged type (null when it fits or no slot is under the pointer). */ function slotRefusal(ctrl: Controller, drag: { slot: { parentId: string; handle: string } | null; type?: string; blueprint?: string }): { handle: string; why: string; rect: DOMRect } | null { if (!drag.slot || (!drag.type && !drag.blueprint)) return null const { parentId, handle } = drag.slot const zone = [...ctrl.doc.querySelectorAll('[data-cms-slot]')].find( (el) => el.getAttribute('data-cms-slot') === handle && el.getAttribute('data-cms-slot-parent') === parentId, ) if (!zone) return null const rect = zone.getBoundingClientRect() if (drag.blueprint) return { handle, why: `${handle}: blueprints can't land in slots`, rect } if (ctrl.acceptsInSlot(parentId, handle, drag.type!)) return null const takes = ctrl.slotsFor(parentId).find((s) => s.name === handle)?.def.to return { handle, why: `${handle} takes: ${takes?.length ? takes.join(', ') : 'any'}`, rect } } // --- insertion slots: "+" between a container's children (and inside an empty one) ---------------- function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected: string | null; hovered: string | null }) { const ctrl = useCtrl() const containers = [selected, hovered].filter((id, i, a): id is string => !!id && a.indexOf(id) === i).map((id) => blocks.find((b) => b.id === id)).filter((b): b is Block => !!b && b.container) const open = (parentId: string, index: number | undefined, x: number, y: number) => { let key = 0 key = ctrl.openDialog({ id: `insert:${parentId}:${index ?? 'end'}`, title: 'Add block', size: 'sm', anchor: { x, y }, content: createElement(InsertPicker, { parentId, index, ctrl, close: () => ctrl.closeDialog(key) }) }) } return ( <> {containers.map((c) => { const r = rectOf(c.el) const kids = blocks.filter((b) => b.parentId === c.id).map((b) => ({ b, r: rectOf(b.el) })) // Only vertical flows get between-slots; grids/rows would put the "+" in the wrong place. const vertical = kids.every((k, i) => i === 0 || k.r.top >= kids[i - 1].r.bottom - 1) if (!kids.length) { return ( ) } const slots = vertical ? [...kids.map((k, i) => ({ index: i, y: i === 0 ? k.r.top : (kids[i - 1].r.bottom + k.r.top) / 2 })), { index: kids.length, y: kids[kids.length - 1].r.bottom }] : [{ index: kids.length, y: r.bottom }] return slots.map((s) => ( )) })} ) } // --- slot add buttons: "+" on each tagged dropzone of the selected / hovered block ------------------ // The outline already lists every schema slot with an add button; this is the // on-page equivalent so e.g. an empty `3d-accessories` zone on a PDP is // discoverable where the accessories render. function SlotAdds({ selected, hovered }: { selected: string | null; hovered: string | null }) { const ctrl = useCtrl() useEditorState((s) => s.tick) useEditorState((s) => s.schemasVersion) const parents = [selected, hovered].filter((id, i, a): id is string => !!id && a.indexOf(id) === i) if (!parents.length) return null // NB: doc.querySelectorAll does not pierce the editor's shadow root, so // editor UI is never included (unlike block scans, no host filter needed). const zones = [...ctrl.doc.querySelectorAll('[data-cms-slot]')].filter((el) => parents.includes(el.getAttribute('data-cms-slot-parent') ?? ''), ) const open = (parentId: string, handle: string, x: number, y: number) => { let key = 0 key = ctrl.openDialog({ id: `insert:${parentId}:${handle}`, title: `Add to ${handle}`, size: 'sm', anchor: { x, y }, content: createElement(InsertPicker, { parentId, handle, ctrl, close: () => ctrl.closeDialog(key) }) }) } return ( <> {zones.map((el) => { const parentId = el.getAttribute('data-cms-slot-parent')! const handle = el.getAttribute('data-cms-slot')! const r = rectOf(el) return ( ) })} ) } // --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------ const ATTR_ICON: Record = { href: 'link', src: 'image', alt: 'info', title: 'info' } function AttrChips() { const ctrl = useCtrl() const bindings = useEditorState((s) => s.bindings) const selected = useEditorState((s) => s.selected) return ( <> {bindings.map((b) => { if (!b.el.isConnected) return null const r = rectOf(b.el) return ( ) })} ) }