| @@ -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,26 @@ | ||
| 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 = ({ busy = false, working = false }) => { | |
| 26 | 29 | const [rect, setRect] = useState(null); |
| 30 | + const [ringNeeded, setRingNeeded] = useState(false); | |
| 27 | 31 | const mountNode = usePortal('extendify-agent-dom-mount'); |
| 28 | - const raf = useRef(null); | |
| 29 | 32 | const el = useRef(null); |
| 30 | - const { getWorkflowsByFeature, block, setBlock, setBlockCode } = | |
| 31 | - useWorkflowStore(); | |
| 33 | + const { getWorkflowsByFeature } = useWorkflowStore(); | |
| 34 | + const block = useQuickEditStore((s) => s.agentBlock); | |
| 35 | + const setBlock = useQuickEditStore((s) => s.setAgentBlock); | |
| 36 | + const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode); | |
| 32 | 37 | const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0; |
| 33 | 38 | |
| 34 | 39 | const clearBlock = useCallback(() => { |
| 35 | 40 | setBlock(null); |
| @@ -34,23 +39,18 @@ | ||
| 34 | 39 | const clearBlock = useCallback(() => { |
| 35 | 40 | setBlock(null); |
| 36 | 41 | setRect(null); |
| 37 | 42 | el.current = null; |
| 38 | - document.querySelector(HIGHLIGHTER_CLS)?.classList.remove(HIGHLIGHTER_CLS); | |
| 39 | - document | |
| 40 | - .querySelector(`[${SELECTED_ATTR}]`) | |
| 41 | - ?.removeAttribute(SELECTED_ATTR); | |
| 42 | 43 | }, [setBlock, setRect]); |
| 43 | 44 | |
| 44 | 45 | useEffect(() => { |
| 45 | 46 | if (!block?.id) return; |
| 46 | 47 | 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 | - }; | |
| 48 | + const queryArgs = blockCodeQueryArgs( | |
| 49 | + block, | |
| 50 | + window.extAgentData?.context?.postId, | |
| 51 | + ); | |
| 52 | + if (!queryArgs) return; | |
| 53 | 53 | |
| 54 | 54 | const isAlive = { current: true }; |
| 55 | 55 | (async () => { |
| 56 | 56 | const res = await apiFetch({ |
| @@ -65,9 +65,51 @@ | ||
| 65 | 65 | isAlive.current = false; |
| 66 | 66 | }; |
| 67 | 67 | }, [setBlockCode, block]); |
| 68 | 68 | |
| 69 | + // Re-syncs the rect for programmatic block changes (e.g. Ask AI) | |
| 70 | + // and after the wp-site-blocks open/close transform settles. | |
| 69 | 71 | useEffect(() => { |
| 72 | + if (!block?.id) return; | |
| 73 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 74 | + if (!match) return; | |
| 75 | + el.current = match; | |
| 76 | + setRingNeeded(needsContrastRing(match)); | |
| 77 | + | |
| 78 | + const measure = () => { | |
| 79 | + const r = match.getBoundingClientRect(); | |
| 80 | + if (r.width <= 0 || r.height <= 0) return; | |
| 81 | + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); | |
| 82 | + }; | |
| 83 | + measure(); | |
| 84 | + | |
| 85 | + // transitionend covers the panel-open/close transform; the | |
| 86 | + // timeouts are belt-and-braces if transitions are disabled. | |
| 87 | + const wsb = document.querySelector('.wp-site-blocks'); | |
| 88 | + const onTransitionEnd = (e) => { | |
| 89 | + if (e.propertyName === 'transform') measure(); | |
| 90 | + }; | |
| 91 | + wsb?.addEventListener('transitionend', onTransitionEnd); | |
| 92 | + const t1 = window.setTimeout(measure, 80); | |
| 93 | + const t2 = window.setTimeout(measure, 360); | |
| 94 | + const dropSettle = whenAnimationsSettle(match, measure); | |
| 95 | + | |
| 96 | + return () => { | |
| 97 | + wsb?.removeEventListener('transitionend', onTransitionEnd); | |
| 98 | + window.clearTimeout(t1); | |
| 99 | + window.clearTimeout(t2); | |
| 100 | + dropSettle(); | |
| 101 | + }; | |
| 102 | + }, [block]); | |
| 103 | + | |
| 104 | + // The chip's X clears the block without firing the event. | |
| 105 | + useEffect(() => { | |
| 106 | + if (block?.id) return; | |
| 107 | + setRect(null); | |
| 108 | + el.current = null; | |
| 109 | + }, [block]); | |
| 110 | + | |
| 111 | + useEffect(() => { | |
| 70 | 112 | const handle = () => { |
| 71 | 113 | setRect(null); |
| 72 | 114 | el.current = null; |
| 73 | 115 | }; |
| @@ -79,130 +121,61 @@ | ||
| 79 | 121 | ); |
| 80 | 122 | }, []); |
| 81 | 123 | |
| 82 | 124 | 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 | - }); | |
| 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 }); | |
| 117 | 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]); | |
| 118 | 145 | |
| 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 | - | |
| 146 | + // Use capture phase for `scroll` so we hear it on any scrollable | |
| 147 | + // ancestor (e.g. wp-site-blocks when something repositions it as | |
| 148 | + // the page scroll container). Bubble-phase `scroll` doesn't | |
| 149 | + // propagate, so a window-only listener misses those. | |
| 126 | 150 | useEffect(() => { |
| 127 | 151 | const onScrollOrResize = () => { |
| 128 | 152 | if (!el.current) return; |
| 129 | 153 | const { top, left, width, height } = el.current.getBoundingClientRect(); |
| 130 | - 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 }); | |
| 131 | 156 | }; |
| 132 | - window.addEventListener('scroll', onScrollOrResize, { passive: true }); | |
| 157 | + window.addEventListener('scroll', onScrollOrResize, { | |
| 158 | + passive: true, | |
| 159 | + capture: true, | |
| 160 | + }); | |
| 133 | 161 | window.addEventListener('resize', onScrollOrResize); |
| 134 | 162 | return () => { |
| 135 | - window.removeEventListener('scroll', onScrollOrResize); | |
| 163 | + window.removeEventListener('scroll', onScrollOrResize, { | |
| 164 | + capture: true, | |
| 165 | + }); | |
| 136 | 166 | window.removeEventListener('resize', onScrollOrResize); |
| 137 | 167 | }; |
| 138 | 168 | }, [el]); |
| 139 | 169 | |
| 140 | 170 | 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 | 171 | if (!el.current) return; |
| 201 | 172 | |
| 202 | 173 | const resizeObserver = new ResizeObserver(() => { |
| 203 | 174 | if (!el.current) return; |
| 204 | 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; | |
| 205 | 178 | setRect({ top, left, width, height }); |
| 206 | 179 | }); |
| 207 | 180 | |
| 208 | 181 | resizeObserver.observe(el.current); |
| @@ -211,9 +184,45 @@ | ||
| 211 | 184 | resizeObserver.disconnect(); |
| 212 | 185 | }; |
| 213 | 186 | }, [el.current]); |
| 214 | 187 | |
| 188 | + // ResizeObserver misses a replaced node and an ancestor reflow, so the | |
| 189 | + // outline stays on the old box. | |
| 215 | 190 | useEffect(() => { |
| 191 | + if (!block?.id) return; | |
| 192 | + const root = document.querySelector('.wp-site-blocks'); | |
| 193 | + if (!root) return; | |
| 194 | + | |
| 195 | + let rafId = 0; | |
| 196 | + const observer = new MutationObserver(() => { | |
| 197 | + if (rafId) return; | |
| 198 | + rafId = window.requestAnimationFrame(() => { | |
| 199 | + rafId = 0; | |
| 200 | + const match = findBlockEl(block.id, document, scopeOf(block)); | |
| 201 | + if (!match) return; | |
| 202 | + el.current = match; | |
| 203 | + const r = match.getBoundingClientRect(); | |
| 204 | + if (r.width <= 0 || r.height <= 0) return; | |
| 205 | + setRect({ | |
| 206 | + top: r.top, | |
| 207 | + left: r.left, | |
| 208 | + width: r.width, | |
| 209 | + height: r.height, | |
| 210 | + }); | |
| 211 | + }); | |
| 212 | + }); | |
| 213 | + observer.observe(root, { | |
| 214 | + childList: true, | |
| 215 | + subtree: true, | |
| 216 | + characterData: true, | |
| 217 | + }); | |
| 218 | + return () => { | |
| 219 | + observer.disconnect(); | |
| 220 | + if (rafId) window.cancelAnimationFrame(rafId); | |
| 221 | + }; | |
| 222 | + }, [block]); | |
| 223 | + | |
| 224 | + useEffect(() => { | |
| 216 | 225 | if (!enabled) return; |
| 217 | 226 | const root = document.querySelector('.wp-site-blocks'); |
| 218 | 227 | if (!root) return; |
| 219 | 228 | root.classList.add('extendify-agent-highlighter-mode'); |
| @@ -227,18 +236,36 @@ | ||
| 227 | 236 | root.classList.add('extendify-agent-busy'); |
| 228 | 237 | return () => root.classList.remove('extendify-agent-busy'); |
| 229 | 238 | }, [busy]); |
| 230 | 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 | + | |
| 231 | 248 | if (!enabled || !rect || !mountNode) return null; |
| 232 | 249 | |
| 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, | |
| 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, | |
| 240 | 259 | }; |
| 260 | + const transition = instant | |
| 261 | + ? { duration: 0 } | |
| 262 | + : { | |
| 263 | + type: 'spring', | |
| 264 | + stiffness: 700, | |
| 265 | + damping: 40, | |
| 266 | + mass: 0.25, | |
| 267 | + }; | |
| 241 | 268 | return createPortal( |
| 242 | 269 | <> |
| 243 | 270 | {block && !busy ? ( |
| 244 | 271 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| @@ -243,14 +270,15 @@ | ||
| 243 | 270 | {block && !busy ? ( |
| 244 | 271 | // biome-ignore lint: Using <button> is complicated with unknown themes |
| 245 | 272 | <div |
| 246 | 273 | 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 | - } | |
| 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 | + )} | |
| 250 | 278 | tabIndex={0} |
| 251 | - onClick={() => setBlock(null)} | |
| 252 | - onKeyDown={() => setBlock(null)} | |
| 279 | + onClick={clearBlock} | |
| 280 | + onKeyDown={clearBlock} | |
| 253 | 281 | style={{ |
| 254 | 282 | top, |
| 255 | 283 | left: width / 2 + left - 12, |
| 256 | 284 | backgroundColor: 'var(--wp--preset--color--primary, red)', |
| @@ -271,15 +299,17 @@ | ||
| 271 | 299 | initial={false} |
| 272 | 300 | aria-hidden |
| 273 | 301 | animate={animate} |
| 274 | 302 | transition={transition} |
| 275 | - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4" | |
| 303 | + className="fixed z-8 outline-dashed outline-4" | |
| 276 | 304 | style={{ |
| 277 | 305 | top: 0, |
| 278 | 306 | left: 0, |
| 279 | 307 | willChange: 'transform,width,height,opacity', |
| 280 | 308 | outlineColor: 'var(--wp--preset--color--primary, red)', |
| 281 | - pointerEvents: block && !busy ? 'auto' : 'none', | |
| 309 | + boxShadow: ringNeeded ? MEDIA_RING : undefined, | |
| 310 | + // This mount sits outside the scroller; 'auto' eats page scroll. | |
| 311 | + pointerEvents: 'none', | |
| 282 | 312 | }} |
| 283 | 313 | /> |
| 284 | 314 | </>, |
| 285 | 315 | mountNode, |