editor: drop into tagged slot dropzones ([data-cms-slot])

Templates mark visual edge slots with data-cms-slot/-parent. The drag
engine now resolves those zones first (slot-scoped index, acceptsSlot
filter), sorts within the dragged block's own slot, and the overlay
shows a slot add-button so empty zones are discoverable. dnd unit
tests cover slot hit/index/refusal/same-slot sort.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
mats committed 2026-11-07 11:26:52 +01:00
1 parent 33bd5269fd
commit 8e8823421d
5 files changed
+196 -8

No files matched your search

+37
View File
@@ -36,6 +36,7 @@ export function Overlay() {
))}
{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 />}
@@ -246,6 +247,42 @@ function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected:
)
}
// --- 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' }