Highlight every dropzone on the page while dragging

Palette blueprints, searched nodes and moving blocks shared one engine
but only the single current target line was visible, so there was no way
to see where a dragged item could land. While a drag is active the
overlay now outlines every valid dropzone with the engine's own
predicates (accepts / acceptsInSlot, blueprint refusal, self-exclusion):
containers in green, fitting slots in blue with a name tag, refused slots
dimmed red with a name tag so they read as "exists but not for this".

Tests: palette fit + slot-fallback, blueprint container/refused-slot,
node fit/refuse on the same slot, move self-exclusion; typecheck clean.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
mats committed 2026-11-09 09:19:59 +01:00
1 parent 5c5c34d25a
commit a798bc1ad3
3 files changed
+154

No files matched your search

+65
View File
@@ -40,6 +40,7 @@ export function Overlay() {
{idle && sel[0] && <AttrChips />}
{idle && sel[0] && <ActionBar block={sel[0]} />}
{drag && <DropGhost />}
{drag && <DropZones />}
</div>
)
}
@@ -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<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),
)
// 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<HTMLElement>('[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 <div key={b.id} className="cms-dropzone" data-dropzone={b.id} style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
})}
{slots.map((z) => (
<div key={z.key} className={`cms-dropzone cms-dropzone--slot${z.refused ? ' is-refused' : ''}`} data-dropzone={`slot:${z.parentId}:${z.handle}`} style={{ left: z.rect.left, top: z.rect.top, width: z.rect.width, height: z.rect.height }}>
<span className="cms-dropzone__tag">{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