File: /home/globfdxw/diasporameetsafrica.com/wp-content/plugins/extendify/src/QuickEdit/lib/hover-bar.js
// Plain-DOM (not React) — mouseover-driven, runs outside the React
// commit cycle to avoid dropped clicks under fast pointer movement.
import { __ } from '@wordpress/i18n';
import { useEditModeStore } from '../state/edit-mode';
import { useQuickEditStore } from '../state/store';
import { isAgentEligibleForTarget } from './agent-gate';
import {
askAiAboutElement,
hasAgentBlockSelected,
isAgentAvailable,
isAgentSidebarOpen,
stageAgentBlock,
subscribeToAgentBlock,
} from './ask-ai';
import { prefetchBlockSource } from './block-source-cache';
import { decideClickAction } from './click-rule';
import { resolveTarget } from './dom';
import { track } from './insights';
import { hasQuickEditModalFor } from './quick-edit-handlers';
import { hasSaver, saveSelected } from './save-bridge';
import {
getTranslatedContext,
isTextBearing,
isTranslatedRender,
translatedNoticeMessage,
} from './translated';
let hoverTarget = null;
let hoverBar = null;
let hoverOutline = null; // body-level positioned div, see ensureOutline()
let attached = false;
const debugLog = (label, el) => {
if (!window.extQuickEditData?.debug) return;
console.groupCollapsed(`[qe-debug] hover-bar: ${label}`);
if (el) console.log('target:', el);
console.trace();
console.groupEnd();
};
// Body-level fixed overlay rather than an outline on each block:
// outline overhang would clip inside an ancestor's overflow:hidden,
// and a single repositioning overlay gets the smooth-expand feel
// for free via CSS transitions.
const ensureOutline = () => {
if (hoverOutline) return hoverOutline;
hoverOutline = document.createElement('div');
hoverOutline.className =
'extendify-quick-edit extendify-quick-edit-hover-outline';
hoverOutline.setAttribute('aria-hidden', 'true');
document.body.appendChild(hoverOutline);
return hoverOutline;
};
// `instant` skips the 0.2s CSS transition. Scroll-driven updates can't
// afford to animate: every scroll event would reset the transition target
// while the outline is still en route, so the outline visibly trails the
// content during a drag-scroll ("stays fixed in screen"). Hover-driven
// updates (block A → block B) keep the spring animation.
const showOutline = (el, { instant = false } = {}) => {
const overlay = ensureOutline();
if (instant) {
overlay.style.transition = 'none';
} else if (overlay.style.transition === 'none') {
overlay.style.transition = '';
}
const r = el.getBoundingClientRect();
overlay.style.top = `${r.top}px`;
overlay.style.left = `${r.left}px`;
overlay.style.width = `${r.width}px`;
overlay.style.height = `${r.height}px`;
overlay.classList.add('is-visible');
debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
};
const hideOutline = () => {
hoverOutline?.classList.remove('is-visible');
debugLog('hideOutline');
};
const removeOutline = () => {
hoverOutline?.remove();
hoverOutline = null;
};
const POST_ATTR = 'data-extendify-agent-block-id';
const PART_ATTR = 'data-extendify-part-block-id';
const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
// Resolve the live DOM node for the currently-staged agent block, so the
// click + hover gates can carve out "inside the staged block." Returns
// null when no block is staged or its node has detached from the tree.
const stagedBlockEl = () => {
const block = useQuickEditStore.getState().agentBlock;
if (!block?.id) return null;
const attr = block.target || POST_ATTR;
return document.querySelector(`[${attr}="${CSS.escape(String(block.id))}"]`);
};
// Resolve the committed selection's live DOM node. buildTarget stashes
// the element reference on the descriptor; if the block has been swapped
// out (e.g. by an agent workflow) we treat the commit as gone.
const committedBlockEl = () => {
const sel = useQuickEditStore.getState().committedSelection;
if (!sel?.el || !document.body.contains(sel.el)) return null;
return sel.el;
};
// Tries above first, falls back to below or inside. Placement is
// stored on the dataset so CSS can extend the bar's hover area via
// a ::before bridge in the right direction.
const positionBar = (bar, el) => {
const rect = el.getBoundingClientRect();
const bw = bar.offsetWidth;
const bh = bar.offsetHeight || 36;
const gap = 8;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const adminBarH = document.getElementById('wpadminbar')?.offsetHeight ?? 0;
const minTop = adminBarH + 4;
let left = rect.left + (rect.width - bw) / 2;
left = Math.max(4, Math.min(left, vw - bw - 4));
let top = rect.top - bh - gap;
let placement = 'above';
if (top < minTop) {
const below = rect.bottom + gap;
const belowFits = below + bh + 4 <= vh;
const visibleHeight = Math.min(rect.bottom, vh) - Math.max(rect.top, 0);
const dominantBlock = visibleHeight > vh * 0.7;
if (belowFits && !dominantBlock) {
top = below;
placement = 'below';
} else {
top = Math.max(minTop, rect.top + 4);
placement = 'inside';
}
}
bar.style.top = `${top}px`;
bar.style.left = `${left}px`;
bar.dataset.extendifyQuickEditPlacement = placement;
};
let translatedErrorEl = null;
let translatedErrorTimer = 0;
const clearTranslatedError = () => {
if (translatedErrorTimer) {
window.clearTimeout(translatedErrorTimer);
translatedErrorTimer = 0;
}
translatedErrorEl?.remove();
translatedErrorEl = null;
};
// Body-level notice anchored just under the hover bar, so it reads as "this
// block" and stays visible even with the Agent sidebar open (a top-right pill
// hides behind it). Inline-styled like the canvas ErrorPill — the bar lives
// outside the prefix-scoped stylesheet, so utility classes wouldn't reach it.
const showTranslatedError = (bar) => {
clearTranslatedError();
const el = document.createElement('div');
el.className = 'extendify-quick-edit-translated-error';
el.setAttribute('role', 'alert');
el.textContent = translatedNoticeMessage(getTranslatedContext()?.plugin);
const r = bar.getBoundingClientRect();
Object.assign(el.style, {
position: 'fixed',
zIndex: '100001',
maxWidth: '300px',
padding: '8px 12px',
borderRadius: '8px',
background: '#fee2e2',
color: '#991b1b',
fontSize: '13px',
lineHeight: '1.4',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
left: `${Math.max(4, r.left)}px`,
top: `${r.bottom + 6}px`,
});
document.body.appendChild(el);
translatedErrorEl = el;
translatedErrorTimer = window.setTimeout(clearTranslatedError, 6000);
};
const clearBar = () => {
if (hoverBar) {
hoverBar.remove();
hoverBar = null;
}
hoverTarget = null;
hideOutline();
clearTranslatedError();
};
// Walk past tagged-but-unsupported ancestors (e.g. core/post-title inside
// a cover's inner-container) so the bar resolves to the nearest editable
// parent. Without this, hovering the middle of a hero cover that
// surfaces post-title returned blockType=null and — combined with the
// template-part source gating Ask AI off — produced no bar at all.
const buildTarget = (el) => {
let current = resolveTarget(el);
let safety = 5;
while (
current &&
!current.blockType &&
current.el?.parentElement &&
safety-- > 0
) {
const next = resolveTarget(current.el.parentElement);
if (!next) return current;
current = next;
}
return current;
};
// Picker blocks keep the bar visible because the dropdown anchors
// to it; text edits tear it down so the inline toolbar can replace
// it. Keep aligned with PICKER_STRATEGIES in components/InlineEditor.jsx.
const isPickerType = (blockType) =>
blockType === 'core/image' ||
blockType === 'core/cover' ||
blockType === 'core/media-text:image' ||
blockType === 'product:image';
// Translated text blocks have no editor — Quick Edit writes the source
// post_content while the screen shows the translation. We never commit a
// selection for them (it would render nothing and the unsubSelected cancel-on-
// clear logic would tear down a co-staged Ask AI block); the bar's Quick Edit
// pill shows the error inline instead, and Ask AI stays reachable.
const isTranslatedTextBlock = (target) =>
isTranslatedRender() && isTextBearing(target?.blockType);
// Which pills a target would surface (without mounting the bar). Click rule
// (Option 7) needs this to decide between opening QE directly, today's
// sticky commit, and the silent agent stage. Exported so keyboard-entry
// gates Enter on the same signal the hover bar uses.
export const pillContextFor = (target) => {
const quickEditEnabled = !!window.extQuickEditData?.quickEditEnabled;
const quickEditable =
quickEditEnabled && hasQuickEditModalFor(target?.blockType);
const sourceKind = target?.source?.kind ?? null;
const agentSupportedSource = sourceKind === 'post' || sourceKind === null;
const aiAvailable =
isAgentAvailable() &&
agentSupportedSource &&
isAgentEligibleForTarget(target);
return { quickEditable, aiAvailable };
};
// Exported for keyboard-entry to bypass the bar's click handler.
export const editTarget = (target) => onEditClick(target);
const onEditClick = (target) => {
const store = useQuickEditStore.getState();
if (store.selected?.el === target.el) {
store.setSelected(null);
store.setCommittedSelection(null);
clearBar();
return;
}
// Translated text has no editor — leave the bar in place (its Quick Edit
// pill shows the error) and don't commit a selection that renders nothing
// and would cancel a co-staged Ask AI block on clear.
if (isTranslatedTextBlock(target)) return;
// Snapshot the bar rect before clearing — ImagePicker anchors to it.
const anchorRect = hoverBar?.getBoundingClientRect() ?? null;
const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
if (!isPickerType(target.blockType)) clearBar();
store.setCommittedSelection(null);
store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
track('quick_edit_clicked', { blockType: target.blockType });
};
const onAiClick = (el) => {
// clearSelected before clearBar: when QE was clicked first on a
// picker-type block (image / cover), `selected` is set and the
// InlineEditor renders an ImagePicker dropdown anchored to the bar.
// clearBar removes the bar but leaves the dropdown mounted as an
// orphan; clearing `selected` first unmounts the InlineEditor too.
const store = useQuickEditStore.getState();
store.clearSelected();
clearBar();
store.setCommittedSelection(null);
track('ask_ai_clicked', { matched: !!resolveTarget(el)?.blockType });
askAiAboutElement(el);
};
// Exported for keyboard-entry to route Enter on an Ask-AI-only block
// straight to the agent, mirroring the Ask AI pill's click handler.
export const askAiTarget = (el) => onAiClick(el);
// Exported for keyboard-entry's focus-driven mount/dismiss.
export const showBar = (el) => renderBar(el);
export const hideBar = () => clearBar();
const renderBar = (el) => {
// While an agent block is staged, the hover bar is intentionally
// hidden — only DOMHighlighter's X-close indicator is shown.
// Defense in depth for any caller (a re-render, the keyboard
// entry's showBar) that might otherwise paint a stale bar.
if (hasAgentBlockSelected()) return;
const target = buildTarget(el);
const { quickEditable, aiAvailable } = pillContextFor(target);
// Bail BEFORE clearing the current bar — when the cursor traverses
// from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
// tagged group with too many inner tagged blocks, or a tagged
// post-title walked up to from inside), the previous behavior was
// to clearBar() first and then bail, leaving the user with no bar
// at all. Round-5 regression: cursor passing under the bar gap on
// the way to a pill could land on the ancestor before reaching the
// pill itself. Keep the existing bar in place if the new candidate
// has nothing to render.
if (!quickEditable && !aiAvailable) return;
clearBar();
// Position around the resolved target (which may be a walked-up
// ancestor), not the original DOM node we entered on. media-text's
// image is a child <figure> (mediaEl) of the block element — anchor the
// outline + bar to it so the selector hugs the image, while Quick Edit
// and Ask AI still act on the block element.
const positionEl = target?.el ?? el;
const anchorEl = target?.mediaEl ?? positionEl;
hoverTarget = anchorEl;
showOutline(anchorEl);
// Prefetch source markup so BlockTextEditor's load effect hits the cache.
// No-ops for sources the cache doesn't load (product/wpforms/nav).
prefetchBlockSource(target?.source, target?.blockId);
const bar = document.createElement('div');
bar.className = 'extendify-quick-edit extendify-quick-edit-bar';
bar.setAttribute('data-extendify-quick-edit-bar', '');
// preventDefault on mousedown so clicks don't blur a contenteditable
// in another open editor.
const stopMouseDown = (ev) => ev.preventDefault();
// Forward wheel events to the page scroller. The bar (and its
// ::before hover bridge) sits over page content; with bar
// pointer-events: auto, real-mouse wheel-scrolling stalled on the
// bar in production. An earlier attempt at a CSS-only fix
// (pointer-events: none on the wrapper) broke hover-
// traversal block→pill. Restore pointer-events: auto
// and own scroll in JS instead — gives us both behaviors.
bar.addEventListener(
'wheel',
(ev) => {
window.scrollBy({ left: ev.deltaX, top: ev.deltaY });
ev.preventDefault();
},
{ passive: false },
);
if (quickEditable) {
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'extendify-quick-edit-pill';
editBtn.setAttribute('data-extendify-quick-edit-pill', '');
editBtn.innerHTML = '<span aria-hidden="true">✎</span>';
editBtn.append(__('Quick Edit', 'extendify-local'));
editBtn.addEventListener('mousedown', stopMouseDown);
editBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
// Translated text can't be edited (we'd overwrite the source) — show
// the error right under the bar and leave the bar (with Ask AI) in
// place rather than opening a canvas.
if (isTranslatedTextBlock(target)) {
showTranslatedError(bar);
return;
}
onEditClick(target);
});
bar.appendChild(editBtn);
}
if (aiAvailable) {
const aiBtn = document.createElement('button');
aiBtn.type = 'button';
aiBtn.className = 'extendify-quick-edit-pill extendify-quick-edit-pill-ai';
aiBtn.setAttribute('data-extendify-quick-edit-pill', '');
aiBtn.innerHTML = '<span aria-hidden="true">✦</span>';
aiBtn.append(__('Ask AI', 'extendify-local'));
aiBtn.addEventListener('mousedown', stopMouseDown);
aiBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
onAiClick(positionEl);
});
bar.appendChild(aiBtn);
}
document.body.appendChild(bar);
hoverBar = bar;
positionBar(bar, anchorEl);
};
// Walk up to the innermost tagged ancestor. resolveTarget then derives
// blockType from that element's wp-block-* class; the pill renderer
// decides which pills (if any) to show. Lighter than resolveTarget —
// onMouseOver hot path doesn't need the full descriptor.
const findTagged = (start) => {
let node = start;
while (node && node.nodeType === 1 && node !== document.body) {
if (
node.hasAttribute?.(POST_ATTR) ||
node.hasAttribute?.(PART_ATTR) ||
node.hasAttribute?.(PRODUCT_ATTR) ||
node.hasAttribute?.(WPFORM_FIELD_ATTR) ||
node.hasAttribute?.(MEDIATEXT_MEDIA_ATTR)
) {
return node;
}
node = node.parentElement;
}
return null;
};
const onMouseOver = (e) => {
if (!useEditModeStore.getState().on) return;
if (useQuickEditStore.getState().selected) return;
// Sticky modes hard-suppress all hover-driven bar movement.
// - agentBlock staged: the bar is intentionally hidden; only
// DOMHighlighter's X-close is shown. To re-engage Ask AI on the
// same block, the user clicks X-close (clears agentBlock) then
// re-hovers / re-clicks.
// - committedSelection: the bar is pinned to the committed element.
// Hover anywhere else — including tagged inner blocks of a
// committed container — leaves the bar where it is. To select a
// different block, the user clicks outside or presses Esc first.
if (hasAgentBlockSelected()) return;
if (useQuickEditStore.getState().committedSelection) return;
if (hoverBar && (e.target === hoverBar || hoverBar.contains(e.target))) {
return;
}
const el = findTagged(e.target);
if (el === hoverTarget) return;
if (!el) return;
renderBar(el);
};
const onScrollOrResize = () => {
if (!hoverTarget) return;
if (hoverBar) positionBar(hoverBar, hoverTarget);
if (hoverOutline?.classList.contains('is-visible')) {
showOutline(hoverTarget, { instant: true });
}
};
// Capture-phase so we win against any underlying handler (link nav,
// contact form submit, theme JS) — we either commit the click as a
// selection, let it through, or clear the bar. Decision per `decideClickAction`.
// The hover bar itself is in this list so clicks on it (the pills) bail
// before the committed-selection clear branch fires — pill handlers run on
// bubble and need the bar to still be in the DOM.
//
// WP popovers (LinkControl + format-toolbar in the canvas) get explicit
// entries too: the popover may end up portaled inside a tagged ancestor
// (BlockTools' Popover.Slot lives inside our canvas, which can be a
// descendant of `[data-extendify-agent-block-id]`). Without these
// entries, the capture handler routes the click to the tagged ancestor
// via decideClickAction's `select` branch, preventDefault eats the
// click, and the URL input never focuses.
const QE_INTERIOR = [
'.extendify-quick-edit-bar',
'.extendify-quick-edit-canvas',
'.extendify-quick-edit-floating-bar',
'.extendify-quick-edit-image-menu',
'.extendify-quick-edit-modal',
'.extendify-quick-edit-modal-root',
'.block-editor-link-control',
'.components-popover',
'#extendify-agent-main',
'#extendify-agent-dom-mount',
'#wpadminbar',
// The wp.media library (the Agent's "Change image" picker and QE's own
// image flows). Without this, clicking an image in the grid reads as an
// outside-click: it clears the staged agentBlock and cancels the in-flight
// agent workflow, unmounting the picker's confirm component and orphaning
// the modal as a stuck white overlay.
'.media-modal',
'.media-modal-backdrop',
].join(', ');
const onDocClickCapture = (e) => {
if (!useEditModeStore.getState().on) return;
if (e.target?.closest?.(QE_INTERIOR)) return;
// Implicit close on the QE text-edit canvas: clicks outside the canvas
// while it's open save the in-flight edits instead of discarding them.
// `alsoClear: false` only when the click will open QE on a different
// block (the `select` branch's `quickEditable` cell); otherwise save
// clears so the canvas unmounts. Without that distinction the click
// would race: save's `clearSelected(null)` would overwrite the new
// block's `setSelected(B)`. `hasSaver()` is false for picker blocks
// (image / cover) — they save synchronously on pick and never
// register. Fall through either way so the existing agentBlock-clear +
// clear-bar branches still run.
if (hasSaver() && useQuickEditStore.getState().selected) {
const tagged = findTagged(e.target);
const willOpenQE =
!!tagged && hasQuickEditModalFor(buildTarget(tagged)?.blockType);
saveSelected({ alsoClear: !willOpenQE });
}
// Soft selection: while a block is staged for Ask AI, clicks INSIDE
// the staged block route natively (anchor navigates, form control
// focuses, text-content click is a no-op) — EXCEPT when they land on
// a tagged descendant block, in which case the same gesture swaps
// the stage onto the descendant (drill-in parity with the cross-
// sibling swap below). Clicks OUTSIDE clear the staged block —
// sidebar stays open. The asymmetry is intentional: closing the
// sidebar still cascades to clearing the block (handled in
// Agent.jsx), but clearing the block here does NOT close the
// sidebar.
if (hasAgentBlockSelected()) {
const staged = stagedBlockEl();
if (staged?.contains(e.target)) {
const innerTagged = findTagged(e.target);
if (!innerTagged || innerTagged === staged) return;
}
useQuickEditStore.setState({ agentBlock: null, agentBlockCode: null });
// Fall through to decideClickAction only when the click lands on a
// tagged block (sibling or descendant) — the cross-block gesture
// transitions both surfaces (QE + agent re-stage) in one click.
// Whitespace / non-tagged outside-clicks return here: the same
// gesture that clears the staged block shouldn't commit a new
// selection out of empty space.
if (!findTagged(e.target)) return;
}
// Sticky pre-pill-action selection: a prior click committed a block.
// Inside-clicks route natively (anchor / form control) — EXCEPT when
// they land on a tagged descendant, which swaps the commit onto the
// descendant in the same gesture. Outside-clicks clear the commit; if
// the same click also lands on a different tagged block, the switch
// below commits it in the same gesture so a single click swaps the
// selection. Pills bail above via QE_INTERIOR so they aren't treated
// as outside-clicks.
if (useQuickEditStore.getState().committedSelection) {
const committedEl = committedBlockEl();
if (committedEl?.contains(e.target)) {
const innerTagged = findTagged(e.target);
if (!innerTagged || innerTagged === committedEl) return;
}
useQuickEditStore.getState().setCommittedSelection(null);
clearBar();
}
const result = decideClickAction(e.target);
switch (result.action) {
case 'select': {
e.preventDefault();
e.stopPropagation();
// stopPropagation above blocks ImagePicker's bubble-phase
// outside-click — without this clear its menu lingers (issue 19).
const store = useQuickEditStore.getState();
if (
store.selected &&
isPickerType(store.selected.blockType) &&
store.selected.el !== result.el
) {
store.clearSelected();
}
const target = buildTarget(result.el);
const { quickEditable, aiAvailable } = pillContextFor(target);
// Click semantics by pill count + agent-open state:
// QE-only → open QE menu directly (collapsed gesture).
// AI-only + closed → today's sticky commit (the one path that
// keeps committedSelection alive).
// AI-only + open → silently stage agentBlock (bridge).
// Both pills → open QE menu directly; bridge agentBlock
// too when the agent sidebar is open. The
// Ask AI button now lives on the QE bar
// chrome (BlockTextEditor.jsx), so the
// collapsed click no longer hides Ask AI.
// Picker-type blocks (image, cover) are
// exempt from the silent stage — the
// hover bar stays mounted for them and
// keeps the Ask AI pill, so the user
// escalates explicitly rather than seeing
// both the picker dropdown AND the
// agent's X-close at once.
// Tagged but neither → clear (no outline on a block the user
// can't act on).
if (quickEditable) {
renderBar(result.el);
onEditClick(target);
if (
aiAvailable &&
isAgentSidebarOpen() &&
!isPickerType(target.blockType)
) {
stageAgentBlock(result.el);
}
return;
}
if (aiAvailable && isAgentSidebarOpen()) {
useQuickEditStore.getState().setCommittedSelection(null);
clearBar();
stageAgentBlock(result.el);
return;
}
if (aiAvailable) {
useQuickEditStore.getState().setCommittedSelection(target);
renderBar(result.el);
return;
}
clearBar();
return;
}
case 'clear':
clearBar();
return;
default:
return;
}
};
let unsubEditMode = null;
let unsubSelected = null;
let unsubAgentBlock = null;
let unsubCommitted = null;
export const attach = () => {
if (attached) return;
attached = true;
document.addEventListener('mouseover', onMouseOver, true);
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
document.addEventListener('click', onDocClickCapture, true);
// Warm the agent-sidebar state cache so the sync click rule has fresh
// state by the time the user clicks. The dynamic import resolves on
// the microtask queue; user clicks are seconds-later in real use.
isAgentSidebarOpen();
unsubEditMode = useEditModeStore.subscribe((state) => {
if (!state.on) {
useQuickEditStore.getState().setCommittedSelection(null);
clearBar();
}
});
// committedSelection → null transition: Esc / programmatic clears
// don't go through onDocClickCapture, so they wouldn't otherwise
// remove the bar. Fire clearBar here. The outside-click path
// already calls clearBar synchronously; this subscriber's clearBar
// is idempotent in that case.
let lastCommitted = useQuickEditStore.getState().committedSelection;
unsubCommitted = useQuickEditStore.subscribe((state) => {
const prev = lastCommitted;
lastCommitted = state.committedSelection;
if (prev && !state.committedSelection) clearBar();
});
// Picker dropdown anchors to the bar; keep it visible for those.
// On the non-null → null transition (Esc / Cancel / Save closes the
// canvas) the bar is re-rendered on the previously edited element
// without waiting for a mouse-cross — mouseover only fires when the
// cursor crosses an element boundary, so a user who Escs without
// moving the cursor would otherwise see the bar disappear and stay
// gone until they nudged the mouse.
let lastSelected = useQuickEditStore.getState().selected;
unsubSelected = useQuickEditStore.subscribe((state) => {
// The store carries multiple slots (selected / committedSelection /
// agentBlock / dirty / error). Without this gate, an unrelated write
// like setCommittedSelection(null) in onEditClick would fire this
// listener while state.selected was still the prior non-picker
// block — clearBar would then tear down the bar that renderBar
// just mounted for the new picker target.
if (state.selected === lastSelected) return;
const prev = lastSelected;
lastSelected = state.selected;
if (state.selected) {
if (!isPickerType(state.selected.blockType)) clearBar();
return;
}
// Canvas closing (Esc / Cancel / Save / programmatic clearSelected)
// on the same block the agent is staged on should also clear the
// stage — otherwise the dashed outline + X-close indicator linger
// after the user dismissed the canvas. The two-pill silent-stage
// shape sets both slots from one click, so closing the canvas is
// the symmetric "I'm done with this block" gesture.
const agentBlock = useQuickEditStore.getState().agentBlock;
if (
prev?.blockId != null &&
agentBlock?.id != null &&
String(prev.blockId) === String(agentBlock.id)
) {
window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow'));
useQuickEditStore.getState().setAgentBlock(null);
}
if (!prev?.el || !document.body.contains(prev.el)) return;
if (!useEditModeStore.getState().on) return;
if (hoverTarget === prev.el && hoverBar) return;
renderBar(prev.el);
});
unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
if (hasBlock) clearBar();
});
};
export const detach = () => {
if (!attached) return;
attached = false;
document.removeEventListener('mouseover', onMouseOver, true);
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
document.removeEventListener('click', onDocClickCapture, true);
unsubEditMode?.();
unsubSelected?.();
unsubAgentBlock?.();
unsubCommitted?.();
unsubEditMode = null;
unsubSelected = null;
unsubAgentBlock = null;
unsubCommitted = null;
clearBar();
removeOutline();
};