PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.5, at src/QuickEdit/lib/hover-bar.js

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