Cheaper, clearer dropzones: memoize, hatch refused, label slots as words
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 <noreply@codebuff.com>
This commit is contained in:
1 parent
a798bc1ad3
commit
7362e4730f
5 files changed
+114
-52
No files matched your search
+3
-1
@@ -191,9 +191,11 @@ export function render(g: Graph, id: string): string {
|
|||||||
const attrs = `data-cms="${n.id}" data-cms-type="${n.dataType}"`
|
const attrs = `data-cms="${n.id}" data-cms-type="${n.dataType}"`
|
||||||
switch (n.dataType) {
|
switch (n.dataType) {
|
||||||
case 'section':
|
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 `<section class="sec sec--${esc(n.data.tone ?? 'light')}${n.data.padded ? ' sec--padded' : ''}" ${attrs} data-cms-container="true">${
|
return `<section class="sec sec--${esc(n.data.tone ?? 'light')}${n.data.padded ? ' sec--padded' : ''}" ${attrs} data-cms-container="true">${
|
||||||
n.data.title ? `<p class="sec__title">${esc(n.data.title)}</p>` : ''
|
n.data.title ? `<p class="sec__title">${esc(n.data.title)}</p>` : ''
|
||||||
}\n${kids()}\n</section>`
|
}\n${kids()}\n<div data-cms-slot="sidebar" data-cms-slot-parent="${n.id}"></div>\n</section>`
|
||||||
case 'heading': {
|
case 'heading': {
|
||||||
const l = n.data.level ?? 2
|
const l = n.data.level ?? 2
|
||||||
return `<h${l} ${attrs} data-cms-field="text" data-cms-editor="text">${esc(n.data.text)}</h${l}>`
|
return `<h${l} ${attrs} data-cms-field="text" data-cms-editor="text">${esc(n.data.text)}</h${l}>`
|
||||||
|
|||||||
+12
-8
@@ -268,14 +268,18 @@ export class Controller {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live slot rects for the drag engine (measured per move like blocks). */
|
/** Live slot rects for the drag engine (measured per move like blocks). */ private slotRects(): DndSlot[] {
|
||||||
private slotRects(): DndSlot[] {
|
return this.slots.flatMap((s) => {
|
||||||
return this.slots.flatMap((s) => {
|
if (!s.el.isConnected) return []
|
||||||
if (!s.el.isConnected) return []
|
const r = s.el.getBoundingClientRect()
|
||||||
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 }]
|
||||||
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 {
|
block(id: string | null | undefined): Block | undefined {
|
||||||
return id ? this.store.get().blocks.find((b) => b.id === id) : undefined
|
return id ? this.store.get().blocks.find((b) => b.id === id) : undefined
|
||||||
|
|||||||
+52
-29
@@ -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 type { Block, Controller } from '../controller'
|
||||||
import { AttrEditor } from '../editors/AttrEditor'
|
import { AttrEditor } from '../editors/AttrEditor'
|
||||||
import { InsertPicker } from '../editors/builtin'
|
import { InsertPicker } from '../editors/builtin'
|
||||||
@@ -223,16 +223,23 @@ function DropGhost() {
|
|||||||
// --- dropzone highlights: while dragging, outline every zone the dragged item can land in --------
|
// --- dropzone highlights: while dragging, outline every zone the dragged item can land in --------
|
||||||
// Palette blueprints, searched nodes and moving blocks share one engine (`accepts` /
|
// Palette blueprints, searched nodes and moving blocks share one engine (`accepts` /
|
||||||
// `acceptsInSlot` in the controller); the highlights use the same predicates so what
|
// `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,
|
// 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.
|
// (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() {
|
export interface ZoneDefs {
|
||||||
const ctrl = useCtrl()
|
containers: Block[]
|
||||||
const drag = useEditorState((s) => s.drag)!
|
slots: { key: string; parentId: string; handle: string; el: HTMLElement; refused: boolean }[]
|
||||||
const blocks = useEditorState((s) => s.blocks)
|
}
|
||||||
useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation
|
|
||||||
useEditorState((s) => s.schemasVersion)
|
export function matchZones(
|
||||||
if (!drag) return null
|
ctrl: Controller,
|
||||||
|
blocks: Block[],
|
||||||
|
drag: { source: string; id: string | null; type?: string; blueprint?: string },
|
||||||
|
): ZoneDefs {
|
||||||
const type = drag.type
|
const type = drag.type
|
||||||
// A moving block can never land inside itself: mirror the engine, which
|
// A moving block can never land inside itself: mirror the engine, which
|
||||||
// excludes the dragged block and its descendants from every hit test.
|
// excludes the dragged block and its descendants from every hit test.
|
||||||
@@ -255,31 +262,47 @@ function DropZones() {
|
|||||||
const containers = blocks.filter(
|
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),
|
(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
|
// Slot zones come from the controller's last scan (no second DOM walk; the
|
||||||
// editor UI is never included (same as SlotAdds below).
|
// query never pierces the editor's shadow root, so editor UI is excluded).
|
||||||
const slots = [...ctrl.doc.querySelectorAll<HTMLElement>('[data-cms-slot]')].flatMap((el, i) => {
|
// Blueprints cannot land in slots (the outline refuses them too); a type
|
||||||
const handle = el.getAttribute('data-cms-slot')
|
// fits when the slot's own `to` list takes it.
|
||||||
if (!handle || !el.isConnected) return []
|
const slots = ctrl.slotZones().flatMap((s, i) => {
|
||||||
const parentId = el.getAttribute('data-cms-slot-parent') ?? el.closest('[data-cms]')?.getAttribute('data-cms') ?? ''
|
if (drag.source === 'block' && s.parentId === drag.id) return [] // engine never targets the dragged block's own zones
|
||||||
if (!parentId) return []
|
const refused = !type || !ctrl.acceptsInSlot(s.parentId, s.handle, type)
|
||||||
if (drag.source === 'block' && parentId === drag.id) return [] // engine never targets the dragged block's own zones
|
return [{ key: `${s.parentId}:${s.handle}:${i}`, parentId: s.parentId, handle: s.handle, el: s.el, refused }]
|
||||||
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 }]
|
|
||||||
})
|
})
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{containers.map((b) => {
|
{zones.containers.map((b) => {
|
||||||
const r = b.el.getBoundingClientRect()
|
const r = b.el.getBoundingClientRect()
|
||||||
return <div key={b.id} className="cms-dropzone" data-dropzone={b.id} style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
|
return <div key={b.id} className="cms-dropzone" data-dropzone={b.id} style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
|
||||||
})}
|
})}
|
||||||
{slots.map((z) => (
|
{zones.slots.map((z) => {
|
||||||
<div key={z.key} className={`cms-dropzone cms-dropzone--slot${z.refused ? ' is-refused' : ''}`} data-dropzone={`slot:${z.parentId}:${z.handle}`} style={{ left: z.rect.left, top: z.rect.top, width: z.rect.width, height: z.rect.height }}>
|
const r = z.el.getBoundingClientRect()
|
||||||
<span className="cms-dropzone__tag">{z.handle}</span>
|
return (
|
||||||
</div>
|
<div key={z.key} className={`cms-dropzone cms-dropzone--slot${z.refused ? ' is-refused' : ''}`} data-dropzone={`slot:${z.parentId}:${z.handle}`} title={z.handle} style={{ left: r.left, top: r.top, width: r.width, height: r.height }}>
|
||||||
))}
|
<span className="cms-dropzone__tag">{slotLabel(z.handle)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -218,7 +218,7 @@ input.cms-map__key { padding-top: 6px; }
|
|||||||
/* dropzone highlights: while dragging, every zone the dragged item can land in */
|
/* 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 { 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 { 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__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 .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); }
|
.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 { position: fixed; pointer-events: none; background: rgba(94,224,160,.28); border-radius: 2px; }
|
||||||
.cms-snap.is-active { background: transparent; }
|
.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; }
|
.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 */
|
/* titles + groups */
|
||||||
.cms-frame__tag i { font-style: normal; opacity: .7; font-weight: 400; }
|
.cms-frame__tag i { font-style: normal; opacity: .7; font-weight: 400; }
|
||||||
|
|||||||
+45
-12
@@ -3,6 +3,7 @@ import { act } from 'react'
|
|||||||
import { byId, click, setup } from './harness'
|
import { byId, click, setup } from './harness'
|
||||||
import type { Harness } from './harness'
|
import type { Harness } from './harness'
|
||||||
import { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon'
|
import { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon'
|
||||||
|
import { slotLabel } from '../src/ui/Overlay'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
let h: Harness
|
let h: Harness
|
||||||
@@ -204,8 +205,7 @@ describe('block palette and drag & drop', () => {
|
|||||||
h = await setup()
|
h = await setup()
|
||||||
await h.settle()
|
await h.settle()
|
||||||
place('s1', 0, 0, 600, 300)
|
place('s1', 0, 0, 600, 300)
|
||||||
// The section schema declares a `sidebar` slot taking text/button/footer; the template marks it.
|
// The section schema declares a `sidebar` slot taking text/button/footer; the fixture template marks it.
|
||||||
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
||||||
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
||||||
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
|
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()
|
h.editor.ctrl.scan()
|
||||||
@@ -223,7 +223,6 @@ describe('block palette and drag & drop', () => {
|
|||||||
h = await setup()
|
h = await setup()
|
||||||
await h.settle()
|
await h.settle()
|
||||||
place('s1', 0, 0, 600, 300)
|
place('s1', 0, 0, 600, 300)
|
||||||
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
||||||
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
||||||
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
|
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()
|
h.editor.ctrl.scan()
|
||||||
@@ -267,7 +266,7 @@ describe('insertion slots (virtual "+" elements)', () => {
|
|||||||
h = await setup()
|
h = await setup()
|
||||||
await h.settle()
|
await h.settle()
|
||||||
await select('s1')
|
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 act(async () => void qa('.cms-slot')[1].click())
|
||||||
await h.settle()
|
await h.settle()
|
||||||
expect([...h.shadow.querySelectorAll('.cms-dialog h4')].map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) // grouped by x-graph.group
|
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('s2')
|
||||||
expect(ids).toContain('l1') // layout takes text too
|
expect(ids).toContain('l1') // layout takes text too
|
||||||
expect(ids).not.toContain('t1') // leaves are never dropzones
|
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()
|
await stop()
|
||||||
expect(qa('.cms-dropzone').length).toBe(0)
|
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 () => {
|
it('a blueprint drag lights up containers and marks every slot refused', async () => {
|
||||||
h = await setup()
|
h = await setup()
|
||||||
await h.settle()
|
await h.settle()
|
||||||
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
||||||
h.editor.ctrl.scan()
|
h.editor.ctrl.scan()
|
||||||
h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' })
|
h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' })
|
||||||
await h.settle()
|
await h.settle()
|
||||||
expect(zones()).toContain('s1') // typeless: every container fits
|
expect(zones()).toContain('s1') // typeless: every container fits
|
||||||
const s = slots()
|
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].className).toContain('is-refused') // blueprints cannot land in slots
|
||||||
expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar')
|
expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar')
|
||||||
await stop()
|
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 () => {
|
it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => {
|
||||||
h = await setup()
|
h = await setup()
|
||||||
await h.settle()
|
await h.settle()
|
||||||
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
||||||
h.editor.ctrl.scan()
|
h.editor.ctrl.scan()
|
||||||
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
|
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
|
||||||
await h.settle()
|
await h.settle()
|
||||||
expect(slots().length).toBe(1)
|
expect(slots()).toHaveLength(2)
|
||||||
expect(slots()[0].className).not.toContain('is-refused') // sidebar takes text
|
expect(slots().every((el) => !el.className.includes('is-refused'))).toBe(true) // sidebar takes text
|
||||||
await stop()
|
await stop()
|
||||||
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' })
|
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' })
|
||||||
await h.settle()
|
await h.settle()
|
||||||
expect(slots().length).toBe(1)
|
expect(slots()).toHaveLength(2)
|
||||||
expect(slots()[0].className).toContain('is-refused') // sidebar takes text/button/footer
|
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
|
expect(zones()).toContain('s1') // ... while the section itself still takes it
|
||||||
await stop()
|
await stop()
|
||||||
})
|
})
|
||||||
@@ -479,4 +478,38 @@ describe('dropzone highlights', () => {
|
|||||||
expect(ids).not.toContain('h1') // leaves are never dropzones
|
expect(ids).not.toContain('h1') // leaves are never dropzones
|
||||||
await stop()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user