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:
1 parent
5c5c34d25a
commit
a798bc1ad3
3 files changed
+154
No files matched your search
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
||||
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', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user