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
extendify / src / Agent / components / DOMHighlighter.jsx

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

339 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 { blockCodeQueryArgs } from '@agent/lib/block-code';
3 import { findBlockEl, scopeOf } from '@agent/lib/block-el';
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';
8 import apiFetch from '@wordpress/api-fetch';
9 import {
10 createPortal,
11 useCallback,
12 useEffect,
13 useRef,
14 useState,
15 } from '@wordpress/element';
16 import { __ } from '@wordpress/i18n';
17 import { close, Icon } from '@wordpress/icons';
18 import { addQueryArgs } from '@wordpress/url';
19 import classNames from 'classnames';
20 import { motion } from 'framer-motion';
21
22 const MIN_OUTLINE_SIZE = 10;
23
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 }) => {
34 const [rect, setRect] = useState(null);
35 const [ringNeeded, setRingNeeded] = useState(false);
36 const mountNode = usePortal('extendify-agent-dom-mount');
37 const el = useRef(null);
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);
42 const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
43
44 const clearBlock = useCallback(() => {
45 setBlock(null);
46 setRect(null);
47 el.current = null;
48 }, [setBlock, setRect]);
49
50 useEffect(() => {
51 if (!block?.id) return;
52 const ac = new AbortController();
53 const queryArgs = blockCodeQueryArgs(
54 block,
55 window.extAgentData?.context?.postId,
56 );
57 if (!queryArgs) return;
58
59 const isAlive = { current: true };
60 (async () => {
61 const res = await apiFetch({
62 path: addQueryArgs(`extendify/v1/agent/get-block-code`, queryArgs),
63 signal: ac.signal,
64 }).catch(() => ({})); // Agent will get it later if fails
65 if (!res.block || !isAlive.current || ac.signal.aborted) return;
66 setBlockCode(res.block);
67 })();
68 return () => {
69 ac.abort();
70 isAlive.current = false;
71 };
72 }, [setBlockCode, block]);
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.
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(() => {
117 const handle = () => {
118 setRect(null);
119 el.current = null;
120 };
121 window.addEventListener('extendify-agent:remove-block-highlight', handle);
122 return () =>
123 window.removeEventListener(
124 'extendify-agent:remove-block-highlight',
125 handle,
126 );
127 }, []);
128
129 useEffect(() => {
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 });
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 // ResizeObserver misses a replaced node and an ancestor reflow, so the
194 // outline stays on the old box.
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(() => {
230 if (!enabled) return;
231 const root = document.querySelector('.wp-site-blocks');
232 if (!root) return;
233 root.classList.add('extendify-agent-highlighter-mode');
234 return () => root.classList.remove('extendify-agent-highlighter-mode');
235 }, [enabled]);
236
237 useEffect(() => {
238 if (!busy) return;
239 const root = document.querySelector('.wp-site-blocks');
240 if (!root) return;
241 root.classList.add('extendify-agent-busy');
242 return () => root.classList.remove('extendify-agent-busy');
243 }, [busy]);
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
269 if (!enabled || !rect || !mountNode) return null;
270
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,
280 };
281 const transition = instant
282 ? { duration: 0 }
283 : {
284 type: 'spring',
285 stiffness: 700,
286 damping: 40,
287 mass: 0.25,
288 };
289 return createPortal(
290 <>
291 {block && !busy ? (
292 // biome-ignore lint: Using <button> is complicated with unknown themes
293 <div
294 role="button"
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 )}
299 tabIndex={0}
300 onClick={clearBlock}
301 onKeyDown={clearBlock}
302 style={{
303 top,
304 left: width / 2 + left - 12,
305 backgroundColor: 'var(--wp--preset--color--primary, red)',
306 color: 'var(--wp--preset--color--background, white)',
307 }}
308 >
309 <Icon
310 className="pointer-events-none fill-current leading-none"
311 icon={close}
312 size={18}
313 />
314 <span className="sr-only">
315 {__('Remove highlight', 'extendify-local')}
316 </span>
317 </div>
318 ) : null}
319 <motion.div
320 initial={false}
321 aria-hidden
322 animate={animate}
323 transition={transition}
324 className="fixed z-8 outline-dashed outline-4"
325 style={{
326 top: 0,
327 left: 0,
328 willChange: 'transform,width,height,opacity',
329 outlineColor: 'var(--wp--preset--color--primary, red)',
330 boxShadow: ringNeeded ? MEDIA_RING : undefined,
331 // This mount sits outside the scroller; 'auto' eats page scroll.
332 pointerEvents: 'none',
333 }}
334 />
335 </>,
336 mountNode,
337 );
338 };
339