PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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
extendify / src / Agent / components / DOMHighlighter.jsx

DOMHighlighter.jsx in Extendify 3.1.5, at src/Agent/components/DOMHighlighter.jsx

329 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { usePortal } from '@agent/hooks/usePortal';
2 import { useWorkflowStore } from '@agent/state/workflows';
3 import { whenAnimationsSettle } from '@quick-edit/lib/after-animations';
4 import { MEDIA_RING, needsContrastRing } from '@quick-edit/lib/over-media';
5 import { useQuickEditStore } from '@quick-edit/state/store';
6 import apiFetch from '@wordpress/api-fetch';
7 import {
8 createPortal,
9 useCallback,
10 useEffect,
11 useRef,
12 useState,
13 } from '@wordpress/element';
14 import { __ } from '@wordpress/i18n';
15 import { close, Icon } from '@wordpress/icons';
16 import { addQueryArgs } from '@wordpress/url';
17 import classNames from 'classnames';
18 import { motion } from 'framer-motion';
19
20 const MIN_OUTLINE_SIZE = 10;
21
22 // Render-only after the selector unification: `agentBlock` is set
23 // by Quick Edit's Ask AI flow (or future workflows), and this component
24 // draws the outline + X-close indicator. Hover-bar owns hover + click
25 // selection on the live page; DOMHighlighter no longer listens for either.
26 export const DOMHighlighter = ({ busy = false, working = false }) => {
27 const [rect, setRect] = useState(null);
28 const [ringNeeded, setRingNeeded] = useState(false);
29 const mountNode = usePortal('extendify-agent-dom-mount');
30 const el = useRef(null);
31 const { getWorkflowsByFeature } = useWorkflowStore();
32 const block = useQuickEditStore((s) => s.agentBlock);
33 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
34 const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode);
35 const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
36
37 const clearBlock = useCallback(() => {
38 setBlock(null);
39 setRect(null);
40 el.current = null;
41 }, [setBlock, setRect]);
42
43 useEffect(() => {
44 if (!block?.id) return;
45 const ac = new AbortController();
46 const postId = window.extAgentData?.context?.postId;
47 if (!postId) return;
48 const queryArgs = {
49 postId: String(postId),
50 blockId: String(block.id),
51 };
52
53 const isAlive = { current: true };
54 (async () => {
55 const res = await apiFetch({
56 path: addQueryArgs(`extendify/v1/agent/get-block-code`, queryArgs),
57 signal: ac.signal,
58 }).catch(() => ({})); // Agent will get it later if fails
59 if (!res.block || !isAlive.current || ac.signal.aborted) return;
60 setBlockCode(res.block);
61 })();
62 return () => {
63 ac.abort();
64 isAlive.current = false;
65 };
66 }, [setBlockCode, block]);
67
68 // Re-syncs the rect for programmatic block changes (e.g. Ask AI)
69 // and after the wp-site-blocks open/close transform settles.
70 useEffect(() => {
71 if (!block?.id) return;
72 const attr = block.target || 'data-extendify-agent-block-id';
73 const match = document.querySelector(
74 `[${attr}="${CSS.escape(String(block.id))}"]`,
75 );
76 if (!match) return;
77 el.current = match;
78 setRingNeeded(needsContrastRing(match));
79
80 const measure = () => {
81 const r = match.getBoundingClientRect();
82 if (r.width <= 0 || r.height <= 0) return;
83 setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
84 };
85 measure();
86
87 // transitionend covers the panel-open/close transform; the
88 // timeouts are belt-and-braces if transitions are disabled.
89 const wsb = document.querySelector('.wp-site-blocks');
90 const onTransitionEnd = (e) => {
91 if (e.propertyName === 'transform') measure();
92 };
93 wsb?.addEventListener('transitionend', onTransitionEnd);
94 const t1 = window.setTimeout(measure, 80);
95 const t2 = window.setTimeout(measure, 360);
96 const dropSettle = whenAnimationsSettle(match, measure);
97
98 return () => {
99 wsb?.removeEventListener('transitionend', onTransitionEnd);
100 window.clearTimeout(t1);
101 window.clearTimeout(t2);
102 dropSettle();
103 };
104 }, [block]);
105
106 // The chip's X clears the block without firing the event.
107 useEffect(() => {
108 if (block?.id) return;
109 setRect(null);
110 el.current = null;
111 }, [block]);
112
113 useEffect(() => {
114 const handle = () => {
115 setRect(null);
116 el.current = null;
117 };
118 window.addEventListener('extendify-agent:remove-block-highlight', handle);
119 return () =>
120 window.removeEventListener(
121 'extendify-agent:remove-block-highlight',
122 handle,
123 );
124 }, []);
125
126 useEffect(() => {
127 if (!block?.id) return;
128 const attr = block.target || 'data-extendify-agent-block-id';
129 const handle = () => {
130 const match = document.querySelector(
131 `[${attr}="${CSS.escape(String(block.id))}"]`,
132 );
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 });
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]);
150
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.
155 useEffect(() => {
156 const onScrollOrResize = () => {
157 if (!el.current) return;
158 const { top, left, width, height } = el.current.getBoundingClientRect();
159 // Animating this re-targets the spring mid-scroll, so it never lands.
160 setRect({ top, left, width, height, instant: true });
161 };
162 window.addEventListener('scroll', onScrollOrResize, {
163 passive: true,
164 capture: true,
165 });
166 window.addEventListener('resize', onScrollOrResize);
167 return () => {
168 window.removeEventListener('scroll', onScrollOrResize, {
169 capture: true,
170 });
171 window.removeEventListener('resize', onScrollOrResize);
172 };
173 }, [el]);
174
175 useEffect(() => {
176 if (!el.current) return;
177
178 const resizeObserver = new ResizeObserver(() => {
179 if (!el.current) return;
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;
183 setRect({ top, left, width, height });
184 });
185
186 resizeObserver.observe(el.current);
187
188 return () => {
189 resizeObserver.disconnect();
190 };
191 }, [el.current]);
192
193 // Workflows can mutate the page while the outline is up: a tool that
194 // re-renders the block produces a new DOM node with the same
195 // data-extendify-agent-block-id, and ancestor reflows can shift the
196 // element without changing its own size (ResizeObserver misses both).
197 // Re-query and re-measure on any wp-site-blocks subtree mutation,
198 // rAF-debounced so a burst of mutations costs one measurement.
199 useEffect(() => {
200 if (!block?.id) return;
201 const root = document.querySelector('.wp-site-blocks');
202 if (!root) return;
203 const attr = block.target || 'data-extendify-agent-block-id';
204 const sel = `[${attr}="${CSS.escape(String(block.id))}"]`;
205
206 let rafId = 0;
207 const observer = new MutationObserver(() => {
208 if (rafId) return;
209 rafId = window.requestAnimationFrame(() => {
210 rafId = 0;
211 const match = document.querySelector(sel);
212 if (!match) return;
213 el.current = match;
214 const r = match.getBoundingClientRect();
215 if (r.width <= 0 || r.height <= 0) return;
216 setRect({
217 top: r.top,
218 left: r.left,
219 width: r.width,
220 height: r.height,
221 });
222 });
223 });
224 observer.observe(root, {
225 childList: true,
226 subtree: true,
227 characterData: true,
228 });
229 return () => {
230 observer.disconnect();
231 if (rafId) window.cancelAnimationFrame(rafId);
232 };
233 }, [block]);
234
235 useEffect(() => {
236 if (!enabled) return;
237 const root = document.querySelector('.wp-site-blocks');
238 if (!root) return;
239 root.classList.add('extendify-agent-highlighter-mode');
240 return () => root.classList.remove('extendify-agent-highlighter-mode');
241 }, [enabled]);
242
243 useEffect(() => {
244 if (!busy) return;
245 const root = document.querySelector('.wp-site-blocks');
246 if (!root) return;
247 root.classList.add('extendify-agent-busy');
248 return () => root.classList.remove('extendify-agent-busy');
249 }, [busy]);
250
251 useEffect(() => {
252 if (!working) return;
253 const root = document.querySelector('.wp-site-blocks');
254 if (!root) return;
255 root.classList.add('extendify-agent-working');
256 return () => root.classList.remove('extendify-agent-working');
257 }, [working]);
258
259 if (!enabled || !rect || !mountNode) return null;
260
261 const { top, left, width, height, instant } = rect;
262 // A separator's box is sub-pixel tall; 4px dashes read as a broken line.
263 const framed = (size) => Math.max(size, MIN_OUTLINE_SIZE);
264 const animate = {
265 x: left - (framed(width) - width) / 2,
266 y: top - (framed(height) - height) / 2,
267 width: framed(width),
268 height: framed(height),
269 opacity: 1,
270 };
271 const transition = instant
272 ? { duration: 0 }
273 : {
274 type: 'spring',
275 stiffness: 700,
276 damping: 40,
277 mass: 0.25,
278 };
279 return createPortal(
280 <>
281 {block && !busy ? (
282 // biome-ignore lint: Using <button> is complicated with unknown themes
283 <div
284 role="button"
285 className={classNames(
286 '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',
287 { 'ring-1 ring-white/20': ringNeeded },
288 )}
289 tabIndex={0}
290 onClick={clearBlock}
291 onKeyDown={clearBlock}
292 style={{
293 top,
294 left: width / 2 + left - 12,
295 backgroundColor: 'var(--wp--preset--color--primary, red)',
296 color: 'var(--wp--preset--color--background, white)',
297 }}
298 >
299 <Icon
300 className="pointer-events-none fill-current leading-none"
301 icon={close}
302 size={18}
303 />
304 <span className="sr-only">
305 {__('Remove highlight', 'extendify-local')}
306 </span>
307 </div>
308 ) : null}
309 <motion.div
310 initial={false}
311 aria-hidden
312 animate={animate}
313 transition={transition}
314 className="fixed z-8 outline-dashed outline-4"
315 style={{
316 top: 0,
317 left: 0,
318 willChange: 'transform,width,height,opacity',
319 outlineColor: 'var(--wp--preset--color--primary, red)',
320 boxShadow: ringNeeded ? MEDIA_RING : undefined,
321 // This mount sits outside the scroller; 'auto' eats page scroll.
322 pointerEvents: 'none',
323 }}
324 />
325 </>,
326 mountNode,
327 );
328 };
329