From 33b3e2f170b924905fc1656d9c919e7bca8b5152 Mon Sep 17 00:00:00 2001 From: LilianBarbe <110779804+LilianBarbe@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:25:43 +0200 Subject: [PATCH] fix(navigator): keep the drop marker until the drag lands, leaves or is cancelled The panel body cleared the drop target on every dragleave, including moves between two of its own descendants, so the insertion line flickered and vanished while hovering the marker or a row. It now clears only when the pointer leaves the panel, hovers blank space, or the drag ends. The marker is absolutely positioned and ignores the pointer, so it no longer shifts the gap or becomes a drag target itself. Also, as separate UI choices: - the native drag ghost is replaced by a transparent image, so only the drop line shows where the node will land; - starting a drag selects the dragged row. Co-Authored-By: Claude Opus 5.5 --- src/panels/StructurePanel.tsx | 9 +++++++- src/panels/StructureTree.tsx | 12 ++++++++++- src/styles.css | 8 +++++-- test/navigator.js | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/panels/StructurePanel.tsx b/src/panels/StructurePanel.tsx index 4b3fa0ed..2d4007d2 100644 --- a/src/panels/StructurePanel.tsx +++ b/src/panels/StructurePanel.tsx @@ -433,7 +433,13 @@ function NavigatorBody({ }) { const nodes = props.pageState.model.nodes; return ( -
state.setDropTarget(null)}> +
state.setDropTarget(null)} + onDragLeave={(event) => { + if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) { + state.setDropTarget(null); + } + }}> {nodes.length === 0 && } @@ -482,6 +488,7 @@ function EmptyDropTarget({ onDragOver={(event) => { if (isDndPayload(event)) { event.preventDefault(); + event.stopPropagation(); state.setDropTarget({ kind: 'gap', ...location }); } }} diff --git a/src/panels/StructureTree.tsx b/src/panels/StructureTree.tsx index ecd7876e..5dbda7be 100644 --- a/src/panels/StructureTree.tsx +++ b/src/panels/StructureTree.tsx @@ -63,7 +63,15 @@ interface NodeListProps extends StructureTreeContext { const ANNOTATABLE = new Set(['element', 'component']); +// Decode once so native drag feedback does not obscure the drop target. +let blankDragImage: HTMLImageElement | undefined; export function NodeList({ nodes, parentId, depth, ...context }: NodeListProps) { + useEffect(() => { + if (!blankDragImage) { + blankDragImage = document.createElement('img'); + blankDragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + } + }, []); const { noteFor, folded } = foldedNotes(nodes); return ( <> @@ -299,6 +307,8 @@ function treeRowHandlers(props: TreeRowProps) { event.stopPropagation(); event.dataTransfer.setData('avb/node', node.id); event.dataTransfer.effectAllowed = 'move'; + if (blankDragImage) { event.dataTransfer.setDragImage(blankDragImage, 0, 0); } + props.onSelect(node.id); const tag = 'name' in node ? node.name : undefined; setDrag({ kind: 'node', @@ -307,7 +317,7 @@ function treeRowHandlers(props: TreeRowProps) { ...(tag === undefined ? {} : { tag }), }); }, - onDragEnd: clearDrag, + onDragEnd: () => { clearDrag(); props.setDropTarget(null); }, onDragOver: (event: React.DragEvent): void => { if (props.canHostChildren && acceptsDrag(node) && props.isDndPayload(event)) { event.preventDefault(); diff --git a/src/styles.css b/src/styles.css index 02fa6301..61b033a1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2380,14 +2380,18 @@ body > .embed-editor_provenance { gaps of every ancestor level are adjacent in the DOM — collapse all but the first so they don't stack into a large blank strip (each stays a distinct drop target for its own nesting level). */ -.nav-gap { height: 7px; } +.nav-gap { position: relative; height: 7px; } .nav-gap + .nav-gap { height: 2px; } .drop-indicator { + /* The marker must not move the gap or become a new drag target. */ + position: absolute; + inset: 50% 0 auto; + transform: translateY(-50%); + pointer-events: none; height: 2px; border-radius: 1px; background: var(--accent); - margin: 2px 0; box-shadow: 0 0 6px rgba(0, 153, 255, 0.6); } .drop-zone-empty { diff --git a/test/navigator.js b/test/navigator.js index 3fb89c7e..0eeebdbf 100644 --- a/test/navigator.js +++ b/test/navigator.js @@ -269,6 +269,45 @@ const WORDS = el('words', 'p', [{ id: 'w-text', kind: 'expr', value: '{heading}' ); } + // Moving between descendants must not erase the destination between two + // dragover events. Leaving the panel, hovering blank space, and cancelling + // a drag must still remove it. + { + const source = rowFor('hero-command'); + const gap = rowFor('if-ternary').previousElementSibling; + const data = new Map(); + const dataTransfer = { + types: ['avb/node'], + setData: (type, value) => data.set(type, value), + getData: (type) => data.get(type) || '', + setDragImage: () => {}, + }; + const fire = (element, type, relatedTarget = null) => act(async () => { + element.dispatchEvent(Object.assign( + new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }), + { dataTransfer } + )); + }); + await fire(source, 'dragstart'); + check('dragging selects the source row', selected.at(-1) === 'hero-command'); + check('dragging keeps the source in its original place', rowFor('hero-command') === source); + await fire(gap, 'dragover'); + check('a gap shows the insertion marker', !!gap.querySelector('.drop-indicator')); + await fire(gap, 'dragleave', gap.querySelector('.drop-indicator')); + check('entering the marker does not erase it', !!gap.querySelector('.drop-indicator')); + await fire(rowFor('if-and'), 'dragover'); + await fire(gap, 'dragleave', rowFor('if-and').querySelector('.label')); + check('entering a row preserves the new child destination', rowFor('if-and').style.borderColor !== ''); + await fire(rowFor('if-and'), 'dragleave', document.body); + check('leaving the panel clears the child destination', rowFor('if-and').style.borderColor === ''); + await fire(gap, 'dragover'); + await fire(container.querySelector('.panel-body'), 'dragover'); + check('blank space clears the previous marker', !container.querySelector('.drop-indicator')); + await fire(gap, 'dragover'); + await fire(source, 'dragend'); + check('cancelling clears the insertion marker', !container.querySelector('.drop-indicator')); + } + // --- arrow keys ------------------------------------------------------------- // They walk the tree that is drawn: a row nobody can see is not somewhere the // selection can land.