From 40b33f89a47ec8392c716ce848d06f8853c0ae7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sat, 7 Nov 2026 11:53:47 +0100 Subject: [PATCH] =?UTF-8?q?editor:=20explain=20slot=20refusals=20on-page;?= =?UTF-8?q?=20auto-scroll=20while=20dragging\n\nDrag=20state=20now=20recor?= =?UTF-8?q?ds=20the=20tagged=20slot=20under=20the=20pointer.=20When=20the\?= =?UTF-8?q?ndragged=20type=20does=20not=20fit=20it,=20a=20badge=20on=20the?= =?UTF-8?q?=20zone=20names=20the=20accepted\ntypes=20(from=20the=20slot=20?= =?UTF-8?q?schema)=20and=20the=20pointer=20chip=20turns=20refused=20-\npre?= =?UTF-8?q?viously=20a=20refused=20slot=20silently=20fell=20back=20to=20a?= =?UTF-8?q?=20container=20drop.\nHolding=20the=20pointer=20near=20a=20view?= =?UTF-8?q?port=20edge=20scrolls=20the=20page=20(ramped\nrAF=20loop,=20re-?= =?UTF-8?q?targeting=20after=20each=20step)=20so=20long=20pages=20are=20dr?= =?UTF-8?q?oppable\nend=20to=20end.=20Helpers=20are=20unit-tested;=20harne?= =?UTF-8?q?ss=20covers=20refusal=20badge,\naccept=20path=20and=20the=20scr?= =?UTF-8?q?oll=20step.\n\n=F0=9F=A4=96=20Generated=20with=20Codebuff\nCo-A?= =?UTF-8?q?uthored-By:=20Codebuff=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller.ts | 40 +++++++++++++++++++++++------ src/page/dnd.ts | 12 +++++++++ src/ui/Overlay.tsx | 29 ++++++++++++++++++--- src/ui/styles.css | 1 + test/dnd.test.ts | 23 ++++++++++++++++- test/onpage.test.tsx | 61 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/controller.ts b/src/controller.ts index 765f0b2..9e384d7 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -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) diff --git a/src/page/dnd.ts b/src/page/dnd.ts index a2e2327..a353f53 100644 --- a/src/page/dnd.ts +++ b/src/page/dnd.ts @@ -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) } +} diff --git a/src/ui/Overlay.tsx b/src/ui/Overlay.tsx index 33b82d3..6b74e6c 100644 --- a/src/ui/Overlay.tsx +++ b/src/ui/Overlay.tsx @@ -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() { )} + {refused && ( +
+ {refused.why} +
+ )} {drag.pointer && ( -
+
{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' : '') : ''}
)} ) } +/** 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('[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 }) { diff --git a/src/ui/styles.css b/src/ui/styles.css index c0671d0..f8bfab7 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -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; } diff --git a/test/dnd.test.ts b/test/dnd.test.ts index 46fae5f..1a9e594 100644 --- a/test/dnd.test.ts +++ b/test/dnd.test.ts @@ -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) }, diff --git a/test/onpage.test.tsx b/test/onpage.test.tsx index f51b42b..30afd0d 100644 --- a/test/onpage.test.tsx +++ b/test/onpage.test.tsx @@ -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', '
') + const zone = byId('s1').querySelector('[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', '
') + const zone = byId('s1').querySelector('[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)', () => {