From 8e8823421dc17f14fe35af880b2f3b53267df391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sat, 7 Nov 2026 11:26:52 +0100 Subject: [PATCH] editor: drop into tagged slot dropzones ([data-cms-slot]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/controller.ts | 40 ++++++++++++++++++++++-- src/page-style.css | 6 ++++ src/page/dnd.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++-- src/ui/Overlay.tsx | 37 ++++++++++++++++++++++ test/dnd.test.ts | 45 +++++++++++++++++++++++++-- 5 files changed, 196 insertions(+), 8 deletions(-) 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, + }) + }) +})