PluginProbe
Extendify / 3.1.2
Extendify v3.1.2
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 0.7.0 All 126 releases
extendify / src / QuickEdit / lib / hover-bar.js

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

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