diff --git a/src/ui/Overlay.tsx b/src/ui/Overlay.tsx
index 6b74e6c..dcb9a11 100644
--- a/src/ui/Overlay.tsx
+++ b/src/ui/Overlay.tsx
@@ -40,6 +40,7 @@ export function Overlay() {
{idle && sel[0] && }
{idle && sel[0] && }
{drag && }
+ {drag && }
)
}
@@ -219,6 +220,70 @@ function DropGhost() {
)
}
+// --- 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,
+// so it is clear they exist and just do not take this item.
+
+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
+ useEditorState((s) => s.schemasVersion)
+ if (!drag) return null
+ 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),
+ )
+ // NB: doc.querySelectorAll does not pierce the editor's shadow root, so
+ // editor UI is never included (same as SlotAdds below).
+ const slots = [...ctrl.doc.querySelectorAll('[data-cms-slot]')].flatMap((el, i) => {
+ const handle = el.getAttribute('data-cms-slot')
+ if (!handle || !el.isConnected) return []
+ const parentId = el.getAttribute('data-cms-slot-parent') ?? el.closest('[data-cms]')?.getAttribute('data-cms') ?? ''
+ if (!parentId) return []
+ if (drag.source === 'block' && parentId === drag.id) return [] // engine never targets the dragged block's own zones
+ const r = el.getBoundingClientRect()
+ // Blueprints cannot land in slots (the outline refuses them too); a type
+ // fits when the slot's own `to` list takes it.
+ const refused = !type || !ctrl.acceptsInSlot(parentId, handle, type)
+ return [{ key: `${parentId}:${handle}:${i}`, parentId, handle, rect: r, refused }]
+ })
+ return (
+ <>
+ {containers.map((b) => {
+ const r = b.el.getBoundingClientRect()
+ return
+ })}
+ {slots.map((z) => (
+
+ {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
diff --git a/src/ui/styles.css b/src/ui/styles.css
index 3ad5929..f26af25 100644
--- a/src/ui/styles.css
+++ b/src/ui/styles.css
@@ -215,6 +215,14 @@ input.cms-map__key { padding-top: 6px; }
.cms-dragchip.is-refused { border-color: #a33a4a; color: #ff8a8a; }
.cms-slotref { position: fixed; display: inline-flex; align-items: center; gap: 6px; background: var(--cms-panel); border: 1px solid #a33a4a; color: #ff8a8a; border-radius: 999px; padding: 3px 10px; font-size: 12px; font-weight: 600; pointer-events: none; z-index: 4; max-width: 60vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+/* dropzone highlights: while dragging, every zone the dragged item can land in */
+.cms-dropzone { position: fixed; pointer-events: none; z-index: 1; border: 1.5px dashed rgba(94,224,160,.7); border-radius: 6px; background: rgba(94,224,160,.05); }
+.cms-dropzone--slot { border-color: rgba(106,167,255,.75); background: rgba(106,167,255,.07); }
+.cms-dropzone--slot.is-refused { border-color: rgba(163,58,74,.55); background: rgba(163,58,74,.04); }
+.cms-dropzone__tag { position: absolute; top: -9px; left: 8px; padding: 0 6px; font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; background: #1c2b25; color: #8fe3b8; border: 1px solid rgba(94,224,160,.7); border-radius: 999px; }
+.cms-dropzone--slot .cms-dropzone__tag { background: #232c4a; color: #aebdff; border-color: rgba(106,167,255,.75); }
+.cms-dropzone--slot.is-refused .cms-dropzone__tag { background: #33222a; color: #e89aa6; border-color: rgba(163,58,74,.55); }
+
/* insertion slots */
.cms-slot { position: fixed; pointer-events: auto; transform: translate(-50%, -50%); display: inline-flex; align-items: center; gap: 4px; min-width: 22px; height: 22px; padding: 0 3px; border-radius: 999px; border: 1px solid #4a5a9a; background: #2b3350; color: #dfe5ff; opacity: .0; transition: opacity .12s, transform .12s; z-index: 1; }
.cms-overlay:hover .cms-slot, .cms-slot:focus-visible, .cms-slot--empty { opacity: .9; }
diff --git a/test/onpage.test.tsx b/test/onpage.test.tsx
index 30afd0d..e6c3dca 100644
--- a/test/onpage.test.tsx
+++ b/test/onpage.test.tsx
@@ -399,3 +399,84 @@ describe('sorting root nodes (snap between siblings)', () => {
expect(kids('l1')).toEqual(['x2', 'x1'])
})
})
+
+describe('dropzone highlights', () => {
+ const zones = () => qa('.cms-dropzone:not(.cms-dropzone--slot)').map((el) => el.getAttribute('data-dropzone'))
+ const slots = () => qa('.cms-dropzone--slot')
+ const stop = async () => {
+ await act(async () => void h.editor.ctrl.cancelDrag())
+ await h.settle()
+ }
+
+ it('a palette drag outlines every container the type fits, and clears on drop/cancel', async () => {
+ h = await setup()
+ await h.settle()
+ expect(qa('.cms-dropzone').length).toBe(0) // idle: no highlights
+ h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'Text' })
+ await h.settle()
+ const ids = zones()
+ expect(ids).toContain('s1') // section takes text as a plain child
+ expect(ids).toContain('s2')
+ expect(ids).toContain('l1') // layout takes text too
+ expect(ids).not.toContain('t1') // leaves are never dropzones
+ expect(slots().length).toBe(0) // the fixture page has no tagged slots
+ await stop()
+ expect(qa('.cms-dropzone').length).toBe(0)
+ })
+
+ it('a type that only fits through a slot still lights up the container', async () => {
+ h = await setup()
+ await h.settle()
+ expect(h.editor.ctrl.accepts('s1', 'footer')).toBe(false) // not a plain child ...
+ expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar') // ... but fits the slot
+ h.editor.ctrl.beginPaletteDrag({ type: 'footer', label: 'Footer' })
+ await h.settle()
+ expect(zones()).toContain('s1') // the drop lands in its slot, so it glows
+ await stop()
+ })
+
+ it('a blueprint drag lights up containers and marks every slot refused', async () => {
+ h = await setup()
+ await h.settle()
+ byId('s1').insertAdjacentHTML('beforeend', '')
+ h.editor.ctrl.scan()
+ h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' })
+ await h.settle()
+ expect(zones()).toContain('s1') // typeless: every container fits
+ const s = slots()
+ expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar'])
+ expect(s[0].className).toContain('is-refused') // blueprints cannot land in slots
+ expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar')
+ await stop()
+ })
+
+ it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => {
+ h = await setup()
+ await h.settle()
+ byId('s1').insertAdjacentHTML('beforeend', '')
+ h.editor.ctrl.scan()
+ h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
+ await h.settle()
+ expect(slots().length).toBe(1)
+ expect(slots()[0].className).not.toContain('is-refused') // sidebar takes text
+ await stop()
+ h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' })
+ await h.settle()
+ expect(slots().length).toBe(1)
+ expect(slots()[0].className).toContain('is-refused') // sidebar takes text/button/footer
+ expect(zones()).toContain('s1') // ... while the section itself still takes it
+ await stop()
+ })
+
+ it('moving a block excludes itself (and never highlights leaves)', async () => {
+ h = await setup()
+ await h.settle()
+ h.editor.ctrl.beginDrag('s1')
+ await h.settle()
+ const ids = zones()
+ expect(ids).not.toContain('s1') // cannot drop into itself
+ expect(ids).toEqual(expect.arrayContaining(['s2', 'l1'])) // sections nest, so siblings still take it
+ expect(ids).not.toContain('h1') // leaves are never dropzones
+ await stop()
+ })
+})