diff --git a/src/controller.ts b/src/controller.ts
index a768a88..765f0b2 100644
--- a/src/controller.ts
+++ b/src/controller.ts
@@ -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: `
` around a `Slot` output. */
+const SLOT_SEL = '[data-cms-slot]'
export class Controller {
readonly store: Store
@@ -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 & { el: HTMLElement }> = []
+
+ private scanSlots(blocks: Block[]) {
+ const byEl = new Map(blocks.map((b) => [b.el, b]))
+ const els = Array.from(this.doc.querySelectorAll(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(BLOCK_SEL)?.getAttribute('data-cms') ?? undefined
+ if (!parentId) return []
+ const kids = Array.from(el.querySelectorAll(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 } })
diff --git a/src/page-style.css b/src/page-style.css
index 608f89b..cb2f93d 100644
--- a/src/page-style.css
+++ b/src/page-style.css
@@ -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; }
diff --git a/src/page/dnd.ts b/src/page/dnd.ts
index b0b721d..a2e2327 100644
--- a/src/page/dnd.ts
+++ b/src/page/dnd.ts
@@ -16,6 +16,19 @@ export interface DndBlock {
depth: number
rect: Rect
}
+/**
+ * A tagged slot dropzone in the page: an element carrying
+ * `data-cms-slot="" data-cms-slot-parent=""`, emitted by
+ * node templates around their `{{.Slot ""}}` 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
}
diff --git a/src/ui/Overlay.tsx b/src/ui/Overlay.tsx
index 19a48a8..33b82d3 100644
--- a/src/ui/Overlay.tsx
+++ b/src/ui/Overlay.tsx
@@ -36,6 +36,7 @@ export function Overlay() {
))}
{idle && sel[0] && }
{idle && }
+ {idle && }
{idle && sel[0] && }
{idle && sel[0] && }
{drag && }
@@ -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('[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 (
+
+ )
+ })}
+ >
+ )
+}
+
// --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------
const ATTR_ICON: Record = { href: 'link', src: 'image', alt: 'info', title: 'info' }
diff --git a/test/dnd.test.ts b/test/dnd.test.ts
index 44d60c1..46fae5f 100644
--- a/test/dnd.test.ts
+++ b/test/dnd.test.ts
@@ -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,
+ })
+ })
+})