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

+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 }) {