| @@ -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,27 @@ | ||
| 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 = ({ busy = false, working = false }) => { | |
| 22 | 29 | const [rect, setRect] = useState(null); |
| 30 | + const [ringNeeded, setRingNeeded] = useState(false); | |
| 23 | 31 | const mountNode = usePortal('extendify-agent-dom-mount'); |
| 24 | 32 | const el = useRef(null); |
| 25 | 33 | const { getWorkflowsByFeature } = useWorkflowStore(); |
| 26 | 34 | const block = useQuickEditStore((s) => s.agentBlock); |
| 27 | - const selected = useQuickEditStore((s) => s.selected); | |
| 28 | 35 | const setBlock = useQuickEditStore((s) => s.setAgentBlock); |
| 29 | 36 | const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode); |
| 30 | 37 | 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 | 38 | |
| 39 | 39 | const clearBlock = useCallback(() => { |
| 40 | 40 | setBlock(null); |
| 41 | 41 | setRect(null); |
| @@ -44,14 +44,13 @@ | ||
| 44 | 44 | |
| 45 | 45 | useEffect(() => { |
| 46 | 46 | if (!block?.id) return; |
| 47 | 47 | 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 | - }; | |
| 48 | + const queryArgs = blockCodeQueryArgs( | |
| 49 | + block, | |
| 50 | + window.extAgentData?.context?.postId, | |
| 51 | + ); | |
| 52 | + if (!queryArgs) return; | |
| 54 | 53 | |
| 55 | 54 | const isAlive = { current: true }; |
| 56 | 55 | (async () => { |
| 57 | 56 | const res = await apiFetch({ |
| @@ -70,14 +69,12 @@ | ||
| 70 | 69 | // Re-syncs the rect for programmatic block changes (e.g. Ask AI) |
| 71 | 70 | // and after the wp-site-blocks open/close transform settles. |
| 72 | 71 | useEffect(() => { |
| 73 | 72 | 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 | - ); | |
| 73 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 78 | 74 | if (!match) return; |
| 79 | 75 | el.current = match; |
| 76 | + setRingNeeded(needsContrastRing(match)); | |
| 80 | 77 | |
| 81 | 78 | const measure = () => { |
| 82 | 79 | const r = match.getBoundingClientRect(); |
| 83 | 80 | if (r.width <= 0 || r.height <= 0) return; |
| @@ -93,17 +90,26 @@ | ||
| 93 | 90 | }; |
| 94 | 91 | wsb?.addEventListener('transitionend', onTransitionEnd); |
| 95 | 92 | const t1 = window.setTimeout(measure, 80); |
| 96 | 93 | const t2 = window.setTimeout(measure, 360); |
| 94 | + const dropSettle = whenAnimationsSettle(match, measure); | |
| 97 | 95 | |
| 98 | 96 | return () => { |
| 99 | 97 | wsb?.removeEventListener('transitionend', onTransitionEnd); |
| 100 | 98 | window.clearTimeout(t1); |
| 101 | 99 | window.clearTimeout(t2); |
| 100 | + dropSettle(); | |
| 102 | 101 | }; |
| 103 | 102 | }, [block]); |
| 104 | 103 | |
| 104 | + // The chip's X clears the block without firing the event. | |
| 105 | 105 | useEffect(() => { |
| 106 | + if (block?.id) return; | |
| 107 | + setRect(null); | |
| 108 | + el.current = null; | |
| 109 | + }, [block]); | |
| 110 | + | |
| 111 | + useEffect(() => { | |
| 106 | 112 | const handle = () => { |
| 107 | 113 | setRect(null); |
| 108 | 114 | el.current = null; |
| 109 | 115 | }; |
| @@ -114,8 +120,30 @@ | ||
| 114 | 120 | handle, |
| 115 | 121 | ); |
| 116 | 122 | }, []); |
| 117 | 123 | |
| 124 | + useEffect(() => { | |
| 125 | + if (!block?.id) return; | |
| 126 | + const handle = () => { | |
| 127 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 128 | + // Clearing only the rect leaves the chip counting a gone node. | |
| 129 | + if (!match) { | |
| 130 | + clearBlock(); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + el.current = match; | |
| 134 | + const r = match.getBoundingClientRect(); | |
| 135 | + if (r.width <= 0 || r.height <= 0) return; | |
| 136 | + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); | |
| 137 | + }; | |
| 138 | + window.addEventListener('extendify-agent:refresh-block-highlight', handle); | |
| 139 | + return () => | |
| 140 | + window.removeEventListener( | |
| 141 | + 'extendify-agent:refresh-block-highlight', | |
| 142 | + handle, | |
| 143 | + ); | |
| 144 | + }, [block, clearBlock]); | |
| 145 | + | |
| 118 | 146 | // Use capture phase for `scroll` so we hear it on any scrollable |
| 119 | 147 | // ancestor (e.g. wp-site-blocks when something repositions it as |
| 120 | 148 | // the page scroll container). Bubble-phase `scroll` doesn't |
| 121 | 149 | // propagate, so a window-only listener misses those. |
| @@ -122,9 +150,10 @@ | ||
| 122 | 150 | useEffect(() => { |
| 123 | 151 | const onScrollOrResize = () => { |
| 124 | 152 | if (!el.current) return; |
| 125 | 153 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 126 | - setRect({ top, left, width, height }); | |
| 154 | + // Animating this re-targets the spring mid-scroll, so it never lands. | |
| 155 | + setRect({ top, left, width, height, instant: true }); | |
| 127 | 156 | }; |
| 128 | 157 | window.addEventListener('scroll', onScrollOrResize, { |
| 129 | 158 | passive: true, |
| 130 | 159 | capture: true, |
| @@ -143,8 +172,10 @@ | ||
| 143 | 172 | |
| 144 | 173 | const resizeObserver = new ResizeObserver(() => { |
| 145 | 174 | if (!el.current) return; |
| 146 | 175 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 176 | + // A detached node reports 0x0, which draws as a corner dot. | |
| 177 | + if (width <= 0 || height <= 0) return; | |
| 147 | 178 | setRect({ top, left, width, height }); |
| 148 | 179 | }); |
| 149 | 180 | |
| 150 | 181 | resizeObserver.observe(el.current); |
| @@ -153,20 +184,14 @@ | ||
| 153 | 184 | resizeObserver.disconnect(); |
| 154 | 185 | }; |
| 155 | 186 | }, [el.current]); |
| 156 | 187 | |
| 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. | |
| 188 | + // ResizeObserver misses a replaced node and an ancestor reflow, so the | |
| 189 | + // outline stays on the old box. | |
| 163 | 190 | useEffect(() => { |
| 164 | 191 | if (!block?.id) return; |
| 165 | 192 | const root = document.querySelector('.wp-site-blocks'); |
| 166 | 193 | if (!root) return; |
| 167 | - const attr = block.target || 'data-extendify-agent-block-id'; | |
| 168 | - const sel = `[${attr}="${CSS.escape(String(block.id))}"]`; | |
| 169 | 194 | |
| 170 | 195 | let rafId = 0; |
| 171 | 196 | const observer = new MutationObserver(() => { |
| 172 | 197 | if (rafId) return; |
| @@ -171,9 +196,9 @@ | ||
| 171 | 196 | const observer = new MutationObserver(() => { |
| 172 | 197 | if (rafId) return; |
| 173 | 198 | rafId = window.requestAnimationFrame(() => { |
| 174 | 199 | rafId = 0; |
| 175 | - const match = document.querySelector(sel); | |
| 200 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 176 | 201 | if (!match) return; |
| 177 | 202 | el.current = match; |
| 178 | 203 | const r = match.getBoundingClientRect(); |
| 179 | 204 | if (r.width <= 0 || r.height <= 0) return; |
| @@ -211,18 +236,36 @@ | ||
| 211 | 236 | root.classList.add('extendify-agent-busy'); |
| 212 | 237 | return () => root.classList.remove('extendify-agent-busy'); |
| 213 | 238 | }, [busy]); |
| 214 | 239 | |
| 240 | + useEffect(() => { | |
| 241 | + if (!working) return; | |
| 242 | + const root = document.querySelector('.wp-site-blocks'); | |
| 243 | + if (!root) return; | |
| 244 | + root.classList.add('extendify-agent-working'); | |
| 245 | + return () => root.classList.remove('extendify-agent-working'); | |
| 246 | + }, [working]); | |
| 247 | + | |
| 215 | 248 | if (!enabled || !rect || !mountNode) return null; |
| 216 | 249 | |
| 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, | |
| 250 | + const { top, left, width, height, instant } = rect; | |
| 251 | + // A separator's box is sub-pixel tall; 4px dashes read as a broken line. | |
| 252 | + const framed = (size) => Math.max(size, MIN_OUTLINE_SIZE); | |
| 253 | + const animate = { | |
| 254 | + x: left - (framed(width) - width) / 2, | |
| 255 | + y: top - (framed(height) - height) / 2, | |
| 256 | + width: framed(width), | |
| 257 | + height: framed(height), | |
| 258 | + opacity: 1, | |
| 224 | 259 | }; |
| 260 | + const transition = instant | |
| 261 | + ? { duration: 0 } | |
| 262 | + : { | |
| 263 | + type: 'spring', | |
| 264 | + stiffness: 700, | |
| 265 | + damping: 40, | |
| 266 | + mass: 0.25, | |
| 267 | + }; | |
| 225 | 268 | return createPortal( |
| 226 | 269 | <> |
| 227 | 270 | {block && !busy ? ( |
| 228 | 271 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| @@ -227,11 +270,12 @@ | ||
| 227 | 270 | {block && !busy ? ( |
| 228 | 271 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| 229 | 272 | <div |
| 230 | 273 | 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 | - } | |
| 274 | + className={classNames( | |
| 275 | + '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', | |
| 276 | + { 'ring-1 ring-white/20': ringNeeded }, | |
| 277 | + )} | |
| 234 | 278 | tabIndex={0} |
| 235 | 279 | onClick={clearBlock} |
| 236 | 280 | onKeyDown={clearBlock} |
| 237 | 281 | style={{ |
| @@ -255,15 +299,17 @@ | ||
| 255 | 299 | initial={false} |
| 256 | 300 | aria-hidden |
| 257 | 301 | animate={animate} |
| 258 | 302 | transition={transition} |
| 259 | - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4" | |
| 303 | + className="fixed z-8 outline-dashed outline-4" | |
| 260 | 304 | style={{ |
| 261 | 305 | top: 0, |
| 262 | 306 | left: 0, |
| 263 | 307 | willChange: 'transform,width,height,opacity', |
| 264 | 308 | outlineColor: 'var(--wp--preset--color--primary, red)', |
| 265 | - pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none', | |
| 309 | + boxShadow: ringNeeded ? MEDIA_RING : undefined, | |
| 310 | + // This mount sits outside the scroller; 'auto' eats page scroll. | |
| 311 | + pointerEvents: 'none', | |
| 266 | 312 | }} |
| 267 | 313 | /> |
| 268 | 314 | </>, |
| 269 | 315 | mountNode, |