editor: explain slot refusals on-page; auto-scroll while dragging\n\nDrag state now records the tagged slot under the pointer. When the\ndragged type does not fit it, a badge on the zone names the accepted\ntypes (from the slot schema) and the pointer chip turns refused -\npreviously a refused slot silently fell back to a container drop.\nHolding the pointer near a viewport edge scrolls the page (ramped\nrAF loop, re-targeting after each step) so long pages are droppable\nend to end. Helpers are unit-tested; harness covers refusal badge,\naccept path and the scroll step.\n\n🤖 Generated with Codebuff\nCo-Authored-By: Codebuff <noreply@codebuff.com>

This commit is contained in:
mats committed 2026-11-07 11:53:47 +01:00
1 parent 8e8823421d
commit 40b33f89a4
6 files changed
+154 -12

No files matched your search

+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { dropTarget, moveTarget } from '../src/page/dnd'
import { dropTarget, edgeScroll, moveTarget, slotAt } from '../src/page/dnd'
import type { DndBlock, DndSlot } from '../src/page/dnd'
const r = (y: number, h = 100, x = 0, w = 600) => ({ x, y, w, h })
@@ -63,6 +63,27 @@ describe('dropTarget with tagged slots ([data-cms-slot])', () => {
it('without an acceptsSlot filter the slot still wins over the container', () => {
expect(dropTarget(pdp, { x: 50, y: 200 }, 'C', undefined, slots)).toMatchObject({ parentId: 'p1', handle: '3d-accessories' })
})
it('slotAt reports the zone under the pointer regardless of fit', () => {
const zones: DndSlot[] = [
{ handle: 'sidebar', parentId: 's1', rect: r(190, 150, 0, 600), kids: [] },
{ handle: 'hero', parentId: 's1', rect: r(10, 100, 0, 600), kids: [] },
]
expect(slotAt(zones, { x: 50, y: 200 })?.handle).toBe('sidebar')
expect(slotAt(zones, { x: 50, y: 50 })?.handle).toBe('hero')
expect(slotAt(zones, { x: 50, y: 500 })).toBeNull()
})
it('edgeScroll stays put inside the safe area and ramps to max at the edge', () => {
expect(edgeScroll(400, 300, 800, 600)).toEqual({ dx: 0, dy: 0 })
expect(edgeScroll(0, 300, 800, 600).dx).toBe(-24)
expect(edgeScroll(800, 300, 800, 600).dx).toBe(24)
expect(edgeScroll(400, 0, 800, 600).dy).toBe(-24)
expect(edgeScroll(400, 600, 800, 600).dy).toBe(24)
const half = edgeScroll(36, 300, 800, 600)
expect(half.dx).toBeLessThan(0)
expect(half.dx).toBeGreaterThan(-24)
})
it('moveTarget sorts within the dragged block\'s own slot', () => {
const two: DndBlock[] = [
{ id: 'p1', container: true, depth: 0, rect: r(0, 400) },
+61
View File
@@ -199,6 +199,67 @@ describe('block palette and drag & drop', () => {
await h.settle()
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
})
it('a slot that refuses the type names what it takes (drop still falls back to the container)', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
// The section schema declares a `sidebar` slot taking text/button/footer; the template marks it.
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
h.editor.ctrl.scan()
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
await ptr('pointermove', 80, 200)
expect(h.editor.ctrl.store.get().drag).toMatchObject({ slot: { parentId: 's1', handle: 'sidebar' }, target: { parentId: 's1' } })
expect(h.shadow.querySelector('.cms-slotref')?.textContent).toContain('sidebar takes: text, button, footer')
expect(h.shadow.querySelector('.cms-dragchip')?.className).toContain('is-refused')
await ptr('pointerup', 80, 200)
await h.settle()
expect(h.editor.ctrl.store.get().drag).toBeNull()
})
it('a slot that accepts the type shows no refusal', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
h.editor.ctrl.scan()
h.editor.ctrl.beginPaletteDrag({ type: 'button', label: 'button' })
await ptr('pointermove', 80, 200)
expect(h.editor.ctrl.store.get().drag).toMatchObject({
slot: { parentId: 's1', handle: 'sidebar' },
target: { parentId: 's1', handle: 'sidebar' },
})
expect(h.shadow.querySelector('.cms-slotref')).toBeNull()
await ptr('pointerup', 80, 200)
await h.settle()
})
it('holding the pointer at the viewport edge scrolls the page while dragging', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
h.editor.ctrl.scan()
const scrolled: Array<[number, number]> = []
const realScrollBy = window.scrollBy
window.scrollBy = ((dx: number, dy: number) => void scrolled.push([dx, dy])) as typeof window.scrollBy
try {
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
await ptr('pointermove', 400, window.innerHeight - 4)
await act(async () => void (await new Promise((r) => setTimeout(r, 40)))) // let the rAF autoscroll step run
expect(scrolled.length).toBeGreaterThan(0)
expect(scrolled[0]).toEqual([0, expect.any(Number)])
expect(scrolled[0][1]).toBeGreaterThan(0)
await ptr('pointerup', 400, window.innerHeight - 4)
await h.settle()
} finally {
window.scrollBy = realScrollBy
}
expect(h.editor.ctrl.store.get().drag).toBeNull()
})
})
describe('insertion slots (virtual "+" elements)', () => {