Files
cms-onpageeditor/src/ui/Overlay.tsx
T
mats 7362e4730f Cheaper, clearer dropzones: memoize, hatch refused, label slots as words
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>
2026-11-09 09:35:16 +01:00

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