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.