PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
← All changes | src/Agent/components/DOMHighlighter.jsx +190 -139 3.0.5 → 3.2.2 View file →
@@ -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,