editor: drop into tagged slot dropzones ([data-cms-slot])
Templates mark visual edge slots with data-cms-slot/-parent. The drag engine now resolves those zones first (slot-scoped index, acceptsSlot filter), sorts within the dragged block's own slot, and the overlay shows a slot add-button so empty zones are discoverable. dnd unit tests cover slot hit/index/refusal/same-slot sort. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
33bd5269fd
commit
8e8823421d
5 files changed
+196
-8
No files matched your search
+37
-3
@@ -11,6 +11,7 @@ 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 type { DndSlot } from './page/dnd'
|
||||
import { INFERRED_ATTR, collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from './page/infer'
|
||||
import type { AttrBinding } from './page/infer'
|
||||
import { OTHER_GROUP, acceptsChild, acceptsHandle, connectionsOf, groupOf, labelKey, typeName } from './schema'
|
||||
@@ -88,6 +89,8 @@ export interface ControllerOptions {
|
||||
}
|
||||
|
||||
const BLOCK_SEL = '[data-cms]'
|
||||
/** Tagged slot dropzones: `<div data-cms-slot="<handle>" data-cms-slot-parent="<nodeId>">` around a `Slot` output. */
|
||||
const SLOT_SEL = '[data-cms-slot]'
|
||||
|
||||
export class Controller {
|
||||
readonly store: Store<EditorState>
|
||||
@@ -185,7 +188,7 @@ export class Controller {
|
||||
if (records.every((r) => this.host.contains(r.target))) return
|
||||
this.scheduleScan()
|
||||
})
|
||||
mo.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-cms', 'data-cms-type', 'data-cms-container'] })
|
||||
mo.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-cms', 'data-cms-type', 'data-cms-container', 'data-cms-slot', 'data-cms-slot-parent'] })
|
||||
this.cleanup.push(() => mo.disconnect())
|
||||
// Edit mode: stop links/forms from navigating away mid-edit.
|
||||
doc.documentElement.setAttribute('data-cms-editing', this.store.get().mode)
|
||||
@@ -233,12 +236,40 @@ export class Controller {
|
||||
}
|
||||
})
|
||||
this.prefetchSchemas(blocks.map((b) => b.type))
|
||||
this.scanSlots(blocks)
|
||||
const prev = this.store.get().blocks
|
||||
const same = prev.length === blocks.length && prev.every((b, i) => b.el === blocks[i].el && b.type === blocks[i].type && b.container === blocks[i].container)
|
||||
this.store.set((s) => ({ blocks: same ? s.blocks : blocks, tick: s.tick + 1 }))
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Tagged slot dropzones on the page (not blocks): handle + parent + member ids in DOM order. */
|
||||
private slots: Array<Omit<DndSlot, 'rect'> & { el: HTMLElement }> = []
|
||||
|
||||
private scanSlots(blocks: Block[]) {
|
||||
const byEl = new Map<HTMLElement, Block>(blocks.map((b) => [b.el, b]))
|
||||
const els = Array.from(this.doc.querySelectorAll<HTMLElement>(SLOT_SEL)).filter((el) => !this.host.contains(el))
|
||||
this.slots = els.flatMap((el) => {
|
||||
const handle = el.getAttribute('data-cms-slot')
|
||||
if (!handle) return []
|
||||
const parentId = el.getAttribute('data-cms-slot-parent') ?? el.closest<HTMLElement>(BLOCK_SEL)?.getAttribute('data-cms') ?? undefined
|
||||
if (!parentId) return []
|
||||
const kids = Array.from(el.querySelectorAll<HTMLElement>(BLOCK_SEL))
|
||||
.filter((child) => !this.host.contains(child) && byEl.has(child))
|
||||
.map((child) => byEl.get(child)!.id)
|
||||
return [{ handle, parentId, el, kids }]
|
||||
})
|
||||
}
|
||||
|
||||
/** Live slot rects for the drag engine (measured per move like blocks). */
|
||||
private slotRects(): DndSlot[] {
|
||||
return this.slots.flatMap((s) => {
|
||||
if (!s.el.isConnected) return []
|
||||
const r = s.el.getBoundingClientRect()
|
||||
return [{ handle: s.handle, parentId: s.parentId, rect: { x: r.x, y: r.y, w: r.width, h: r.height }, kids: s.kids }]
|
||||
})
|
||||
}
|
||||
|
||||
block(id: string | null | undefined): Block | undefined {
|
||||
return id ? this.store.get().blocks.find((b) => b.id === id) : undefined
|
||||
}
|
||||
@@ -1027,9 +1058,12 @@ export class Controller {
|
||||
const accepts = d.type
|
||||
? (parent: string) => this.accepts(parent, d.type!) || this.handleForDrop(parent, d.type!) !== undefined
|
||||
: undefined
|
||||
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 }, { nest, accepts })
|
||||
return dropTarget(blocks, { x, y }, d.id, accepts)
|
||||
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)
|
||||
}
|
||||
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 } })
|
||||
|
||||
@@ -8,3 +8,9 @@ html[data-cms-dragging], html[data-cms-dragging] * { user-select: none !importan
|
||||
/* every editable text field: a hint on hover (inferred ones only exist on the selected block) */
|
||||
html[data-cms-editing="edit"] [data-cms-field][data-cms-editor]:not([contenteditable]):hover { outline: 1px dashed rgba(94,224,160,.9); outline-offset: 3px; }
|
||||
html[data-cms-editing="edit"] [data-cms-inferred]:not([contenteditable]) { outline: 1px dotted rgba(94,224,160,.45); outline-offset: 3px; }
|
||||
|
||||
/* tagged slot dropzones ([data-cms-slot]): an empty slot still needs a hit
|
||||
area, and every slot gets a hover outline so the drop target is visible */
|
||||
html[data-cms-editing="edit"] [data-cms-slot-empty="true"] { min-height: 44px; }
|
||||
html[data-cms-editing="edit"] [data-cms-slot]:hover { outline: 1px dashed rgba(159,178,255,.8); outline-offset: 4px; }
|
||||
html[data-cms-editing="edit"] [data-cms-slot-empty="true"] { outline: 1px dashed rgba(159,178,255,.55); outline-offset: 4px; }
|
||||
+73
-3
@@ -16,6 +16,19 @@ export interface DndBlock {
|
||||
depth: number
|
||||
rect: Rect
|
||||
}
|
||||
/**
|
||||
* A tagged slot dropzone in the page: an element carrying
|
||||
* `data-cms-slot="<handle>" data-cms-slot-parent="<nodeId>"`, emitted by
|
||||
* node templates around their `{{.Slot "<handle>"}}` output (e.g. the PDP's
|
||||
* `3d-accessories` zone). `kids` are the member block ids in DOM order.
|
||||
*/
|
||||
export interface DndSlot {
|
||||
handle: string
|
||||
parentId: string
|
||||
rect: Rect
|
||||
kids: string[]
|
||||
}
|
||||
|
||||
export interface DropTarget {
|
||||
/** Container id, or '' for the page root. */
|
||||
parentId: string
|
||||
@@ -39,7 +52,42 @@ function descendantOf(blocks: DndBlock[], id: string, ancestor: string): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
export function dropTarget(blocks: DndBlock[], point: { x: number; y: number }, dragId: string | null, accepts?: (parentId: string) => boolean): DropTarget | null {
|
||||
export function dropTarget(
|
||||
blocks: DndBlock[],
|
||||
point: { x: number; y: number },
|
||||
dragId: string | null,
|
||||
accepts?: (parentId: string) => boolean,
|
||||
slots: DndSlot[] = [],
|
||||
acceptsSlot?: (slot: DndSlot) => boolean,
|
||||
): DropTarget | null {
|
||||
// A tagged slot zone wins over its container: drops inside
|
||||
// `[data-cms-slot]` land on the handle, not as plain children. Smallest
|
||||
// zone first so nested slots resolve innermost.
|
||||
const byId = new Map(blocks.map((b) => [b.id, b]))
|
||||
const slotHit = slots
|
||||
.filter((s) => inside(s.rect, point) && s.parentId !== dragId && (!acceptsSlot || acceptsSlot(s)))
|
||||
.sort((a, b) => a.rect.w * a.rect.h - b.rect.w * b.rect.h)[0]
|
||||
if (slotHit) {
|
||||
const kids = slotHit.kids.filter((id) => id !== dragId).map((id) => byId.get(id)).filter((b): b is DndBlock => !!b)
|
||||
let index = kids.length
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
if (point.y < kids[i].rect.y + kids[i].rect.h / 2) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let line: Rect
|
||||
if (kids.length === 0) {
|
||||
line = { x: slotHit.rect.x + 8, y: slotHit.rect.y + 8, w: slotHit.rect.w - 16, h: 3 }
|
||||
} else if (index < kids.length) {
|
||||
const r = kids[index].rect
|
||||
line = { x: r.x, y: r.y - 2, w: r.w, h: 3 }
|
||||
} else {
|
||||
const r = kids[kids.length - 1].rect
|
||||
line = { x: r.x, y: r.y + r.h - 1, w: r.w, h: 3 }
|
||||
}
|
||||
return { parentId: slotHit.parentId, handle: slotHit.handle, index, line }
|
||||
}
|
||||
// Deepest container under the pointer that is not the dragged block or inside it.
|
||||
const hit = blocks
|
||||
.filter((b) => b.container && inside(b.rect, point) && !(dragId && descendantOf(blocks, b.id, dragId)) && (!accepts || accepts(b.id)))
|
||||
@@ -127,8 +175,18 @@ export function rootExtent(blocks: DndBlock[], margin = 24): Rect | null {
|
||||
* between them); holding Alt - or leaving the parent's area - switches to the general "drop into whatever
|
||||
* container is under the pointer" behaviour.
|
||||
*/
|
||||
export function moveTarget(blocks: DndBlock[], point: { x: number; y: number }, drag: { id: string; parentId?: string }, opts: { nest?: boolean; accepts?: (parentId: string) => boolean } = {}): DropTarget | null {
|
||||
export function moveTarget(blocks: DndBlock[], point: { x: number; y: number }, drag: { id: string; parentId?: string; handle?: string }, opts: { nest?: boolean; accepts?: (parentId: string) => boolean; slots?: DndSlot[]; acceptsSlot?: (slot: DndSlot) => boolean } = {}): DropTarget | null {
|
||||
const parentId = drag.parentId ?? ''
|
||||
const slots = opts.slots ?? []
|
||||
// Sorting inside the dragged block's own slot: snap among the slot's
|
||||
// members while the pointer stays in the zone (Alt still forces nesting).
|
||||
if (!opts.nest && drag.handle) {
|
||||
const own = slots.find((s) => s.parentId === parentId && s.handle === drag.handle && inside(s.rect, point))
|
||||
if (own) {
|
||||
const rects = own.kids.filter((id) => id !== drag.id).map((id) => blocks.find((b) => b.id === id)?.rect).filter((r): r is Rect => !!r)
|
||||
if (rects.length) return { parentId, handle: drag.handle, index: nearestIndex(rects, point), line: { x: 0, y: point.y, w: 0, h: 3 }, snaps: [] }
|
||||
}
|
||||
}
|
||||
if (!opts.nest) {
|
||||
const area = parentId ? blocks.find((b) => b.id === parentId)?.rect : rootExtent(blocks)
|
||||
const pad = parentId ? 12 : 0
|
||||
@@ -137,5 +195,17 @@ export function moveTarget(blocks: DndBlock[], point: { x: number; y: number },
|
||||
if (t) return t
|
||||
}
|
||||
}
|
||||
return dropTarget(blocks, point, drag.id, opts.accepts)
|
||||
return dropTarget(blocks, point, drag.id, opts.accepts, slots, opts.acceptsSlot)
|
||||
}
|
||||
|
||||
/** Index among sibling rects by flow midpoint (shared by slot sorting). */
|
||||
function nearestIndex(rects: Rect[], point: { x: number; y: number }): number {
|
||||
let index = rects.length
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
if (point.y < rects[i].y + rects[i].h / 2) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export function Overlay() {
|
||||
))}
|
||||
{idle && sel[0] && <BoxHandles block={sel[0]} />}
|
||||
{idle && <InsertSlots blocks={blocks} selected={selected} hovered={hovered} />}
|
||||
{idle && <SlotAdds selected={selected} hovered={hovered} />}
|
||||
{idle && sel[0] && <AttrChips />}
|
||||
{idle && sel[0] && <ActionBar block={sel[0]} />}
|
||||
{drag && <DropGhost />}
|
||||
@@ -246,6 +247,42 @@ function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected:
|
||||
)
|
||||
}
|
||||
|
||||
// --- slot add buttons: "+" on each tagged dropzone of the selected / hovered block ------------------
|
||||
// The outline already lists every schema slot with an add button; this is the
|
||||
// on-page equivalent so e.g. an empty `3d-accessories` zone on a PDP is
|
||||
// discoverable where the accessories render.
|
||||
|
||||
function SlotAdds({ selected, hovered }: { selected: string | null; hovered: string | null }) {
|
||||
const ctrl = useCtrl()
|
||||
useEditorState((s) => s.tick)
|
||||
useEditorState((s) => s.schemasVersion)
|
||||
const parents = [selected, hovered].filter((id, i, a): id is string => !!id && a.indexOf(id) === i)
|
||||
if (!parents.length) return null
|
||||
// NB: doc.querySelectorAll does not pierce the editor's shadow root, so
|
||||
// editor UI is never included (unlike block scans, no host filter needed).
|
||||
const zones = [...ctrl.doc.querySelectorAll<HTMLElement>('[data-cms-slot]')].filter((el) =>
|
||||
parents.includes(el.getAttribute('data-cms-slot-parent') ?? ''),
|
||||
)
|
||||
const open = (parentId: string, handle: string, x: number, y: number) => {
|
||||
let key = 0
|
||||
key = ctrl.openDialog({ id: `insert:${parentId}:${handle}`, title: `Add to ${handle}`, size: 'sm', anchor: { x, y }, content: createElement(InsertPicker, { parentId, handle, ctrl, close: () => ctrl.closeDialog(key) }) })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{zones.map((el) => {
|
||||
const parentId = el.getAttribute('data-cms-slot-parent')!
|
||||
const handle = el.getAttribute('data-cms-slot')!
|
||||
const r = rectOf(el)
|
||||
return (
|
||||
<button key={`${parentId}:${handle}`} className="cms-slot" style={{ left: Math.max(4, r.right - 14), top: Math.max(4, r.top + 2) }} aria-label={`Add to ${handle}`} title={`Add to ${handle}`} onClick={() => open(parentId, handle, r.right, r.top)}>
|
||||
<Icon name="add" size={14} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------
|
||||
|
||||
const ATTR_ICON: Record<string, string> = { href: 'link', src: 'image', alt: 'info', title: 'info' }
|
||||
|
||||
+43
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dropTarget } from '../src/page/dnd'
|
||||
import type { DndBlock } from '../src/page/dnd'
|
||||
import { dropTarget, moveTarget } 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 })
|
||||
// page: A (container: a1, a2) , B (container, empty), C (leaf)
|
||||
@@ -36,3 +36,44 @@ describe('dropTarget', () => {
|
||||
expect(dropTarget(blocks, { x: 50, y: 900 }, 'a1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropTarget with tagged slots ([data-cms-slot])', () => {
|
||||
// PDP 'p1' (container) with an accessory slot zone holding g1; shop blurb 'C' is a leaf.
|
||||
const pdp: DndBlock[] = [
|
||||
{ id: 'p1', container: true, depth: 0, rect: r(0, 400) },
|
||||
{ id: 'g1', parentId: 'p1', container: false, depth: 1, rect: r(200, 80, 10, 580) },
|
||||
{ id: 'C', container: false, depth: 0, rect: r(420, 100) },
|
||||
]
|
||||
const slots: DndSlot[] = [{ handle: '3d-accessories', parentId: 'p1', rect: r(190, 150, 0, 600), kids: ['g1'] }]
|
||||
const acc = (s: DndSlot) => s.handle === '3d-accessories'
|
||||
|
||||
it('lands in the slot with the handle, indexed among slot members only', () => {
|
||||
expect(dropTarget(pdp, { x: 50, y: 195 }, 'C', undefined, slots, acc)).toMatchObject({ parentId: 'p1', handle: '3d-accessories', index: 0 })
|
||||
expect(dropTarget(pdp, { x: 50, y: 270 }, 'C', undefined, slots, acc)).toMatchObject({ parentId: 'p1', handle: '3d-accessories', index: 1 })
|
||||
})
|
||||
it('drops into an empty slot at index 0', () => {
|
||||
const empty: DndSlot[] = [{ handle: '3d-accessories', parentId: 'p1', rect: r(190, 60, 0, 600), kids: [] }]
|
||||
const t = dropTarget(pdp, { x: 50, y: 200 }, 'C', undefined, empty, acc)!
|
||||
expect(t).toMatchObject({ parentId: 'p1', handle: '3d-accessories', index: 0 })
|
||||
expect(t.line.y).toBeGreaterThanOrEqual(190)
|
||||
})
|
||||
it('a refused slot falls back to the plain container', () => {
|
||||
expect(dropTarget(pdp, { x: 50, y: 200 }, 'C', undefined, slots, () => false)).toMatchObject({ parentId: 'p1', index: 0 })
|
||||
})
|
||||
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('moveTarget sorts within the dragged block\'s own slot', () => {
|
||||
const two: DndBlock[] = [
|
||||
{ id: 'p1', container: true, depth: 0, rect: r(0, 400) },
|
||||
{ id: 'g1', parentId: 'p1', container: false, depth: 1, rect: r(200, 80, 10, 580) },
|
||||
{ id: 'g2', parentId: 'p1', container: false, depth: 1, rect: r(290, 80, 10, 580) },
|
||||
]
|
||||
const s2: DndSlot[] = [{ handle: '3d-accessories', parentId: 'p1', rect: r(190, 200, 0, 600), kids: ['g1', 'g2'] }]
|
||||
expect(moveTarget(two, { x: 50, y: 195 }, { id: 'g2', parentId: 'p1', handle: '3d-accessories' }, { slots: s2 })).toMatchObject({
|
||||
parentId: 'p1',
|
||||
handle: '3d-accessories',
|
||||
index: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user