| @@ -1,6 +1,10 @@ | ||
| 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'; | |
| 3 | 7 | import { useQuickEditStore } from '@quick-edit/state/store'; |
| 4 | 8 | import apiFetch from '@wordpress/api-fetch'; |
| 5 | 9 | import { |
| 6 | 10 | createPortal, |
| @@ -11,31 +15,32 @@ | ||
| 11 | 15 | } from '@wordpress/element'; |
| 12 | 16 | import { __ } from '@wordpress/i18n'; |
| 13 | 17 | import { close, Icon } from '@wordpress/icons'; |
| 14 | 18 | import { addQueryArgs } from '@wordpress/url'; |
| 19 | +import classNames from 'classnames'; | |
| 15 | 20 | import { motion } from 'framer-motion'; |
| 16 | 21 | |
| 22 | +const MIN_OUTLINE_SIZE = 10; | |
| 23 | + | |
| 17 | 24 | // Render-only after the selector unification: `agentBlock` is set |
| 18 | 25 | // by Quick Edit's Ask AI flow (or future workflows), and this component |
| 19 | 26 | // draws the outline + X-close indicator. Hover-bar owns hover + click |
| 20 | 27 | // selection on the live page; DOMHighlighter no longer listens for either. |
| 21 | -export const DOMHighlighter = ({ busy = false }) => { | |
| 28 | +export const DOMHighlighter = ({ | |
| 29 | + busy = false, | |
| 30 | + working = false, | |
| 31 | + waiting = false, | |
| 32 | + taskActive = false, | |
| 33 | +}) => { | |
| 22 | 34 | const [rect, setRect] = useState(null); |
| 35 | + const [ringNeeded, setRingNeeded] = useState(false); | |
| 23 | 36 | const mountNode = usePortal('extendify-agent-dom-mount'); |
| 24 | 37 | const el = useRef(null); |
| 25 | 38 | const { getWorkflowsByFeature } = useWorkflowStore(); |
| 26 | 39 | const block = useQuickEditStore((s) => s.agentBlock); |
| 27 | - const selected = useQuickEditStore((s) => s.selected); | |
| 28 | 40 | const setBlock = useQuickEditStore((s) => s.setAgentBlock); |
| 29 | 41 | const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode); |
| 30 | 42 | const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0; |
| 31 | - // When the QE canvas is mounted on the same block the agent is staged | |
| 32 | - // on, this overlay must NOT intercept clicks — otherwise text-selection | |
| 33 | - // inside the contenteditable underneath is eaten by the outline. | |
| 34 | - const sameBlockAsQE = | |
| 35 | - selected?.blockId != null && | |
| 36 | - block?.id != null && | |
| 37 | - String(selected.blockId) === String(block.id); | |
| 38 | 43 | |
| 39 | 44 | const clearBlock = useCallback(() => { |
| 40 | 45 | setBlock(null); |
| 41 | 46 | setRect(null); |
| @@ -44,14 +49,13 @@ | ||
| 44 | 49 | |
| 45 | 50 | useEffect(() => { |
| 46 | 51 | if (!block?.id) return; |
| 47 | 52 | const ac = new AbortController(); |
| 48 | - const postId = window.extAgentData?.context?.postId; | |
| 49 | - if (!postId) return; | |
| 50 | - const queryArgs = { | |
| 51 | - postId: String(postId), | |
| 52 | - blockId: String(block.id), | |
| 53 | - }; | |
| 53 | + const queryArgs = blockCodeQueryArgs( | |
| 54 | + block, | |
| 55 | + window.extAgentData?.context?.postId, | |
| 56 | + ); | |
| 57 | + if (!queryArgs) return; | |
| 54 | 58 | |
| 55 | 59 | const isAlive = { current: true }; |
| 56 | 60 | (async () => { |
| 57 | 61 | const res = await apiFetch({ |
| @@ -70,14 +74,12 @@ | ||
| 70 | 74 | // Re-syncs the rect for programmatic block changes (e.g. Ask AI) |
| 71 | 75 | // and after the wp-site-blocks open/close transform settles. |
| 72 | 76 | useEffect(() => { |
| 73 | 77 | if (!block?.id) return; |
| 74 | - const attr = block.target || 'data-extendify-agent-block-id'; | |
| 75 | - const match = document.querySelector( | |
| 76 | - `[${attr}="${CSS.escape(String(block.id))}"]`, | |
| 77 | - ); | |
| 78 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 78 | 79 | if (!match) return; |
| 79 | 80 | el.current = match; |
| 81 | + setRingNeeded(needsContrastRing(match)); | |
| 80 | 82 | |
| 81 | 83 | const measure = () => { |
| 82 | 84 | const r = match.getBoundingClientRect(); |
| 83 | 85 | if (r.width <= 0 || r.height <= 0) return; |
| @@ -93,17 +95,26 @@ | ||
| 93 | 95 | }; |
| 94 | 96 | wsb?.addEventListener('transitionend', onTransitionEnd); |
| 95 | 97 | const t1 = window.setTimeout(measure, 80); |
| 96 | 98 | const t2 = window.setTimeout(measure, 360); |
| 99 | + const dropSettle = whenAnimationsSettle(match, measure); | |
| 97 | 100 | |
| 98 | 101 | return () => { |
| 99 | 102 | wsb?.removeEventListener('transitionend', onTransitionEnd); |
| 100 | 103 | window.clearTimeout(t1); |
| 101 | 104 | window.clearTimeout(t2); |
| 105 | + dropSettle(); | |
| 102 | 106 | }; |
| 103 | 107 | }, [block]); |
| 104 | 108 | |
| 109 | + // The chip's X clears the block without firing the event. | |
| 105 | 110 | useEffect(() => { |
| 111 | + if (block?.id) return; | |
| 112 | + setRect(null); | |
| 113 | + el.current = null; | |
| 114 | + }, [block]); | |
| 115 | + | |
| 116 | + useEffect(() => { | |
| 106 | 117 | const handle = () => { |
| 107 | 118 | setRect(null); |
| 108 | 119 | el.current = null; |
| 109 | 120 | }; |
| @@ -114,8 +125,30 @@ | ||
| 114 | 125 | handle, |
| 115 | 126 | ); |
| 116 | 127 | }, []); |
| 117 | 128 | |
| 129 | + useEffect(() => { | |
| 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 }); | |
| 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]); | |
| 150 | + | |
| 118 | 151 | // Use capture phase for `scroll` so we hear it on any scrollable |
| 119 | 152 | // ancestor (e.g. wp-site-blocks when something repositions it as |
| 120 | 153 | // the page scroll container). Bubble-phase `scroll` doesn't |
| 121 | 154 | // propagate, so a window-only listener misses those. |
| @@ -122,9 +155,10 @@ | ||
| 122 | 155 | useEffect(() => { |
| 123 | 156 | const onScrollOrResize = () => { |
| 124 | 157 | if (!el.current) return; |
| 125 | 158 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 126 | - 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 }); | |
| 127 | 161 | }; |
| 128 | 162 | window.addEventListener('scroll', onScrollOrResize, { |
| 129 | 163 | passive: true, |
| 130 | 164 | capture: true, |
| @@ -143,8 +177,10 @@ | ||
| 143 | 177 | |
| 144 | 178 | const resizeObserver = new ResizeObserver(() => { |
| 145 | 179 | if (!el.current) return; |
| 146 | 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; | |
| 147 | 183 | setRect({ top, left, width, height }); |
| 148 | 184 | }); |
| 149 | 185 | |
| 150 | 186 | resizeObserver.observe(el.current); |
| @@ -153,20 +189,14 @@ | ||
| 153 | 189 | resizeObserver.disconnect(); |
| 154 | 190 | }; |
| 155 | 191 | }, [el.current]); |
| 156 | 192 | |
| 157 | - // Workflows can mutate the page while the outline is up: a tool that | |
| 158 | - // re-renders the block produces a new DOM node with the same | |
| 159 | - // data-extendify-agent-block-id, and ancestor reflows can shift the | |
| 160 | - // element without changing its own size (ResizeObserver misses both). | |
| 161 | - // Re-query and re-measure on any wp-site-blocks subtree mutation, | |
| 162 | - // rAF-debounced so a burst of mutations costs one measurement. | |
| 193 | + // ResizeObserver misses a replaced node and an ancestor reflow, so the | |
| 194 | + // outline stays on the old box. | |
| 163 | 195 | useEffect(() => { |
| 164 | 196 | if (!block?.id) return; |
| 165 | 197 | const root = document.querySelector('.wp-site-blocks'); |
| 166 | 198 | if (!root) return; |
| 167 | - const attr = block.target || 'data-extendify-agent-block-id'; | |
| 168 | - const sel = `[${attr}="${CSS.escape(String(block.id))}"]`; | |
| 169 | 199 | |
| 170 | 200 | let rafId = 0; |
| 171 | 201 | const observer = new MutationObserver(() => { |
| 172 | 202 | if (rafId) return; |
| @@ -171,9 +201,9 @@ | ||
| 171 | 201 | const observer = new MutationObserver(() => { |
| 172 | 202 | if (rafId) return; |
| 173 | 203 | rafId = window.requestAnimationFrame(() => { |
| 174 | 204 | rafId = 0; |
| 175 | - const match = document.querySelector(sel); | |
| 205 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 176 | 206 | if (!match) return; |
| 177 | 207 | el.current = match; |
| 178 | 208 | const r = match.getBoundingClientRect(); |
| 179 | 209 | if (r.width <= 0 || r.height <= 0) return; |
| @@ -211,18 +241,52 @@ | ||
| 211 | 241 | root.classList.add('extendify-agent-busy'); |
| 212 | 242 | return () => root.classList.remove('extendify-agent-busy'); |
| 213 | 243 | }, [busy]); |
| 214 | 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 | + | |
| 215 | 269 | if (!enabled || !rect || !mountNode) return null; |
| 216 | 270 | |
| 217 | - const { top, left, width, height } = rect; | |
| 218 | - const animate = { x: left, y: top, width, height, opacity: 1 }; | |
| 219 | - const transition = { | |
| 220 | - type: 'spring', | |
| 221 | - stiffness: 700, | |
| 222 | - damping: 40, | |
| 223 | - 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, | |
| 224 | 280 | }; |
| 281 | + const transition = instant | |
| 282 | + ? { duration: 0 } | |
| 283 | + : { | |
| 284 | + type: 'spring', | |
| 285 | + stiffness: 700, | |
| 286 | + damping: 40, | |
| 287 | + mass: 0.25, | |
| 288 | + }; | |
| 225 | 289 | return createPortal( |
| 226 | 290 | <> |
| 227 | 291 | {block && !busy ? ( |
| 228 | 292 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| @@ -227,11 +291,12 @@ | ||
| 227 | 291 | {block && !busy ? ( |
| 228 | 292 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| 229 | 293 | <div |
| 230 | 294 | role="button" |
| 231 | - className={ | |
| 232 | - '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' | |
| 233 | - } | |
| 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 | + )} | |
| 234 | 299 | tabIndex={0} |
| 235 | 300 | onClick={clearBlock} |
| 236 | 301 | onKeyDown={clearBlock} |
| 237 | 302 | style={{ |
| @@ -255,15 +320,17 @@ | ||
| 255 | 320 | initial={false} |
| 256 | 321 | aria-hidden |
| 257 | 322 | animate={animate} |
| 258 | 323 | transition={transition} |
| 259 | - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4" | |
| 324 | + className="fixed z-8 outline-dashed outline-4" | |
| 260 | 325 | style={{ |
| 261 | 326 | top: 0, |
| 262 | 327 | left: 0, |
| 263 | 328 | willChange: 'transform,width,height,opacity', |
| 264 | 329 | outlineColor: 'var(--wp--preset--color--primary, red)', |
| 265 | - pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none', | |
| 330 | + boxShadow: ringNeeded ? MEDIA_RING : undefined, | |
| 331 | + // This mount sits outside the scroller; 'auto' eats page scroll. | |
| 332 | + pointerEvents: 'none', | |
| 266 | 333 | }} |
| 267 | 334 | /> |
| 268 | 335 | </>, |
| 269 | 336 | mountNode, |