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

+32 -8
View File
@@ -10,7 +10,7 @@ import { Api, ApiError } from './api'
import type { AuthManager, AuthState } from './auth'
import type { CmsEdge, CmsNode, JSONSchema, RequestLog } from './api'
import { blockEditors, editorsFor, inlineEditors } from './registry'
import { dropTarget, moveTarget } from './page/dnd'
import { dropTarget, edgeScroll, moveTarget, slotAt } from './page/dnd'
import type { DndSlot } from './page/dnd'
import { INFERRED_ATTR, collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from './page/infer'
import type { AttrBinding } from './page/infer'
@@ -56,7 +56,7 @@ export interface EditorState {
canRedo: boolean
busy: number
toast: { kind: 'info' | 'error'; text: string; n: number; action?: { label: string; run(): void } } | null
drag: { id: string | null; source: 'block' | 'palette' | 'existing'; type?: string; blueprint?: string; nodeId?: string; label: string; target: DropTarget | null; pointer?: { x: number; y: number }; /** Alt held: nesting into containers instead of sorting among siblings */ nesting?: boolean } | null
drag: { id: string | null; source: 'block' | 'palette' | 'existing'; type?: string; blueprint?: string; nodeId?: string; label: string; target: DropTarget | null; /** Tagged slot zone under the pointer (even when the type does not fit it) */ slot: { parentId: string; handle: string } | null; pointer?: { x: number; y: number }; /** Alt held: nesting into containers instead of sorting among siblings */ nesting?: boolean } | null
/** Attribute-bound properties of the selected block (href, src, alt…), shown as chips on the page. */
bindings: AttrBinding[]
/** Bumped when a schema finishes loading (editors that match on schema re-evaluate). */
@@ -1046,7 +1046,7 @@ export class Controller {
private startDrag(d: { id: string | null; source: 'block' | 'palette' | 'existing'; type?: string; blueprint?: string; nodeId?: string; label: string }, onDrop: (t: DropTarget) => void) {
this.cancelDrag()
const doc = this.doc
this.store.set({ drag: { ...d, target: null } })
this.store.set({ drag: { ...d, target: null, slot: null } })
doc.documentElement.setAttribute('data-cms-dragging', '')
// Rects are measured per move from the live DOM; the ghost is an overlay (not a DOM node), so the
// page never reflows under the pointer and the drop target cannot jitter.
@@ -1061,18 +1061,40 @@ export class Controller {
const slots = this.slotRects()
const acceptsSlot = d.type ? (s: DndSlot) => this.acceptsInSlot(s.parentId, s.handle, d.type!) : undefined
// Existing blocks sort among their siblings by default (snapping); Alt nests into the container under the pointer.
if (d.source === 'block' && d.id)
return moveTarget(blocks, { x, y }, { id: d.id, parentId: this.block(d.id)?.parentId, handle: this.handleOf(d.id) }, { nest, accepts, slots, acceptsSlot })
return dropTarget(blocks, { x, y }, d.id, accepts, slots, acceptsSlot)
const target = d.source === 'block' && d.id
? moveTarget(blocks, { x, y }, { id: d.id, parentId: this.block(d.id)?.parentId, handle: this.handleOf(d.id) }, { nest, accepts, slots, acceptsSlot })
: dropTarget(blocks, { x, y }, d.id, accepts, slots, acceptsSlot)
const under = slotAt(slots, { x, y })
return { target, slot: under ? { parentId: under.parentId, handle: under.handle } : null }
}
let last: { x: number; y: number; alt: boolean } | null = null
const update = () => last && this.store.set({ drag: { ...d, target: compute(last.x, last.y, last.alt), pointer: { x: last.x, y: last.y }, nesting: last.alt } })
const update = () => {
if (!last) return
const { target, slot } = compute(last.x, last.y, last.alt)
this.store.set({ drag: { ...d, target, slot, pointer: { x: last.x, y: last.y }, nesting: last.alt } })
}
// Long pages scroll under a stationary pointer held near the viewport edge; re-target after each step.
let raf = 0
const autoscroll = () => {
raf = 0
const view = doc.defaultView
if (!last || !view) return
const { dx, dy } = edgeScroll(last.x, last.y, view.innerWidth, view.innerHeight)
if (!dx && !dy) return
view.scrollBy(dx, dy)
update()
if (typeof requestAnimationFrame !== 'undefined') raf = requestAnimationFrame(autoscroll)
}
const kick = () => {
if (!raf && typeof requestAnimationFrame !== 'undefined') raf = requestAnimationFrame(autoscroll)
}
const move = (e: PointerEvent) => {
last = { x: e.clientX, y: e.clientY, alt: e.altKey }
update()
kick()
}
const up = (e: PointerEvent) => {
const target = compute(e.clientX, e.clientY, e.altKey)
const { target } = compute(e.clientX, e.clientY, e.altKey)
this.cancelDrag()
if (target) onDrop(target)
}
@@ -1088,6 +1110,8 @@ export class Controller {
doc.addEventListener('keydown', key, true)
doc.addEventListener('keyup', key, true)
this.dragCleanup = () => {
if (raf) cancelAnimationFrame(raf)
raf = 0
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', up, true)
doc.removeEventListener('keydown', key, true)
+12
View File
@@ -209,3 +209,15 @@ function nearestIndex(rects: Rect[], point: { x: number; y: number }): number {
}
return index
}
/** The first tagged slot zone containing the point (DOM order), if any — regardless of whether the dragged type fits it. Used to explain refusals, not just to target. */
export function slotAt(slots: DndSlot[], point: { x: number; y: number }): DndSlot | null {
return slots.find((s) => inside(s.rect, point)) ?? null
}
/** Viewport scroll delta that keeps a drag near the edge moving: zero inside the safe area, ramping to ±max at the very edge. Pure so the ramp is unit-testable. */
export function edgeScroll(x: number, y: number, w: number, h: number, margin = 72, max = 24): { dx: number; dy: number } {
const ramp = (p: number, size: number) =>
p < margin ? -Math.ceil(max * (1 - p / margin)) : p > size - margin ? Math.ceil(max * (1 - (size - p) / margin)) : 0
return { dx: ramp(x, w), dy: ramp(y, h) }
}
+26 -3
View File
@@ -1,5 +1,5 @@
import { createElement, useEffect, useRef, useState } from 'react'
import type { Block } from '../controller'
import type { Block, Controller } from '../controller'
import { AttrEditor } from '../editors/AttrEditor'
import { InsertPicker } from '../editors/builtin'
import { getIn, parsePath } from '../page/infer'
@@ -185,8 +185,11 @@ function ActionBar({ block }: { block: Block }) {
function DropGhost() {
const drag = useEditorState((s) => s.drag)!
const ctrl = useCtrl()
useEditorState((s) => s.tick) // re-measure the slot zone on scroll/resize/mutation
const t = drag.target
const vertical = !!t && t.line.h > t.line.w // a vertical line = siblings side by side
const refused = slotRefusal(ctrl, drag)
return (
<>
{t?.snaps?.map((r, i) => (
@@ -201,16 +204,36 @@ function DropGhost() {
</div>
</>
)}
{refused && (
<div className="cms-slotref" style={{ left: Math.max(4, refused.rect.left), top: Math.max(4, refused.rect.top - 30) }} title={refused.why}>
<TypeIcon type={drag.type ?? drag.label} size={14} /> {refused.why}
</div>
)}
{drag.pointer && (
<div className={`cms-dragchip${t ? '' : ' is-refused'}`} style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 14 }}>
<div className={`cms-dragchip${!t || refused ? ' is-refused' : ''}`} style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 14 }}>
<TypeIcon type={drag.type ?? drag.label} size={14} /> {drag.label}
{!t ? ' · not here' : drag.source === 'block' ? (drag.nesting ? ' · nesting (release Alt to sort)' : t.snaps ? ' · sorting · Alt to nest' : '') : ''}
{!t ? ' · not here' : refused ? ` · not in ${refused.handle}` : drag.source === 'block' ? (drag.nesting ? ' · nesting (release Alt to sort)' : t.snaps ? ' · sorting · Alt to nest' : '') : ''}
</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
const { parentId, handle } = drag.slot
const zone = [...ctrl.doc.querySelectorAll<HTMLElement>('[data-cms-slot]')].find(
(el) => el.getAttribute('data-cms-slot') === handle && el.getAttribute('data-cms-slot-parent') === parentId,
)
if (!zone) return null
const rect = zone.getBoundingClientRect()
if (drag.blueprint) return { handle, why: `${handle}: blueprints can't land in slots`, rect }
if (ctrl.acceptsInSlot(parentId, handle, drag.type!)) return null
const takes = ctrl.slotsFor(parentId).find((s) => s.name === handle)?.def.to
return { handle, why: `${handle} takes: ${takes?.length ? takes.join(', ') : 'any'}`, rect }
}
// --- insertion slots: "+" between a container's children (and inside an empty one) ----------------
function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected: string | null; hovered: string | null }) {
+1
View File
@@ -214,6 +214,7 @@ input.cms-map__key { padding-top: 6px; }
@keyframes cms-pop { from { transform: scale(.97); opacity: 0; } }
.cms-dragchip { position: fixed; display: inline-flex; align-items: center; gap: 6px; background: var(--cms-panel); border: 1px solid #5ee0a0; border-radius: 999px; padding: 3px 10px; font-size: 12px; pointer-events: none; z-index: 4; }
.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; }
/* 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; }
+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)', () => {