diff --git a/src/ui/Overlay.tsx b/src/ui/Overlay.tsx index 6b74e6c..dcb9a11 100644 --- a/src/ui/Overlay.tsx +++ b/src/ui/Overlay.tsx @@ -40,6 +40,7 @@ export function Overlay() { {idle && sel[0] && } {idle && sel[0] && } {drag && } + {drag && } ) } @@ -219,6 +220,70 @@ 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. + +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 + 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. + const excluded = new Set() + if (drag.source === 'block' && drag.id) { + excluded.add(drag.id) + for (let grew = true; grew; ) { + grew = false + for (const b of blocks) { + if (b.parentId && excluded.has(b.parentId) && !excluded.has(b.id)) { + excluded.add(b.id) + grew = true + } + } + } + } + // Same predicate the drag engine targets with: plain acceptance, or a slot + // fallback for types a container only takes through a named slot. A + // blueprint-only drag carries no type and fits every container. + 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 }] + }) + return ( + <> + {containers.map((b) => { + const r = b.el.getBoundingClientRect() + return
+ })} + {slots.map((z) => ( +
+ {z.handle} +
+ ))} + + ) +} + /** Why the tagged slot under the pointer refuses the dragged type (null when it fits or no slot is under the pointer). */ function slotRefusal(ctrl: Controller, drag: { slot: { parentId: string; handle: string } | null; type?: string; blueprint?: string }): { handle: string; why: string; rect: DOMRect } | null { if (!drag.slot || (!drag.type && !drag.blueprint)) return null diff --git a/src/ui/styles.css b/src/ui/styles.css index 3ad5929..f26af25 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -215,6 +215,14 @@ input.cms-map__key { padding-top: 6px; } .cms-dragchip.is-refused { border-color: #a33a4a; color: #ff8a8a; } .cms-slotref { position: fixed; display: inline-flex; align-items: center; gap: 6px; background: var(--cms-panel); border: 1px solid #a33a4a; color: #ff8a8a; border-radius: 999px; padding: 3px 10px; font-size: 12px; font-weight: 600; pointer-events: none; z-index: 4; max-width: 60vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +/* 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__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); } + /* insertion slots */ .cms-slot { position: fixed; pointer-events: auto; transform: translate(-50%, -50%); display: inline-flex; align-items: center; gap: 4px; min-width: 22px; height: 22px; padding: 0 3px; border-radius: 999px; border: 1px solid #4a5a9a; background: #2b3350; color: #dfe5ff; opacity: .0; transition: opacity .12s, transform .12s; z-index: 1; } .cms-overlay:hover .cms-slot, .cms-slot:focus-visible, .cms-slot--empty { opacity: .9; } diff --git a/test/onpage.test.tsx b/test/onpage.test.tsx index 30afd0d..e6c3dca 100644 --- a/test/onpage.test.tsx +++ b/test/onpage.test.tsx @@ -399,3 +399,84 @@ describe('sorting root nodes (snap between siblings)', () => { expect(kids('l1')).toEqual(['x2', 'x1']) }) }) + +describe('dropzone highlights', () => { + const zones = () => qa('.cms-dropzone:not(.cms-dropzone--slot)').map((el) => el.getAttribute('data-dropzone')) + const slots = () => qa('.cms-dropzone--slot') + const stop = async () => { + await act(async () => void h.editor.ctrl.cancelDrag()) + await h.settle() + } + + it('a palette drag outlines every container the type fits, and clears on drop/cancel', async () => { + h = await setup() + await h.settle() + expect(qa('.cms-dropzone').length).toBe(0) // idle: no highlights + h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'Text' }) + await h.settle() + const ids = zones() + expect(ids).toContain('s1') // section takes text as a plain child + 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 + await stop() + expect(qa('.cms-dropzone').length).toBe(0) + }) + + it('a type that only fits through a slot still lights up the container', async () => { + h = await setup() + await h.settle() + expect(h.editor.ctrl.accepts('s1', 'footer')).toBe(false) // not a plain child ... + expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar') // ... but fits the slot + h.editor.ctrl.beginPaletteDrag({ type: 'footer', label: 'Footer' }) + await h.settle() + expect(zones()).toContain('s1') // the drop lands in its slot, so it glows + await stop() + }) + + 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[0].className).toContain('is-refused') // blueprints cannot land in slots + expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar') + await stop() + }) + + 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 + 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(zones()).toContain('s1') // ... while the section itself still takes it + await stop() + }) + + it('moving a block excludes itself (and never highlights leaves)', async () => { + h = await setup() + await h.settle() + h.editor.ctrl.beginDrag('s1') + await h.settle() + const ids = zones() + expect(ids).not.toContain('s1') // cannot drop into itself + expect(ids).toEqual(expect.arrayContaining(['s2', 'l1'])) // sections nest, so siblings still take it + expect(ids).not.toContain('h1') // leaves are never dropzones + await stop() + }) +})