PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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.2.1, at src/Agent/components/DOMHighlighter.jsx

318 lines 9.5 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 = ({ busy = false, working = false }) => {
29 const [rect, setRect] = useState(null);
30 const [ringNeeded, setRingNeeded] = useState(false);
31 const mountNode = usePortal('extendify-agent-dom-mount');
32 const el = useRef(null);
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);
37 const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
38
39 const clearBlock = useCallback(() => {
40 setBlock(null);
41 setRect(null);
42 el.current = null;
43 }, [setBlock, setRect]);
44
45 useEffect(() => {
46 if (!block?.id) return;
47 const ac = new AbortController();
48 const queryArgs = blockCodeQueryArgs(
49 block,
50 window.extAgentData?.context?.postId,
51 );
52 if (!queryArgs) return;
53
54 const isAlive = { current: true };
55 (async () => {
56 const res = await apiFetch({
57 path: addQueryArgs(`extendify/v1/agent/get-block-code`, queryArgs),
58 signal: ac.signal,
59 }).catch(() => ({})); // Agent will get it later if fails
60 if (!res.block || !isAlive.current || ac.signal.aborted) return;
61 setBlockCode(res.block);
62 })();
63 return () => {
64 ac.abort();
65 isAlive.current = false;
66 };
67 }, [setBlockCode, block]);
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.
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(() => {
112 const handle = () => {
113 setRect(null);
114 el.current = null;
115 };
116 window.addEventListener('extendify-agent:remove-block-highlight', handle);
117 return () =>
118 window.removeEventListener(
119 'extendify-agent:remove-block-highlight',
120 handle,
121 );
122 }, []);
123
124 useEffect(() => {
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 });
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]);
145
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.
150 useEffect(() => {
151 const onScrollOrResize = () => {
152 if (!el.current) return;
153 const { top, left, width, height } = el.current.getBoundingClientRect();
154 // Animating this re-targets the spring mid-scroll, so it never lands.
155 setRect({ top, left, width, height, instant: true });
156 };
157 window.addEventListener('scroll', onScrollOrResize, {
158 passive: true,
159 capture: true,
160 });
161 window.addEventListener('resize', onScrollOrResize);
162 return () => {
163 window.removeEventListener('scroll', onScrollOrResize, {
164 capture: true,
165 });
166 window.removeEventListener('resize', onScrollOrResize);
167 };
168 }, [el]);
169
170 useEffect(() => {
171 if (!el.current) return;
172
173 const resizeObserver = new ResizeObserver(() => {
174 if (!el.current) return;
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;
178 setRect({ top, left, width, height });
179 });
180
181 resizeObserver.observe(el.current);
182
183 return () => {
184 resizeObserver.disconnect();
185 };
186 }, [el.current]);
187
188 // ResizeObserver misses a replaced node and an ancestor reflow, so the
189 // outline stays on the old box.
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(() => {
225 if (!enabled) return;
226 const root = document.querySelector('.wp-site-blocks');
227 if (!root) return;
228 root.classList.add('extendify-agent-highlighter-mode');
229 return () => root.classList.remove('extendify-agent-highlighter-mode');
230 }, [enabled]);
231
232 useEffect(() => {
233 if (!busy) return;
234 const root = document.querySelector('.wp-site-blocks');
235 if (!root) return;
236 root.classList.add('extendify-agent-busy');
237 return () => root.classList.remove('extendify-agent-busy');
238 }, [busy]);
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
248 if (!enabled || !rect || !mountNode) return null;
249
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,
259 };
260 const transition = instant
261 ? { duration: 0 }
262 : {
263 type: 'spring',
264 stiffness: 700,
265 damping: 40,
266 mass: 0.25,
267 };
268 return createPortal(
269 <>
270 {block && !busy ? (
271 // biome-ignore lint: Using <button> is complicated with unknown themes
272 <div
273 role="button"
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 )}
278 tabIndex={0}
279 onClick={clearBlock}
280 onKeyDown={clearBlock}
281 style={{
282 top,
283 left: width / 2 + left - 12,
284 backgroundColor: 'var(--wp--preset--color--primary, red)',
285 color: 'var(--wp--preset--color--background, white)',
286 }}
287 >
288 <Icon
289 className="pointer-events-none fill-current leading-none"
290 icon={close}
291 size={18}
292 />
293 <span className="sr-only">
294 {__('Remove highlight', 'extendify-local')}
295 </span>
296 </div>
297 ) : null}
298 <motion.div
299 initial={false}
300 aria-hidden
301 animate={animate}
302 transition={transition}
303 className="fixed z-8 outline-dashed outline-4"
304 style={{
305 top: 0,
306 left: 0,
307 willChange: 'transform,width,height,opacity',
308 outlineColor: 'var(--wp--preset--color--primary, red)',
309 boxShadow: ringNeeded ? MEDIA_RING : undefined,
310 // This mount sits outside the scroller; 'auto' eats page scroll.
311 pointerEvents: 'none',
312 }}
313 />
314 </>,
315 mountNode,
316 );
317 };
318