DropZones re-ran the accept predicates and a DOM walk on every overlay render (pointermove-driven). Matching now runs once per drag via matchZones(); scroll/resize ticks only re-measure the cached rects (new test pins the predicate count across ticks). Refused slots get a diagonal hatch on top of the red tint so the state is not color-only, slot tags show handles as words (`3d-accessories` -> "3d accessories", raw key kept in title), and the ghost pop honours prefers-reduced-motion. The mock section template now marks its sidebar slot like a real template, so slot tests use the fixture instead of hand-injected DOM (the old palette test asserting "no tagged slots" now expects both sections' slots). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
433 lines
20 KiB
TypeScript
433 lines
20 KiB
TypeScript
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 (
|
|
<div className="cms-overlay">
|
|
{hov.map((b, i) => (
|
|
<Frame key={`h${b.id}-${i}`} el={b.el} kind="hover" type={b.type} title={ctrl.labelFor(b.id)} group={ctrl.groupOfType(b.type)} />
|
|
))}
|
|
{sel.map((b, i) => (
|
|
<Frame key={`s${b.id}-${i}`} el={b.el} kind={inline ? 'editing' : 'selected'} type={b.type} />
|
|
))}
|
|
{idle && sel[0] && <BoxHandles block={sel[0]} />}
|
|
{idle && <InsertSlots blocks={blocks} selected={selected} hovered={hovered} />}
|
|
{idle && <SlotAdds selected={selected} hovered={hovered} />}
|
|
{idle && sel[0] && <AttrChips />}
|
|
{idle && sel[0] && <ActionBar block={sel[0]} />}
|
|
{drag && <DropGhost />}
|
|
{drag && <DropZones />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Frame({ el, kind, type, title, group }: { el: HTMLElement; kind: string; type: string; title?: string; group?: string }) {
|
|
const r = rectOf(el)
|
|
return (
|
|
<div className={`cms-frame cms-frame--${kind}`} style={{ left: r.left, top: r.top, width: r.width, height: r.height }}>
|
|
{kind === 'hover' && (
|
|
<span className="cms-frame__tag">
|
|
<TypeIcon type={type} size={12} /> {title ?? type}
|
|
{title && <i> {type}</i>}
|
|
{group && group !== 'Other' && <em>{group}</em>}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- 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<HTMLDivElement>(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 (
|
|
<div className="cms-bar" style={style} role="toolbar" aria-label={`${block.type} actions`}>
|
|
<span
|
|
className="cms-bar__type cms-bar__grip"
|
|
title="Drag to move"
|
|
aria-label="Drag to move"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
ctrl.beginDrag(block.id)
|
|
}}
|
|
>
|
|
<Icon name="grip" size={16} />
|
|
<TypeIcon type={block.type} size={15} />
|
|
<span className="cms-bar__name" title={`${block.type} · ${ctrl.groupOfType(block.type)}`}>
|
|
{ctrl.labelFor(block.id) ?? block.type}
|
|
</span>
|
|
</span>
|
|
{toolbar.map((d) => (
|
|
<button key={d.id} className="cms-ico" title={d.label} aria-label={d.label} onClick={() => void ctrl.openEditor(d, block.id)}>
|
|
<Icon name={d.icon ?? 'edit'} />
|
|
</button>
|
|
))}
|
|
{canWrap && (
|
|
<button className="cms-ico" title={`Add spacing & layout (wrap in ${wrapper})`} aria-label="Wrap in layout" onClick={() => void ctrl.wrapInLayout(block.id)}>
|
|
<Icon name="wrap" />
|
|
</button>
|
|
)}
|
|
<span className="cms-bar__sep" />
|
|
<button className="cms-ico" title="Move up" aria-label="Move up" disabled={!pos || pos.index <= 0} onClick={() => void ctrl.nudge(block.id, -1)}>
|
|
<Icon name="up" />
|
|
</button>
|
|
<button className="cms-ico" title="Move down" aria-label="Move down" disabled={!pos || pos.index >= pos.count - 1} onClick={() => void ctrl.nudge(block.id, 1)}>
|
|
<Icon name="down" />
|
|
</button>
|
|
<button className="cms-ico" title="Duplicate" aria-label="Duplicate" onClick={() => void ctrl.duplicate(block.id)}>
|
|
<Icon name="duplicate" />
|
|
</button>
|
|
<button className="cms-ico cms-ico--danger" title="Delete" aria-label="Delete" onClick={() => void ctrl.remove(block.id)}>
|
|
<Icon name="delete" />
|
|
</button>
|
|
<div className="cms-menuwrap" ref={menuRef}>
|
|
<button className={`cms-ico${open ? ' is-on' : ''}`} title="More" aria-label="More actions" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
|
|
<Icon name="more" />
|
|
</button>
|
|
{open && (
|
|
<div className="cms-menu" role="menu">
|
|
{menu.map((d) => (
|
|
<button
|
|
key={d.id}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
void ctrl.openEditor(d, block.id)
|
|
}}
|
|
>
|
|
<Icon name={d.icon ?? 'edit'} size={16} /> {d.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
void navigator.clipboard?.writeText(block.id).then(() => ctrl.toast('info', 'Node id copied'))
|
|
}}
|
|
>
|
|
<Icon name="duplicate" size={16} /> Copy node id
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- 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) => (
|
|
<div key={i} className={`cms-snap${r === t.line ? ' is-active' : ''}`} style={{ left: r.x, top: r.y, width: r.w, height: r.h }} />
|
|
))}
|
|
{t && (
|
|
<>
|
|
<div className="cms-dropline" style={{ left: t.line.x, top: t.line.y, width: t.line.w, height: t.line.h }} />
|
|
<div className="cms-ghost" style={vertical ? { left: t.line.x + 10, top: t.line.y + Math.min(t.line.h / 2, 120) - 17, width: 130 } : { left: t.line.x, top: t.line.y + 6, width: Math.max(t.line.w, 120) }}>
|
|
<TypeIcon type={drag.type ?? drag.label} size={16} />
|
|
<span>{drag.source === 'palette' ? `New ${drag.label}` : drag.source === 'existing' ? `Link ${drag.label}` : `Move ${drag.label}`}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
{refused && (
|
|
<div className="cms-slotref" style={{ left: Math.max(4, refused.rect.left), top: Math.max(4, refused.rect.top - 30) }} title={refused.why}>
|
|
<TypeIcon type={drag.type ?? drag.label} size={14} /> {refused.why}
|
|
</div>
|
|
)}
|
|
{drag.pointer && (
|
|
<div className={`cms-dragchip${!t || refused ? ' is-refused' : ''}`} style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 14 }}>
|
|
<TypeIcon type={drag.type ?? drag.label} size={14} /> {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' : '') : ''}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// --- 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<string>()
|
|
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 <div key={b.id} className="cms-dropzone" data-dropzone={b.id} style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
|
|
})}
|
|
{zones.slots.map((z) => {
|
|
const r = z.el.getBoundingClientRect()
|
|
return (
|
|
<div key={z.key} className={`cms-dropzone cms-dropzone--slot${z.refused ? ' is-refused' : ''}`} data-dropzone={`slot:${z.parentId}:${z.handle}`} title={z.handle} style={{ left: r.left, top: r.top, width: r.width, height: r.height }}>
|
|
<span className="cms-dropzone__tag">{slotLabel(z.handle)}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** 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<HTMLElement>('[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 (
|
|
<button key={c.id} className="cms-slot cms-slot--empty" style={{ left: r.left + r.width / 2, top: r.top + Math.min(r.height / 2, 40) }} aria-label="Add block inside" onClick={() => open(c.id, undefined, r.left + r.width / 2, r.top)}>
|
|
<Icon name="add" size={16} /> Add block
|
|
</button>
|
|
)
|
|
}
|
|
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) => (
|
|
<button key={`${c.id}-${s.index}`} className="cms-slot" style={{ left: r.left + r.width / 2, top: s.y }} aria-label={`Add block at position ${s.index + 1}`} title="Add block here" onClick={() => open(c.id, s.index, r.left + r.width / 2, s.y)}>
|
|
<Icon name="add" size={14} />
|
|
</button>
|
|
))
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// --- 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<HTMLElement>('[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 (
|
|
<button key={`${parentId}:${handle}`} className="cms-slot" style={{ left: Math.max(4, r.right - 14), top: Math.max(4, r.top + 2) }} aria-label={`Add to ${handle}`} title={`Add to ${handle}`} onClick={() => open(parentId, handle, r.right, r.top)}>
|
|
<Icon name="add" size={14} />
|
|
</button>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------
|
|
|
|
const ATTR_ICON: Record<string, string> = { 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 (
|
|
<button
|
|
key={`${b.path}:${b.attr}`}
|
|
className="cms-attrchip"
|
|
style={{ left: Math.max(4, r.right - 6), top: Math.max(4, r.top - 6) }}
|
|
title={`${b.attr}: ${b.value}`}
|
|
aria-label={`Edit ${b.attr}`}
|
|
onClick={async () => {
|
|
if (!selected) return
|
|
const node = await ctrl.node(selected, true)
|
|
const initial = String(getIn(node.data, parsePath(b.path)) ?? b.value)
|
|
let key = 0
|
|
const rr = b.el.getBoundingClientRect()
|
|
key = ctrl.openDialog({ id: `attr:${selected}:${b.path}`, title: `${b.attr} · ${b.path}`, size: 'sm', anchor: { x: rr.right, y: rr.top }, content: createElement(AttrEditor, { id: selected, path: b.path, attr: b.attr, initial, ctrl, close: () => ctrl.closeDialog(key) }) })
|
|
}}
|
|
>
|
|
<Icon name={ATTR_ICON[b.attr] ?? 'edit'} size={13} />
|
|
</button>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|