PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / QuickEdit / lib / hover-bar.js

hover-bar.js in Extendify 3.1.0, at src/QuickEdit/lib/hover-bar.js

728 lines 27.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Plain-DOM (not React) — mouseover-driven, runs outside the React
2 // commit cycle to avoid dropped clicks under fast pointer movement.
3 import { __ } from '@wordpress/i18n';
4 import { useEditModeStore } from '../state/edit-mode';
5 import { useQuickEditStore } from '../state/store';
6 import { isAgentEligibleForTarget } from './agent-gate';
7 import {
8 askAiAboutElement,
9 hasAgentBlockSelected,
10 isAgentAvailable,
11 isAgentSidebarOpen,
12 stageAgentBlock,
13 subscribeToAgentBlock,
14 } from './ask-ai';
15 import { prefetchBlockSource } from './block-source-cache';
16 import { decideClickAction } from './click-rule';
17 import { resolveTarget } from './dom';
18 import { track } from './insights';
19 import { hasQuickEditModalFor } from './quick-edit-handlers';
20 import { hasSaver, saveSelected } from './save-bridge';
21 import {
22 getTranslatedContext,
23 isTextBearing,
24 isTranslatedRender,
25 translatedNoticeMessage,
26 } from './translated';
27
28 let hoverTarget = null;
29 let hoverBar = null;
30 let hoverOutline = null; // body-level positioned div, see ensureOutline()
31 let attached = false;
32
33 const debugLog = (label, el) => {
34 if (!window.extQuickEditData?.debug) return;
35 console.groupCollapsed(`[qe-debug] hover-bar: ${label}`);
36 if (el) console.log('target:', el);
37 console.trace();
38 console.groupEnd();
39 };
40
41 // Body-level fixed overlay rather than an outline on each block:
42 // outline overhang would clip inside an ancestor's overflow:hidden,
43 // and a single repositioning overlay gets the smooth-expand feel
44 // for free via CSS transitions.
45 const ensureOutline = () => {
46 if (hoverOutline) return hoverOutline;
47 hoverOutline = document.createElement('div');
48 hoverOutline.className =
49 'extendify-quick-edit extendify-quick-edit-hover-outline';
50 hoverOutline.setAttribute('aria-hidden', 'true');
51 document.body.appendChild(hoverOutline);
52 return hoverOutline;
53 };
54
55 // `instant` skips the 0.2s CSS transition. Scroll-driven updates can't
56 // afford to animate: every scroll event would reset the transition target
57 // while the outline is still en route, so the outline visibly trails the
58 // content during a drag-scroll ("stays fixed in screen"). Hover-driven
59 // updates (block A → block B) keep the spring animation.
60 const showOutline = (el, { instant = false } = {}) => {
61 const overlay = ensureOutline();
62 if (instant) {
63 overlay.style.transition = 'none';
64 } else if (overlay.style.transition === 'none') {
65 overlay.style.transition = '';
66 }
67 const r = el.getBoundingClientRect();
68 overlay.style.top = `${r.top}px`;
69 overlay.style.left = `${r.left}px`;
70 overlay.style.width = `${r.width}px`;
71 overlay.style.height = `${r.height}px`;
72 overlay.classList.add('is-visible');
73 debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
74 };
75
76 const hideOutline = () => {
77 hoverOutline?.classList.remove('is-visible');
78 debugLog('hideOutline');
79 };
80
81 const removeOutline = () => {
82 hoverOutline?.remove();
83 hoverOutline = null;
84 };
85
86 const POST_ATTR = 'data-extendify-agent-block-id';
87 const PART_ATTR = 'data-extendify-part-block-id';
88 const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
89 const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
90 const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
91
92 // Resolve the live DOM node for the currently-staged agent block, so the
93 // click + hover gates can carve out "inside the staged block." Returns
94 // null when no block is staged or its node has detached from the tree.
95 const stagedBlockEl = () => {
96 const block = useQuickEditStore.getState().agentBlock;
97 if (!block?.id) return null;
98 const attr = block.target || POST_ATTR;
99 return document.querySelector(`[${attr}="${CSS.escape(String(block.id))}"]`);
100 };
101
102 // Resolve the committed selection's live DOM node. buildTarget stashes
103 // the element reference on the descriptor; if the block has been swapped
104 // out (e.g. by an agent workflow) we treat the commit as gone.
105 const committedBlockEl = () => {
106 const sel = useQuickEditStore.getState().committedSelection;
107 if (!sel?.el || !document.body.contains(sel.el)) return null;
108 return sel.el;
109 };
110
111 // Tries above first, falls back to below or inside. Placement is
112 // stored on the dataset so CSS can extend the bar's hover area via
113 // a ::before bridge in the right direction.
114 const positionBar = (bar, el) => {
115 const rect = el.getBoundingClientRect();
116 const bw = bar.offsetWidth;
117 const bh = bar.offsetHeight || 36;
118 const gap = 8;
119 const vw = document.documentElement.clientWidth;
120 const vh = document.documentElement.clientHeight;
121 const adminBarH = document.getElementById('wpadminbar')?.offsetHeight ?? 0;
122 const minTop = adminBarH + 4;
123
124 let left = rect.left + (rect.width - bw) / 2;
125 left = Math.max(4, Math.min(left, vw - bw - 4));
126
127 let top = rect.top - bh - gap;
128 let placement = 'above';
129 if (top < minTop) {
130 const below = rect.bottom + gap;
131 const belowFits = below + bh + 4 <= vh;
132 const visibleHeight = Math.min(rect.bottom, vh) - Math.max(rect.top, 0);
133 const dominantBlock = visibleHeight > vh * 0.7;
134 if (belowFits && !dominantBlock) {
135 top = below;
136 placement = 'below';
137 } else {
138 top = Math.max(minTop, rect.top + 4);
139 placement = 'inside';
140 }
141 }
142 bar.style.top = `${top}px`;
143 bar.style.left = `${left}px`;
144 bar.dataset.extendifyQuickEditPlacement = placement;
145 };
146
147 let translatedErrorEl = null;
148 let translatedErrorTimer = 0;
149
150 const clearTranslatedError = () => {
151 if (translatedErrorTimer) {
152 window.clearTimeout(translatedErrorTimer);
153 translatedErrorTimer = 0;
154 }
155 translatedErrorEl?.remove();
156 translatedErrorEl = null;
157 };
158
159 // Body-level notice anchored just under the hover bar, so it reads as "this
160 // block" and stays visible even with the Agent sidebar open (a top-right pill
161 // hides behind it). Inline-styled like the canvas ErrorPill — the bar lives
162 // outside the prefix-scoped stylesheet, so utility classes wouldn't reach it.
163 const showTranslatedError = (bar) => {
164 clearTranslatedError();
165 const el = document.createElement('div');
166 el.className = 'extendify-quick-edit-translated-error';
167 el.setAttribute('role', 'alert');
168 el.textContent = translatedNoticeMessage(getTranslatedContext()?.plugin);
169 const r = bar.getBoundingClientRect();
170 Object.assign(el.style, {
171 position: 'fixed',
172 zIndex: '100001',
173 maxWidth: '300px',
174 padding: '8px 12px',
175 borderRadius: '8px',
176 background: '#fee2e2',
177 color: '#991b1b',
178 fontSize: '13px',
179 lineHeight: '1.4',
180 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
181 left: `${Math.max(4, r.left)}px`,
182 top: `${r.bottom + 6}px`,
183 });
184 document.body.appendChild(el);
185 translatedErrorEl = el;
186 translatedErrorTimer = window.setTimeout(clearTranslatedError, 6000);
187 };
188
189 const clearBar = () => {
190 if (hoverBar) {
191 hoverBar.remove();
192 hoverBar = null;
193 }
194 hoverTarget = null;
195 hideOutline();
196 clearTranslatedError();
197 };
198
199 // Walk past tagged-but-unsupported ancestors (e.g. core/post-title inside
200 // a cover's inner-container) so the bar resolves to the nearest editable
201 // parent. Without this, hovering the middle of a hero cover that
202 // surfaces post-title returned blockType=null and — combined with the
203 // template-part source gating Ask AI off — produced no bar at all.
204 const buildTarget = (el) => {
205 let current = resolveTarget(el);
206 let safety = 5;
207 while (
208 current &&
209 !current.blockType &&
210 current.el?.parentElement &&
211 safety-- > 0
212 ) {
213 const next = resolveTarget(current.el.parentElement);
214 if (!next) return current;
215 current = next;
216 }
217 return current;
218 };
219
220 // Picker blocks keep the bar visible because the dropdown anchors
221 // to it; text edits tear it down so the inline toolbar can replace
222 // it. Keep aligned with PICKER_STRATEGIES in components/InlineEditor.jsx.
223 const isPickerType = (blockType) =>
224 blockType === 'core/image' ||
225 blockType === 'core/cover' ||
226 blockType === 'core/media-text:image' ||
227 blockType === 'product:image';
228
229 // Translated text blocks have no editor — Quick Edit writes the source
230 // post_content while the screen shows the translation. We never commit a
231 // selection for them (it would render nothing and the unsubSelected cancel-on-
232 // clear logic would tear down a co-staged Ask AI block); the bar's Quick Edit
233 // pill shows the error inline instead, and Ask AI stays reachable.
234 const isTranslatedTextBlock = (target) =>
235 isTranslatedRender() && isTextBearing(target?.blockType);
236
237 // Which pills a target would surface (without mounting the bar). Click rule
238 // (Option 7) needs this to decide between opening QE directly, today's
239 // sticky commit, and the silent agent stage. Exported so keyboard-entry
240 // gates Enter on the same signal the hover bar uses.
241 export const pillContextFor = (target) => {
242 const quickEditEnabled = !!window.extQuickEditData?.quickEditEnabled;
243 const quickEditable =
244 quickEditEnabled && hasQuickEditModalFor(target?.blockType);
245 const sourceKind = target?.source?.kind ?? null;
246 const agentSupportedSource = sourceKind === 'post' || sourceKind === null;
247 const aiAvailable =
248 isAgentAvailable() &&
249 agentSupportedSource &&
250 isAgentEligibleForTarget(target);
251 return { quickEditable, aiAvailable };
252 };
253
254 // Exported for keyboard-entry to bypass the bar's click handler.
255 export const editTarget = (target) => onEditClick(target);
256
257 const onEditClick = (target) => {
258 const store = useQuickEditStore.getState();
259
260 if (store.selected?.el === target.el) {
261 store.setSelected(null);
262 store.setCommittedSelection(null);
263 clearBar();
264 return;
265 }
266
267 // Translated text has no editor — leave the bar in place (its Quick Edit
268 // pill shows the error) and don't commit a selection that renders nothing
269 // and would cancel a co-staged Ask AI block on clear.
270 if (isTranslatedTextBlock(target)) return;
271
272 // Snapshot the bar rect before clearing — ImagePicker anchors to it.
273 const anchorRect = hoverBar?.getBoundingClientRect() ?? null;
274 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
275
276 if (!isPickerType(target.blockType)) clearBar();
277 store.setCommittedSelection(null);
278 store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
279 track('quick_edit_clicked', { blockType: target.blockType });
280 };
281
282 const onAiClick = (el) => {
283 // clearSelected before clearBar: when QE was clicked first on a
284 // picker-type block (image / cover), `selected` is set and the
285 // InlineEditor renders an ImagePicker dropdown anchored to the bar.
286 // clearBar removes the bar but leaves the dropdown mounted as an
287 // orphan; clearing `selected` first unmounts the InlineEditor too.
288 const store = useQuickEditStore.getState();
289 store.clearSelected();
290 clearBar();
291 store.setCommittedSelection(null);
292 track('ask_ai_clicked', { matched: !!resolveTarget(el)?.blockType });
293 askAiAboutElement(el);
294 };
295
296 // Exported for keyboard-entry to route Enter on an Ask-AI-only block
297 // straight to the agent, mirroring the Ask AI pill's click handler.
298 export const askAiTarget = (el) => onAiClick(el);
299
300 // Exported for keyboard-entry's focus-driven mount/dismiss.
301 export const showBar = (el) => renderBar(el);
302 export const hideBar = () => clearBar();
303
304 const renderBar = (el) => {
305 // While an agent block is staged, the hover bar is intentionally
306 // hidden — only DOMHighlighter's X-close indicator is shown.
307 // Defense in depth for any caller (a re-render, the keyboard
308 // entry's showBar) that might otherwise paint a stale bar.
309 if (hasAgentBlockSelected()) return;
310
311 const target = buildTarget(el);
312 const { quickEditable, aiAvailable } = pillContextFor(target);
313 // Bail BEFORE clearing the current bar — when the cursor traverses
314 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
315 // tagged group with too many inner tagged blocks, or a tagged
316 // post-title walked up to from inside), the previous behavior was
317 // to clearBar() first and then bail, leaving the user with no bar
318 // at all. Round-5 regression: cursor passing under the bar gap on
319 // the way to a pill could land on the ancestor before reaching the
320 // pill itself. Keep the existing bar in place if the new candidate
321 // has nothing to render.
322 if (!quickEditable && !aiAvailable) return;
323
324 clearBar();
325
326 // Position around the resolved target (which may be a walked-up
327 // ancestor), not the original DOM node we entered on. media-text's
328 // image is a child <figure> (mediaEl) of the block element — anchor the
329 // outline + bar to it so the selector hugs the image, while Quick Edit
330 // and Ask AI still act on the block element.
331 const positionEl = target?.el ?? el;
332 const anchorEl = target?.mediaEl ?? positionEl;
333 hoverTarget = anchorEl;
334 showOutline(anchorEl);
335
336 // Prefetch source markup so BlockTextEditor's load effect hits the cache.
337 // No-ops for sources the cache doesn't load (product/wpforms/nav).
338 prefetchBlockSource(target?.source, target?.blockId);
339
340 const bar = document.createElement('div');
341 bar.className = 'extendify-quick-edit extendify-quick-edit-bar';
342 bar.setAttribute('data-extendify-quick-edit-bar', '');
343 // preventDefault on mousedown so clicks don't blur a contenteditable
344 // in another open editor.
345 const stopMouseDown = (ev) => ev.preventDefault();
346 // Forward wheel events to the page scroller. The bar (and its
347 // ::before hover bridge) sits over page content; with bar
348 // pointer-events: auto, real-mouse wheel-scrolling stalled on the
349 // bar in production. An earlier attempt at a CSS-only fix
350 // (pointer-events: none on the wrapper) broke hover-
351 // traversal block→pill. Restore pointer-events: auto
352 // and own scroll in JS instead — gives us both behaviors.
353 bar.addEventListener(
354 'wheel',
355 (ev) => {
356 window.scrollBy({ left: ev.deltaX, top: ev.deltaY });
357 ev.preventDefault();
358 },
359 { passive: false },
360 );
361
362 if (quickEditable) {
363 const editBtn = document.createElement('button');
364 editBtn.type = 'button';
365 editBtn.className = 'extendify-quick-edit-pill';
366 editBtn.setAttribute('data-extendify-quick-edit-pill', '');
367 editBtn.innerHTML = '<span aria-hidden="true">✎</span>';
368 editBtn.append(__('Quick Edit', 'extendify-local'));
369 editBtn.addEventListener('mousedown', stopMouseDown);
370 editBtn.addEventListener('click', (ev) => {
371 ev.preventDefault();
372 ev.stopPropagation();
373 // Translated text can't be edited (we'd overwrite the source) — show
374 // the error right under the bar and leave the bar (with Ask AI) in
375 // place rather than opening a canvas.
376 if (isTranslatedTextBlock(target)) {
377 showTranslatedError(bar);
378 return;
379 }
380 onEditClick(target);
381 });
382 bar.appendChild(editBtn);
383 }
384
385 if (aiAvailable) {
386 const aiBtn = document.createElement('button');
387 aiBtn.type = 'button';
388 aiBtn.className = 'extendify-quick-edit-pill extendify-quick-edit-pill-ai';
389 aiBtn.setAttribute('data-extendify-quick-edit-pill', '');
390 aiBtn.innerHTML = '<span aria-hidden="true">✦</span>';
391 aiBtn.append(__('Ask AI', 'extendify-local'));
392 aiBtn.addEventListener('mousedown', stopMouseDown);
393 aiBtn.addEventListener('click', (ev) => {
394 ev.preventDefault();
395 ev.stopPropagation();
396 onAiClick(positionEl);
397 });
398 bar.appendChild(aiBtn);
399 }
400
401 document.body.appendChild(bar);
402 hoverBar = bar;
403 positionBar(bar, anchorEl);
404 };
405
406 // Walk up to the innermost tagged ancestor. resolveTarget then derives
407 // blockType from that element's wp-block-* class; the pill renderer
408 // decides which pills (if any) to show. Lighter than resolveTarget —
409 // onMouseOver hot path doesn't need the full descriptor.
410 const findTagged = (start) => {
411 let node = start;
412 while (node && node.nodeType === 1 && node !== document.body) {
413 if (
414 node.hasAttribute?.(POST_ATTR) ||
415 node.hasAttribute?.(PART_ATTR) ||
416 node.hasAttribute?.(PRODUCT_ATTR) ||
417 node.hasAttribute?.(WPFORM_FIELD_ATTR) ||
418 node.hasAttribute?.(MEDIATEXT_MEDIA_ATTR)
419 ) {
420 return node;
421 }
422 node = node.parentElement;
423 }
424 return null;
425 };
426
427 const onMouseOver = (e) => {
428 if (!useEditModeStore.getState().on) return;
429 if (useQuickEditStore.getState().selected) return;
430 // Sticky modes hard-suppress all hover-driven bar movement.
431 // - agentBlock staged: the bar is intentionally hidden; only
432 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
433 // same block, the user clicks X-close (clears agentBlock) then
434 // re-hovers / re-clicks.
435 // - committedSelection: the bar is pinned to the committed element.
436 // Hover anywhere else — including tagged inner blocks of a
437 // committed container — leaves the bar where it is. To select a
438 // different block, the user clicks outside or presses Esc first.
439 if (hasAgentBlockSelected()) return;
440 if (useQuickEditStore.getState().committedSelection) return;
441 if (hoverBar && (e.target === hoverBar || hoverBar.contains(e.target))) {
442 return;
443 }
444 const el = findTagged(e.target);
445 if (el === hoverTarget) return;
446 if (!el) return;
447 renderBar(el);
448 };
449
450 const onScrollOrResize = () => {
451 if (!hoverTarget) return;
452 if (hoverBar) positionBar(hoverBar, hoverTarget);
453 if (hoverOutline?.classList.contains('is-visible')) {
454 showOutline(hoverTarget, { instant: true });
455 }
456 };
457
458 // Capture-phase so we win against any underlying handler (link nav,
459 // contact form submit, theme JS) — we either commit the click as a
460 // selection, let it through, or clear the bar. Decision per `decideClickAction`.
461 // The hover bar itself is in this list so clicks on it (the pills) bail
462 // before the committed-selection clear branch fires — pill handlers run on
463 // bubble and need the bar to still be in the DOM.
464 //
465 // WP popovers (LinkControl + format-toolbar in the canvas) get explicit
466 // entries too: the popover may end up portaled inside a tagged ancestor
467 // (BlockTools' Popover.Slot lives inside our canvas, which can be a
468 // descendant of `[data-extendify-agent-block-id]`). Without these
469 // entries, the capture handler routes the click to the tagged ancestor
470 // via decideClickAction's `select` branch, preventDefault eats the
471 // click, and the URL input never focuses.
472 const QE_INTERIOR = [
473 '.extendify-quick-edit-bar',
474 '.extendify-quick-edit-canvas',
475 '.extendify-quick-edit-floating-bar',
476 '.extendify-quick-edit-image-menu',
477 '.extendify-quick-edit-modal',
478 '.extendify-quick-edit-modal-root',
479 '.block-editor-link-control',
480 '.components-popover',
481 '#extendify-agent-main',
482 '#extendify-agent-dom-mount',
483 '#wpadminbar',
484 // The wp.media library (the Agent's "Change image" picker and QE's own
485 // image flows). Without this, clicking an image in the grid reads as an
486 // outside-click: it clears the staged agentBlock and cancels the in-flight
487 // agent workflow, unmounting the picker's confirm component and orphaning
488 // the modal as a stuck white overlay.
489 '.media-modal',
490 '.media-modal-backdrop',
491 ].join(', ');
492
493 const onDocClickCapture = (e) => {
494 if (!useEditModeStore.getState().on) return;
495 if (e.target?.closest?.(QE_INTERIOR)) return;
496
497 // Implicit close on the QE text-edit canvas: clicks outside the canvas
498 // while it's open save the in-flight edits instead of discarding them.
499 // `alsoClear: false` only when the click will open QE on a different
500 // block (the `select` branch's `quickEditable` cell); otherwise save
501 // clears so the canvas unmounts. Without that distinction the click
502 // would race: save's `clearSelected(null)` would overwrite the new
503 // block's `setSelected(B)`. `hasSaver()` is false for picker blocks
504 // (image / cover) — they save synchronously on pick and never
505 // register. Fall through either way so the existing agentBlock-clear +
506 // clear-bar branches still run.
507 if (hasSaver() && useQuickEditStore.getState().selected) {
508 const tagged = findTagged(e.target);
509 const willOpenQE =
510 !!tagged && hasQuickEditModalFor(buildTarget(tagged)?.blockType);
511 saveSelected({ alsoClear: !willOpenQE });
512 }
513
514 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
515 // the staged block route natively (anchor navigates, form control
516 // focuses, text-content click is a no-op) — EXCEPT when they land on
517 // a tagged descendant block, in which case the same gesture swaps
518 // the stage onto the descendant (drill-in parity with the cross-
519 // sibling swap below). Clicks OUTSIDE clear the staged block —
520 // sidebar stays open. The asymmetry is intentional: closing the
521 // sidebar still cascades to clearing the block (handled in
522 // Agent.jsx), but clearing the block here does NOT close the
523 // sidebar.
524 if (hasAgentBlockSelected()) {
525 const staged = stagedBlockEl();
526 if (staged?.contains(e.target)) {
527 const innerTagged = findTagged(e.target);
528 if (!innerTagged || innerTagged === staged) return;
529 }
530 useQuickEditStore.setState({ agentBlock: null, agentBlockCode: null });
531 // Fall through to decideClickAction only when the click lands on a
532 // tagged block (sibling or descendant) — the cross-block gesture
533 // transitions both surfaces (QE + agent re-stage) in one click.
534 // Whitespace / non-tagged outside-clicks return here: the same
535 // gesture that clears the staged block shouldn't commit a new
536 // selection out of empty space.
537 if (!findTagged(e.target)) return;
538 }
539
540 // Sticky pre-pill-action selection: a prior click committed a block.
541 // Inside-clicks route natively (anchor / form control) — EXCEPT when
542 // they land on a tagged descendant, which swaps the commit onto the
543 // descendant in the same gesture. Outside-clicks clear the commit; if
544 // the same click also lands on a different tagged block, the switch
545 // below commits it in the same gesture so a single click swaps the
546 // selection. Pills bail above via QE_INTERIOR so they aren't treated
547 // as outside-clicks.
548 if (useQuickEditStore.getState().committedSelection) {
549 const committedEl = committedBlockEl();
550 if (committedEl?.contains(e.target)) {
551 const innerTagged = findTagged(e.target);
552 if (!innerTagged || innerTagged === committedEl) return;
553 }
554 useQuickEditStore.getState().setCommittedSelection(null);
555 clearBar();
556 }
557
558 const result = decideClickAction(e.target);
559 switch (result.action) {
560 case 'select': {
561 e.preventDefault();
562 e.stopPropagation();
563 // stopPropagation above blocks ImagePicker's bubble-phase
564 // outside-click — without this clear its menu lingers (issue 19).
565 const store = useQuickEditStore.getState();
566 if (
567 store.selected &&
568 isPickerType(store.selected.blockType) &&
569 store.selected.el !== result.el
570 ) {
571 store.clearSelected();
572 }
573 const target = buildTarget(result.el);
574 const { quickEditable, aiAvailable } = pillContextFor(target);
575
576 // Click semantics by pill count + agent-open state:
577 // QE-only → open QE menu directly (collapsed gesture).
578 // AI-only + closed → today's sticky commit (the one path that
579 // keeps committedSelection alive).
580 // AI-only + open → silently stage agentBlock (bridge).
581 // Both pills → open QE menu directly; bridge agentBlock
582 // too when the agent sidebar is open. The
583 // Ask AI button now lives on the QE bar
584 // chrome (BlockTextEditor.jsx), so the
585 // collapsed click no longer hides Ask AI.
586 // Picker-type blocks (image, cover) are
587 // exempt from the silent stage — the
588 // hover bar stays mounted for them and
589 // keeps the Ask AI pill, so the user
590 // escalates explicitly rather than seeing
591 // both the picker dropdown AND the
592 // agent's X-close at once.
593 // Tagged but neither → clear (no outline on a block the user
594 // can't act on).
595 if (quickEditable) {
596 renderBar(result.el);
597 onEditClick(target);
598 if (
599 aiAvailable &&
600 isAgentSidebarOpen() &&
601 !isPickerType(target.blockType)
602 ) {
603 stageAgentBlock(result.el);
604 }
605 return;
606 }
607 if (aiAvailable && isAgentSidebarOpen()) {
608 useQuickEditStore.getState().setCommittedSelection(null);
609 clearBar();
610 stageAgentBlock(result.el);
611 return;
612 }
613 if (aiAvailable) {
614 useQuickEditStore.getState().setCommittedSelection(target);
615 renderBar(result.el);
616 return;
617 }
618 clearBar();
619 return;
620 }
621 case 'clear':
622 clearBar();
623 return;
624 default:
625 return;
626 }
627 };
628
629 let unsubEditMode = null;
630 let unsubSelected = null;
631 let unsubAgentBlock = null;
632 let unsubCommitted = null;
633
634 export const attach = () => {
635 if (attached) return;
636 attached = true;
637 document.addEventListener('mouseover', onMouseOver, true);
638 window.addEventListener('scroll', onScrollOrResize, true);
639 window.addEventListener('resize', onScrollOrResize);
640 document.addEventListener('click', onDocClickCapture, true);
641 // Warm the agent-sidebar state cache so the sync click rule has fresh
642 // state by the time the user clicks. The dynamic import resolves on
643 // the microtask queue; user clicks are seconds-later in real use.
644 isAgentSidebarOpen();
645
646 unsubEditMode = useEditModeStore.subscribe((state) => {
647 if (!state.on) {
648 useQuickEditStore.getState().setCommittedSelection(null);
649 clearBar();
650 }
651 });
652 // committedSelection → null transition: Esc / programmatic clears
653 // don't go through onDocClickCapture, so they wouldn't otherwise
654 // remove the bar. Fire clearBar here. The outside-click path
655 // already calls clearBar synchronously; this subscriber's clearBar
656 // is idempotent in that case.
657 let lastCommitted = useQuickEditStore.getState().committedSelection;
658 unsubCommitted = useQuickEditStore.subscribe((state) => {
659 const prev = lastCommitted;
660 lastCommitted = state.committedSelection;
661 if (prev && !state.committedSelection) clearBar();
662 });
663 // Picker dropdown anchors to the bar; keep it visible for those.
664 // On the non-null → null transition (Esc / Cancel / Save closes the
665 // canvas) the bar is re-rendered on the previously edited element
666 // without waiting for a mouse-cross — mouseover only fires when the
667 // cursor crosses an element boundary, so a user who Escs without
668 // moving the cursor would otherwise see the bar disappear and stay
669 // gone until they nudged the mouse.
670 let lastSelected = useQuickEditStore.getState().selected;
671 unsubSelected = useQuickEditStore.subscribe((state) => {
672 // The store carries multiple slots (selected / committedSelection /
673 // agentBlock / dirty / error). Without this gate, an unrelated write
674 // like setCommittedSelection(null) in onEditClick would fire this
675 // listener while state.selected was still the prior non-picker
676 // block — clearBar would then tear down the bar that renderBar
677 // just mounted for the new picker target.
678 if (state.selected === lastSelected) return;
679 const prev = lastSelected;
680 lastSelected = state.selected;
681 if (state.selected) {
682 if (!isPickerType(state.selected.blockType)) clearBar();
683 return;
684 }
685 // Canvas closing (Esc / Cancel / Save / programmatic clearSelected)
686 // on the same block the agent is staged on should also clear the
687 // stage — otherwise the dashed outline + X-close indicator linger
688 // after the user dismissed the canvas. The two-pill silent-stage
689 // shape sets both slots from one click, so closing the canvas is
690 // the symmetric "I'm done with this block" gesture.
691 const agentBlock = useQuickEditStore.getState().agentBlock;
692 if (
693 prev?.blockId != null &&
694 agentBlock?.id != null &&
695 String(prev.blockId) === String(agentBlock.id)
696 ) {
697 window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow'));
698 useQuickEditStore.getState().setAgentBlock(null);
699 }
700 if (!prev?.el || !document.body.contains(prev.el)) return;
701 if (!useEditModeStore.getState().on) return;
702 if (hoverTarget === prev.el && hoverBar) return;
703 renderBar(prev.el);
704 });
705 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
706 if (hasBlock) clearBar();
707 });
708 };
709
710 export const detach = () => {
711 if (!attached) return;
712 attached = false;
713 document.removeEventListener('mouseover', onMouseOver, true);
714 window.removeEventListener('scroll', onScrollOrResize, true);
715 window.removeEventListener('resize', onScrollOrResize);
716 document.removeEventListener('click', onDocClickCapture, true);
717 unsubEditMode?.();
718 unsubSelected?.();
719 unsubAgentBlock?.();
720 unsubCommitted?.();
721 unsubEditMode = null;
722 unsubSelected = null;
723 unsubAgentBlock = null;
724 unsubCommitted = null;
725 clearBar();
726 removeOutline();
727 };
728