)
}
// --- 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.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 (
)
})}
>
)
}