From 7362e4730f2cd326fd7b051fe66ac5a5fe132749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Mon, 9 Nov 2026 09:35:16 +0100 Subject: [PATCH] Cheaper, clearer dropzones: memoize, hatch refused, label slots as words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DropZones re-ran the accept predicates and a DOM walk on every overlay render (pointermove-driven). Matching now runs once per drag via matchZones(); scroll/resize ticks only re-measure the cached rects (new test pins the predicate count across ticks). Refused slots get a diagonal hatch on top of the red tint so the state is not color-only, slot tags show handles as words (`3d-accessories` -> "3d accessories", raw key kept in title), and the ghost pop honours prefers-reduced-motion. The mock section template now marks its sidebar slot like a real template, so slot tests use the fixture instead of hand-injected DOM (the old palette test asserting "no tagged slots" now expects both sections' slots). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- mock/fixtures.ts | 4 ++- src/controller.ts | 20 ++++++----- src/ui/Overlay.tsx | 81 ++++++++++++++++++++++++++++---------------- src/ui/styles.css | 4 +-- test/onpage.test.tsx | 57 ++++++++++++++++++++++++------- 5 files changed, 114 insertions(+), 52 deletions(-) diff --git a/mock/fixtures.ts b/mock/fixtures.ts index 02aec0b..4e1c89c 100644 --- a/mock/fixtures.ts +++ b/mock/fixtures.ts @@ -191,9 +191,11 @@ export function render(g: Graph, id: string): string { const attrs = `data-cms="${n.id}" data-cms-type="${n.dataType}"` switch (n.dataType) { case 'section': + // The schema declares a `sidebar` connection, so the template marks the zone + // (empty when nothing is slotted) the way a real template would. return `
${ n.data.title ? `

${esc(n.data.title)}

` : '' - }\n${kids()}\n
` + }\n${kids()}\n
\n` case 'heading': { const l = n.data.level ?? 2 return `${esc(n.data.text)}` diff --git a/src/controller.ts b/src/controller.ts index 1c5ebb1..1d7c5bd 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -268,14 +268,18 @@ export class Controller { }) } - /** 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 }] - }) - } + /** 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 }] + }) + } + + /** Tagged slot zones from the last scan. Elements stay live; measure their rects per use. */ + slotZones(): Array<{ handle: string; parentId: string; el: HTMLElement }> { + return this.slots.filter((s) => s.el.isConnected).map(({ handle, parentId, el }) => ({ handle, parentId, el })) + } block(id: string | null | undefined): Block | undefined { return id ? this.store.get().blocks.find((b) => b.id === id) : undefined diff --git a/src/ui/Overlay.tsx b/src/ui/Overlay.tsx index dcb9a11..c813436 100644 --- a/src/ui/Overlay.tsx +++ b/src/ui/Overlay.tsx @@ -1,4 +1,4 @@ -import { createElement, useEffect, useRef, useState } from 'react' +import { createElement, useEffect, useMemo, useRef, useState } from 'react' import type { Block, Controller } from '../controller' import { AttrEditor } from '../editors/AttrEditor' import { InsertPicker } from '../editors/builtin' @@ -223,16 +223,23 @@ function DropGhost() { // --- dropzone highlights: while dragging, outline every zone the dragged item can land in -------- // Palette blueprints, searched nodes and moving blocks share one engine (`accepts` / // `acceptsInSlot` in the controller); the highlights use the same predicates so what -// glows is exactly where a drop commits. Refused slots stay visible but dimmed, -// so it is clear they exist and just do not take this item. +// glows is exactly where a drop commits. Refused slots stay visible but dimmed +// (hatched, so the state is not color-only), so it is clear they exist and +// just do not take this item. +// +// Matching runs once per drag: the page cannot restructure mid-drag, so renders +// triggered by scroll/resize ticks only re-measure the cached rects. -function DropZones() { - const ctrl = useCtrl() - const drag = useEditorState((s) => s.drag)! - const blocks = useEditorState((s) => s.blocks) - useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation - useEditorState((s) => s.schemasVersion) - if (!drag) return null +export interface ZoneDefs { + containers: Block[] + slots: { key: string; parentId: string; handle: string; el: HTMLElement; refused: boolean }[] +} + +export function matchZones( + ctrl: Controller, + blocks: Block[], + drag: { source: string; id: string | null; type?: string; blueprint?: string }, +): ZoneDefs { const type = drag.type // A moving block can never land inside itself: mirror the engine, which // excludes the dragged block and its descendants from every hit test. @@ -255,31 +262,47 @@ function DropZones() { const containers = blocks.filter( (b) => b.container && b.el.isConnected && !excluded.has(b.id) && (!type || ctrl.accepts(b.id, type) || ctrl.handleForDrop(b.id, type) !== undefined), ) - // NB: doc.querySelectorAll does not pierce the editor's shadow root, so - // editor UI is never included (same as SlotAdds below). - const slots = [...ctrl.doc.querySelectorAll('[data-cms-slot]')].flatMap((el, i) => { - const handle = el.getAttribute('data-cms-slot') - if (!handle || !el.isConnected) return [] - const parentId = el.getAttribute('data-cms-slot-parent') ?? el.closest('[data-cms]')?.getAttribute('data-cms') ?? '' - if (!parentId) return [] - if (drag.source === 'block' && parentId === drag.id) return [] // engine never targets the dragged block's own zones - const r = el.getBoundingClientRect() - // Blueprints cannot land in slots (the outline refuses them too); a type - // fits when the slot's own `to` list takes it. - const refused = !type || !ctrl.acceptsInSlot(parentId, handle, type) - return [{ key: `${parentId}:${handle}:${i}`, parentId, handle, rect: r, refused }] + // Slot zones come from the controller's last scan (no second DOM walk; the + // query never pierces the editor's shadow root, so editor UI is excluded). + // Blueprints cannot land in slots (the outline refuses them too); a type + // fits when the slot's own `to` list takes it. + const slots = ctrl.slotZones().flatMap((s, i) => { + if (drag.source === 'block' && s.parentId === drag.id) return [] // engine never targets the dragged block's own zones + const refused = !type || !ctrl.acceptsInSlot(s.parentId, s.handle, type) + return [{ key: `${s.parentId}:${s.handle}:${i}`, parentId: s.parentId, handle: s.handle, el: s.el, refused }] }) + return { containers, slots } +} + +/** Slot handles are template keys (`3d-accessories`); show them as words. */ +export function slotLabel(handle: string): string { + return handle.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim() +} + +function DropZones() { + const ctrl = useCtrl() + const drag = useEditorState((s) => s.drag)! + const blocks = useEditorState((s) => s.blocks) + useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation + const schemasVersion = useEditorState((s) => s.schemasVersion) + // The drag object is rebuilt every pointermove; key on what matching reads. + const spec = drag ? `${drag.source}:${drag.id ?? ''}:${drag.type ?? ''}:${drag.blueprint ?? ''}` : '' + const zones = useMemo(() => (drag ? matchZones(ctrl, blocks, drag) : null), [ctrl, blocks, schemasVersion, spec]) + if (!drag || !zones) return null return ( <> - {containers.map((b) => { + {zones.containers.map((b) => { const r = b.el.getBoundingClientRect() return
})} - {slots.map((z) => ( -
- {z.handle} -
- ))} + {zones.slots.map((z) => { + const r = z.el.getBoundingClientRect() + return ( +
+ {slotLabel(z.handle)} +
+ ) + })} ) } diff --git a/src/ui/styles.css b/src/ui/styles.css index f26af25..a2f6bd9 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -218,7 +218,7 @@ input.cms-map__key { padding-top: 6px; } /* dropzone highlights: while dragging, every zone the dragged item can land in */ .cms-dropzone { position: fixed; pointer-events: none; z-index: 1; border: 1.5px dashed rgba(94,224,160,.7); border-radius: 6px; background: rgba(94,224,160,.05); } .cms-dropzone--slot { border-color: rgba(106,167,255,.75); background: rgba(106,167,255,.07); } -.cms-dropzone--slot.is-refused { border-color: rgba(163,58,74,.55); background: rgba(163,58,74,.04); } +.cms-dropzone--slot.is-refused { border-color: rgba(163,58,74,.55); background-color: rgba(163,58,74,.06); background-image: repeating-linear-gradient(45deg, rgba(163,58,74,.16) 0 6px, transparent 6px 12px); } .cms-dropzone__tag { position: absolute; top: -9px; left: 8px; padding: 0 6px; font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; background: #1c2b25; color: #8fe3b8; border: 1px solid rgba(94,224,160,.7); border-radius: 999px; } .cms-dropzone--slot .cms-dropzone__tag { background: #232c4a; color: #aebdff; border-color: rgba(106,167,255,.75); } .cms-dropzone--slot.is-refused .cms-dropzone__tag { background: #33222a; color: #e89aa6; border-color: rgba(163,58,74,.55); } @@ -311,7 +311,7 @@ input.cms-map__key { padding-top: 6px; } .cms-snap { position: fixed; pointer-events: none; background: rgba(94,224,160,.28); border-radius: 2px; } .cms-snap.is-active { background: transparent; } .cms-dropline { transition: left .09s ease-out, top .09s ease-out, width .09s ease-out, height .09s ease-out; } -@media (prefers-reduced-motion: reduce) { .cms-dropline { transition: none; } } +@media (prefers-reduced-motion: reduce) { .cms-dropline { transition: none; } .cms-ghost { animation: none; } } /* titles + groups */ .cms-frame__tag i { font-style: normal; opacity: .7; font-weight: 400; } diff --git a/test/onpage.test.tsx b/test/onpage.test.tsx index e6c3dca..a9dd361 100644 --- a/test/onpage.test.tsx +++ b/test/onpage.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react' import { byId, click, setup } from './harness' import type { Harness } from './harness' import { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon' +import { slotLabel } from '../src/ui/Overlay' import { createRoot } from 'react-dom/client' let h: Harness @@ -204,8 +205,7 @@ describe('block palette and drag & drop', () => { 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', '
') + // The section schema declares a `sidebar` slot taking text/button/footer; the fixture template marks it. 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() @@ -223,7 +223,6 @@ describe('block palette and drag & drop', () => { 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() @@ -267,7 +266,7 @@ describe('insertion slots (virtual "+" elements)', () => { h = await setup() await h.settle() await select('s1') - expect(qa('.cms-slot').map((s) => s.getAttribute('aria-label'))).toEqual(['Add block at position 1', 'Add block at position 2', 'Add block at position 3', 'Add block at position 4']) + expect(qa('.cms-slot').map((s) => s.getAttribute('aria-label'))).toEqual(['Add block at position 1', 'Add block at position 2', 'Add block at position 3', 'Add block at position 4', 'Add to sidebar']) await act(async () => void qa('.cms-slot')[1].click()) await h.settle() expect([...h.shadow.querySelectorAll('.cms-dialog h4')].map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) // grouped by x-graph.group @@ -419,7 +418,9 @@ describe('dropzone highlights', () => { expect(ids).toContain('s2') expect(ids).toContain('l1') // layout takes text too expect(ids).not.toContain('t1') // leaves are never dropzones - expect(slots().length).toBe(0) // the fixture page has no tagged slots + const s = slots() + expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar', 'slot:s2:sidebar']) // both sections mark their sidebar slot + expect(s.every((el) => !el.className.includes('is-refused'))).toBe(true) // sidebar takes text await stop() expect(qa('.cms-dropzone').length).toBe(0) }) @@ -438,13 +439,12 @@ describe('dropzone highlights', () => { it('a blueprint drag lights up containers and marks every slot refused', async () => { h = await setup() await h.settle() - byId('s1').insertAdjacentHTML('beforeend', '
') h.editor.ctrl.scan() h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' }) await h.settle() expect(zones()).toContain('s1') // typeless: every container fits const s = slots() - expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar']) + expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar', 'slot:s2:sidebar']) expect(s[0].className).toContain('is-refused') // blueprints cannot land in slots expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar') await stop() @@ -453,17 +453,16 @@ describe('dropzone highlights', () => { it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => { h = await setup() await h.settle() - byId('s1').insertAdjacentHTML('beforeend', '
') h.editor.ctrl.scan() h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' }) await h.settle() - expect(slots().length).toBe(1) - expect(slots()[0].className).not.toContain('is-refused') // sidebar takes text + expect(slots()).toHaveLength(2) + expect(slots().every((el) => !el.className.includes('is-refused'))).toBe(true) // sidebar takes text await stop() h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' }) await h.settle() - expect(slots().length).toBe(1) - expect(slots()[0].className).toContain('is-refused') // sidebar takes text/button/footer + expect(slots()).toHaveLength(2) + expect(slots().every((el) => el.className.includes('is-refused'))).toBe(true) // sidebar takes text/button/footer expect(zones()).toContain('s1') // ... while the section itself still takes it await stop() }) @@ -479,4 +478,38 @@ describe('dropzone highlights', () => { expect(ids).not.toContain('h1') // leaves are never dropzones await stop() }) + + it('zones match once per drag: scroll ticks re-measure but do not re-match', async () => { + h = await setup() + await h.settle() + const ctrl = h.editor.ctrl + let n = 0 + const orig = ctrl.accepts + ctrl.accepts = (...args: [string, string]) => { + n++ + return orig.call(ctrl, ...args) + } + try { + ctrl.beginPaletteDrag({ type: 'text', label: 'Text' }) + await h.settle() + expect(n).toBeGreaterThan(0) // matching ran: every container checked + expect(qa('.cms-dropzone').length).toBeGreaterThan(0) + ctrl.store.set({ tick: ctrl.store.get().tick + 1 }) // scroll/resize/mutation tick + await h.settle() + expect(qa('.cms-dropzone').length).toBeGreaterThan(0) // still drawn, rects re-measured ... + const after = n + ctrl.store.set({ tick: ctrl.store.get().tick + 1 }) + await h.settle() + expect(n).toBe(after) // ... without re-running the accept predicates + await stop() + } finally { + ctrl.accepts = orig + } + }) + + it('slot tags show handles as words', () => { + expect(slotLabel('sidebar')).toBe('sidebar') + expect(slotLabel('3d-accessories')).toBe('3d accessories') + expect(slotLabel('hero_slot')).toBe('hero slot') + }) })