PluginProbe
Extendify / trunk
Extendify vtrunk
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 0.6.0 All 127 releases
← All changes | src/Agent/components/DOMHighlighter.jsx +169 -139 3.0.5 → trunk 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,