| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import { usePortal } from '@agent/hooks/usePortal'; |
| 2 | +import { blockCodeQueryArgs } from '@agent/lib/block-code'; | |
| 3 | +import { findBlockEl, scopeOf } from '@agent/lib/block-el'; | |
| 2 | 4 | import { useWorkflowStore } from '@agent/state/workflows'; |
| 5 | +import { whenAnimationsSettle } from '@quick-edit/lib/after-animations'; | |
| 6 | +import { MEDIA_RING, needsContrastRing } from '@quick-edit/lib/over-media'; | |
| 7 | +import { useQuickEditStore } from '@quick-edit/state/store'; | |
| 3 | 8 | import apiFetch from '@wordpress/api-fetch'; |
| 4 | 9 | import { |
| 5 | 10 | createPortal, |
| 6 | 11 | useCallback, |
| @@ -10,26 +15,31 @@ | ||
| 10 | 15 | } from '@wordpress/element'; |
| 11 | 16 | import { __ } from '@wordpress/i18n'; |
| 12 | 17 | import { close, Icon } from '@wordpress/icons'; |
| 13 | 18 | import { addQueryArgs } from '@wordpress/url'; |
| 19 | +import classNames from 'classnames'; | |
| 14 | 20 | import { motion } from 'framer-motion'; |
| 15 | 21 | |
| 16 | -const selector = [ | |
| 17 | - '[data-extendify-agent-block-id]', | |
| 18 | - '[data-extendify-part-block-id]', | |
| 19 | - '.wp-block-navigation', | |
| 20 | -].join(', '); | |
| 21 | -const ignored = ['wp-block-video', 'wp-block-spacer', 'wp-block-post-*']; | |
| 22 | -const SELECTED_ATTR = 'data-extendify-agent-block-selected'; | |
| 23 | -const HIGHLIGHTER_CLS = 'extendify-agent-highlighter-mode'; | |
| 22 | +const MIN_OUTLINE_SIZE = 10; | |
| 24 | 23 | |
| 25 | -export const DOMHighlighter = ({ busy = false }) => { | |
| 24 | +// Render-only after the selector unification: `agentBlock` is set | |
| 25 | +// by Quick Edit's Ask AI flow (or future workflows), and this component | |
| 26 | +// draws the outline + X-close indicator. Hover-bar owns hover + click | |
| 27 | +// selection on the live page; DOMHighlighter no longer listens for either. | |
| 28 | +export const DOMHighlighter = ({ | |
| 29 | + busy = false, | |
| 30 | + working = false, | |
| 31 | + waiting = false, | |
| 32 | + taskActive = false, | |
| 33 | +}) => { | |
| 26 | 34 | const [rect, setRect] = useState(null); |
| 35 | + const [ringNeeded, setRingNeeded] = useState(false); | |
| 27 | 36 | const mountNode = usePortal('extendify-agent-dom-mount'); |
| 28 | - const raf = useRef(null); | |
| 29 | 37 | const el = useRef(null); |
| 30 | - const { getWorkflowsByFeature, block, setBlock, setBlockCode } = | |
| 31 | - useWorkflowStore(); | |
| 38 | + const { getWorkflowsByFeature } = useWorkflowStore(); | |
| 39 | + const block = useQuickEditStore((s) => s.agentBlock); | |
| 40 | + const setBlock = useQuickEditStore((s) => s.setAgentBlock); | |
| 41 | + const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode); | |
| 32 | 42 | const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0; |
| 33 | 43 | |
| 34 | 44 | const clearBlock = useCallback(() => { |
| 35 | 45 | setBlock(null); |
| @@ -34,23 +44,18 @@ | ||
| 34 | 44 | const clearBlock = useCallback(() => { |
| 35 | 45 | setBlock(null); |
| 36 | 46 | setRect(null); |
| 37 | 47 | el.current = null; |
| 38 | - document.querySelector(HIGHLIGHTER_CLS)?.classList.remove(HIGHLIGHTER_CLS); | |
| 39 | - document | |
| 40 | - .querySelector(`[${SELECTED_ATTR}]`) | |
| 41 | - ?.removeAttribute(SELECTED_ATTR); | |
| 42 | 48 | }, [setBlock, setRect]); |
| 43 | 49 | |
| 44 | 50 | useEffect(() => { |
| 45 | 51 | if (!block?.id) return; |
| 46 | 52 | const ac = new AbortController(); |
| 47 | - const postId = window.extAgentData?.context?.postId; | |
| 48 | - if (!postId) return; | |
| 49 | - const queryArgs = { | |
| 50 | - postId: String(postId), | |
| 51 | - blockId: String(block.id), | |
| 52 | - }; | |
| 53 | + const queryArgs = blockCodeQueryArgs( | |
| 54 | + block, | |
| 55 | + window.extAgentData?.context?.postId, | |
| 56 | + ); | |
| 57 | + if (!queryArgs) return; | |
| 53 | 58 | |
| 54 | 59 | const isAlive = { current: true }; |
| 55 | 60 | (async () => { |
| 56 | 61 | const res = await apiFetch({ |
| @@ -65,9 +70,51 @@ | ||
| 65 | 70 | isAlive.current = false; |
| 66 | 71 | }; |
| 67 | 72 | }, [setBlockCode, block]); |
| 68 | 73 | |
| 74 | + // Re-syncs the rect for programmatic block changes (e.g. Ask AI) | |
| 75 | + // and after the wp-site-blocks open/close transform settles. | |
| 69 | 76 | useEffect(() => { |
| 77 | + if (!block?.id) return; | |
| 78 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 79 | + if (!match) return; | |
| 80 | + el.current = match; | |
| 81 | + setRingNeeded(needsContrastRing(match)); | |
| 82 | + | |
| 83 | + const measure = () => { | |
| 84 | + const r = match.getBoundingClientRect(); | |
| 85 | + if (r.width <= 0 || r.height <= 0) return; | |
| 86 | + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); | |
| 87 | + }; | |
| 88 | + measure(); | |
| 89 | + | |
| 90 | + // transitionend covers the panel-open/close transform; the | |
| 91 | + // timeouts are belt-and-braces if transitions are disabled. | |
| 92 | + const wsb = document.querySelector('.wp-site-blocks'); | |
| 93 | + const onTransitionEnd = (e) => { | |
| 94 | + if (e.propertyName === 'transform') measure(); | |
| 95 | + }; | |
| 96 | + wsb?.addEventListener('transitionend', onTransitionEnd); | |
| 97 | + const t1 = window.setTimeout(measure, 80); | |
| 98 | + const t2 = window.setTimeout(measure, 360); | |
| 99 | + const dropSettle = whenAnimationsSettle(match, measure); | |
| 100 | + | |
| 101 | + return () => { | |
| 102 | + wsb?.removeEventListener('transitionend', onTransitionEnd); | |
| 103 | + window.clearTimeout(t1); | |
| 104 | + window.clearTimeout(t2); | |
| 105 | + dropSettle(); | |
| 106 | + }; | |
| 107 | + }, [block]); | |
| 108 | + | |
| 109 | + // The chip's X clears the block without firing the event. | |
| 110 | + useEffect(() => { | |
| 111 | + if (block?.id) return; | |
| 112 | + setRect(null); | |
| 113 | + el.current = null; | |
| 114 | + }, [block]); | |
| 115 | + | |
| 116 | + useEffect(() => { | |
| 70 | 117 | const handle = () => { |
| 71 | 118 | setRect(null); |
| 72 | 119 | el.current = null; |
| 73 | 120 | }; |
| @@ -79,130 +126,61 @@ | ||
| 79 | 126 | ); |
| 80 | 127 | }, []); |
| 81 | 128 | |
| 82 | 129 | useEffect(() => { |
| 83 | - if (busy || block) return; | |
| 84 | - if (!mountNode || !enabled) return setRect(null); | |
| 85 | - | |
| 86 | - const onMove = (e) => { | |
| 87 | - if (raf.current) return; | |
| 88 | - raf.current = requestAnimationFrame(() => { | |
| 89 | - raf.current = null; | |
| 90 | - const target = e.target; | |
| 91 | - | |
| 92 | - if (!target) return setRect(null); | |
| 93 | - const match = target.closest(selector); | |
| 94 | - if (!match) return setRect(null); | |
| 95 | - | |
| 96 | - // Ignore some blocks | |
| 97 | - const pattern = ignored.map((c) => c.replace('*', '.*')).join('|'); | |
| 98 | - const regex = new RegExp(`^(${pattern})$`); | |
| 99 | - if (Array.from(match.classList).some((cls) => regex.test(cls))) { | |
| 100 | - return setRect(null); | |
| 101 | - } | |
| 102 | - | |
| 103 | - const innerBlockCount = Array.from( | |
| 104 | - match.querySelectorAll(selector), | |
| 105 | - ).filter((el) => !ignored.some((c) => el.classList.contains(c))).length; | |
| 106 | - | |
| 107 | - // Manage pattern complexity | |
| 108 | - if (innerBlockCount > 50) return setRect(null); | |
| 109 | - | |
| 110 | - el.current = match; | |
| 111 | - const r = match.getBoundingClientRect(); | |
| 112 | - if (r.width <= 0 || r.height <= 0) return setRect(null); | |
| 113 | - | |
| 114 | - const { top, left, width, height } = r; | |
| 115 | - setRect({ top, left, width, height }); | |
| 116 | - }); | |
| 130 | + if (!block?.id) return; | |
| 131 | + const handle = () => { | |
| 132 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 133 | + // Clearing only the rect leaves the chip counting a gone node. | |
| 134 | + if (!match) { | |
| 135 | + clearBlock(); | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + el.current = match; | |
| 139 | + const r = match.getBoundingClientRect(); | |
| 140 | + if (r.width <= 0 || r.height <= 0) return; | |
| 141 | + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); | |
| 117 | 142 | }; |
| 143 | + window.addEventListener('extendify-agent:refresh-block-highlight', handle); | |
| 144 | + return () => | |
| 145 | + window.removeEventListener( | |
| 146 | + 'extendify-agent:refresh-block-highlight', | |
| 147 | + handle, | |
| 148 | + ); | |
| 149 | + }, [block, clearBlock]); | |
| 118 | 150 | |
| 119 | - window.addEventListener('mousemove', onMove, { passive: true }); | |
| 120 | - return () => { | |
| 121 | - window.removeEventListener('mousemove', onMove); | |
| 122 | - if (raf.current) cancelAnimationFrame(raf.current); | |
| 123 | - }; | |
| 124 | - }, [busy, mountNode, enabled, block]); | |
| 125 | - | |
| 151 | + // Use capture phase for `scroll` so we hear it on any scrollable | |
| 152 | + // ancestor (e.g. wp-site-blocks when something repositions it as | |
| 153 | + // the page scroll container). Bubble-phase `scroll` doesn't | |
| 154 | + // propagate, so a window-only listener misses those. | |
| 126 | 155 | useEffect(() => { |
| 127 | 156 | const onScrollOrResize = () => { |
| 128 | 157 | if (!el.current) return; |
| 129 | 158 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 130 | - setRect({ top, left, width, height }); | |
| 159 | + // Animating this re-targets the spring mid-scroll, so it never lands. | |
| 160 | + setRect({ top, left, width, height, instant: true }); | |
| 131 | 161 | }; |
| 132 | - window.addEventListener('scroll', onScrollOrResize, { passive: true }); | |
| 162 | + window.addEventListener('scroll', onScrollOrResize, { | |
| 163 | + passive: true, | |
| 164 | + capture: true, | |
| 165 | + }); | |
| 133 | 166 | window.addEventListener('resize', onScrollOrResize); |
| 134 | 167 | return () => { |
| 135 | - window.removeEventListener('scroll', onScrollOrResize); | |
| 168 | + window.removeEventListener('scroll', onScrollOrResize, { | |
| 169 | + capture: true, | |
| 170 | + }); | |
| 136 | 171 | window.removeEventListener('resize', onScrollOrResize); |
| 137 | 172 | }; |
| 138 | 173 | }, [el]); |
| 139 | 174 | |
| 140 | 175 | useEffect(() => { |
| 141 | - if (!enabled || busy) return; | |
| 142 | - | |
| 143 | - const onClickCapture = (e) => { | |
| 144 | - if (!rect || busy) return; | |
| 145 | - // If they click inside the chat window, ignore | |
| 146 | - if (e.target.closest('#extendify-agent-chat')) return; | |
| 147 | - // find the real element under cursor | |
| 148 | - const stack = document.elementsFromPoint(e.clientX, e.clientY) || []; | |
| 149 | - if (!stack[0]) return; | |
| 150 | - e.preventDefault(); | |
| 151 | - e.stopPropagation(); | |
| 152 | - | |
| 153 | - const match = stack[0].closest(selector); | |
| 154 | - if (!match && !block) return; | |
| 155 | - const sameBlock = match?.hasAttribute(SELECTED_ATTR); | |
| 156 | - // If we already have a block, clicking outside removes it | |
| 157 | - if (block && !sameBlock) return clearBlock(); | |
| 158 | - if (block && sameBlock) return; // no change | |
| 159 | - | |
| 160 | - match.setAttribute(SELECTED_ATTR, true); | |
| 161 | - document.querySelector('#extendify-agent-chat-textarea')?.focus(); | |
| 162 | - | |
| 163 | - // determine what's in the block. | |
| 164 | - const templatePart = match.closest('[data-extendify-part]'); | |
| 165 | - const details = { | |
| 166 | - id: match.getAttribute('data-extendify-agent-block-id'), | |
| 167 | - target: 'data-extendify-agent-block-id', | |
| 168 | - hasNav: | |
| 169 | - Boolean(match.querySelector('.wp-block-navigation')) || | |
| 170 | - match.classList.contains('wp-block-navigation'), | |
| 171 | - hasSiteTitle: | |
| 172 | - match.classList.contains('wp-block-site-title') || | |
| 173 | - Boolean(match.querySelector('.wp-block-site-title')), | |
| 174 | - hasSiteLogo: | |
| 175 | - match.classList.contains('wp-block-site-logo') || | |
| 176 | - Boolean(match.querySelector('.wp-block-site-logo')), | |
| 177 | - hasLinks: Boolean(match.querySelector('a')) || match.tagName === 'A', | |
| 178 | - hasImages: | |
| 179 | - Boolean(match.querySelector('.wp-block-image')) || | |
| 180 | - match.classList.contains('wp-block-image') || | |
| 181 | - Boolean(match.querySelector('img')), | |
| 182 | - hasText: /\S/.test((match.textContent || '').replace(/\u200B/g, '')), | |
| 183 | - }; | |
| 184 | - // Override how we identify if it's a template part | |
| 185 | - if (templatePart) { | |
| 186 | - details.id = templatePart.getAttribute('data-extendify-part-block-id'); | |
| 187 | - details.target = 'data-extendify-part-block-id'; | |
| 188 | - details.template = templatePart.getAttribute('data-extendify-part'); | |
| 189 | - } | |
| 190 | - setBlock(details); | |
| 191 | - }; | |
| 192 | - | |
| 193 | - // capture=true so we stop clicks before app code or link navigation | |
| 194 | - window.addEventListener('click', onClickCapture, { capture: true }); | |
| 195 | - return () => | |
| 196 | - window.removeEventListener('click', onClickCapture, { capture: true }); | |
| 197 | - }, [enabled, setBlock, rect, clearBlock, block, busy]); | |
| 198 | - | |
| 199 | - useEffect(() => { | |
| 200 | 176 | if (!el.current) return; |
| 201 | 177 | |
| 202 | 178 | const resizeObserver = new ResizeObserver(() => { |
| 203 | 179 | if (!el.current) return; |
| 204 | 180 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 181 | + // A detached node reports 0x0, which draws as a corner dot. | |
| 182 | + if (width <= 0 || height <= 0) return; | |
| 205 | 183 | setRect({ top, left, width, height }); |
| 206 | 184 | }); |
| 207 | 185 | |
| 208 | 186 | resizeObserver.observe(el.current); |
| @@ -211,9 +189,45 @@ | ||
| 211 | 189 | resizeObserver.disconnect(); |
| 212 | 190 | }; |
| 213 | 191 | }, [el.current]); |
| 214 | 192 | |
| 193 | + // ResizeObserver misses a replaced node and an ancestor reflow, so the | |
| 194 | + // outline stays on the old box. | |
| 215 | 195 | useEffect(() => { |
| 196 | + if (!block?.id) return; | |
| 197 | + const root = document.querySelector('.wp-site-blocks'); | |
| 198 | + if (!root) return; | |
| 199 | + | |
| 200 | + let rafId = 0; | |
| 201 | + const observer = new MutationObserver(() => { | |
| 202 | + if (rafId) return; | |
| 203 | + rafId = window.requestAnimationFrame(() => { | |
| 204 | + rafId = 0; | |
| 205 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 206 | + if (!match) return; | |
| 207 | + el.current = match; | |
| 208 | + const r = match.getBoundingClientRect(); | |
| 209 | + if (r.width <= 0 || r.height <= 0) return; | |
| 210 | + setRect({ | |
| 211 | + top: r.top, | |
| 212 | + left: r.left, | |
| 213 | + width: r.width, | |
| 214 | + height: r.height, | |
| 215 | + }); | |
| 216 | + }); | |
| 217 | + }); | |
| 218 | + observer.observe(root, { | |
| 219 | + childList: true, | |
| 220 | + subtree: true, | |
| 221 | + characterData: true, | |
| 222 | + }); | |
| 223 | + return () => { | |
| 224 | + observer.disconnect(); | |
| 225 | + if (rafId) window.cancelAnimationFrame(rafId); | |
| 226 | + }; | |
| 227 | + }, [block]); | |
| 228 | + | |
| 229 | + useEffect(() => { | |
| 216 | 230 | if (!enabled) return; |
| 217 | 231 | const root = document.querySelector('.wp-site-blocks'); |
| 218 | 232 | if (!root) return; |
| 219 | 233 | root.classList.add('extendify-agent-highlighter-mode'); |
| @@ -227,18 +241,52 @@ | ||
| 227 | 241 | root.classList.add('extendify-agent-busy'); |
| 228 | 242 | return () => root.classList.remove('extendify-agent-busy'); |
| 229 | 243 | }, [busy]); |
| 230 | 244 | |
| 245 | + useEffect(() => { | |
| 246 | + if (!taskActive) return; | |
| 247 | + const root = document.querySelector('.wp-site-blocks'); | |
| 248 | + if (!root) return; | |
| 249 | + root.classList.add('extendify-agent-task'); | |
| 250 | + return () => root.classList.remove('extendify-agent-task'); | |
| 251 | + }, [taskActive]); | |
| 252 | + | |
| 253 | + useEffect(() => { | |
| 254 | + if (!waiting) return; | |
| 255 | + const root = document.querySelector('.wp-site-blocks'); | |
| 256 | + if (!root) return; | |
| 257 | + root.classList.add('extendify-agent-waiting'); | |
| 258 | + return () => root.classList.remove('extendify-agent-waiting'); | |
| 259 | + }, [waiting]); | |
| 260 | + | |
| 261 | + useEffect(() => { | |
| 262 | + if (!working) return; | |
| 263 | + const root = document.querySelector('.wp-site-blocks'); | |
| 264 | + if (!root) return; | |
| 265 | + root.classList.add('extendify-agent-working'); | |
| 266 | + return () => root.classList.remove('extendify-agent-working'); | |
| 267 | + }, [working]); | |
| 268 | + | |
| 231 | 269 | if (!enabled || !rect || !mountNode) return null; |
| 232 | 270 | |
| 233 | - const { top, left, width, height } = rect; | |
| 234 | - const animate = { x: left, y: top, width, height, opacity: 1 }; | |
| 235 | - const transition = { | |
| 236 | - type: 'spring', | |
| 237 | - stiffness: 700, | |
| 238 | - damping: 40, | |
| 239 | - mass: 0.25, | |
| 271 | + const { top, left, width, height, instant } = rect; | |
| 272 | + // A separator's box is sub-pixel tall; 4px dashes read as a broken line. | |
| 273 | + const framed = (size) => Math.max(size, MIN_OUTLINE_SIZE); | |
| 274 | + const animate = { | |
| 275 | + x: left - (framed(width) - width) / 2, | |
| 276 | + y: top - (framed(height) - height) / 2, | |
| 277 | + width: framed(width), | |
| 278 | + height: framed(height), | |
| 279 | + opacity: 1, | |
| 240 | 280 | }; |
| 281 | + const transition = instant | |
| 282 | + ? { duration: 0 } | |
| 283 | + : { | |
| 284 | + type: 'spring', | |
| 285 | + stiffness: 700, | |
| 286 | + damping: 40, | |
| 287 | + mass: 0.25, | |
| 288 | + }; | |
| 241 | 289 | return createPortal( |
| 242 | 290 | <> |
| 243 | 291 | {block && !busy ? ( |
| 244 | 292 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| @@ -243,14 +291,15 @@ | ||
| 243 | 291 | {block && !busy ? ( |
| 244 | 292 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| 245 | 293 | <div |
| 246 | 294 | role="button" |
| 247 | - className={ | |
| 248 | - 'fixed z-9 h-6 w-6 -translate-y-3.5 cursor-pointer select-none flex items-center justify-center rounded-full text-center font-bold ring-1 ring-black' | |
| 249 | - } | |
| 295 | + className={classNames( | |
| 296 | + 'fixed z-9 h-6 w-6 -translate-y-3.5 cursor-pointer select-none flex items-center justify-center rounded-full text-center font-bold', | |
| 297 | + { 'ring-1 ring-white/20': ringNeeded }, | |
| 298 | + )} | |
| 250 | 299 | tabIndex={0} |
| 251 | - onClick={() => setBlock(null)} | |
| 252 | - onKeyDown={() => setBlock(null)} | |
| 300 | + onClick={clearBlock} | |
| 301 | + onKeyDown={clearBlock} | |
| 253 | 302 | style={{ |
| 254 | 303 | top, |
| 255 | 304 | left: width / 2 + left - 12, |
| 256 | 305 | backgroundColor: 'var(--wp--preset--color--primary, red)', |
| @@ -271,15 +320,17 @@ | ||
| 271 | 320 | initial={false} |
| 272 | 321 | aria-hidden |
| 273 | 322 | animate={animate} |
| 274 | 323 | transition={transition} |
| 275 | - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4" | |
| 324 | + className="fixed z-8 outline-dashed outline-4" | |
| 276 | 325 | style={{ |
| 277 | 326 | top: 0, |
| 278 | 327 | left: 0, |
| 279 | 328 | willChange: 'transform,width,height,opacity', |
| 280 | 329 | outlineColor: 'var(--wp--preset--color--primary, red)', |
| 281 | - pointerEvents: block && !busy ? 'auto' : 'none', | |
| 330 | + boxShadow: ringNeeded ? MEDIA_RING : undefined, | |
| 331 | + // This mount sits outside the scroller; 'auto' eats page scroll. | |
| 332 | + pointerEvents: 'none', | |
| 282 | 333 | }} |
| 283 | 334 | /> |
| 284 | 335 | </>, |
| 285 | 336 | mountNode, |