| 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 |
|