PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.3 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 All 129 releases
← All changes | src/Agent/components/DOMHighlighter.jsx +169 -139 3.0.4 → 3.2.1 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,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,