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
+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 { 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<HTMLElement>('[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 <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) => (
|
||||
<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 }}>
|
||||
<span className="cms-dropzone__tag">{z.handle}</span>
|
||||
</div>
|
||||
))}
|
||||
{zones.slots.map((z) => {
|
||||
const r = z.el.getBoundingClientRect()
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user