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

272 lines 8.3 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 { useQuickEditStore } from '@quick-edit/state/store';
4 import apiFetch from '@wordpress/api-fetch';
5 import {
6 createPortal,
7 useCallback,
8 useEffect,
9 useRef,
10 useState,
11 } from '@wordpress/element';
12 import { __ } from '@wordpress/i18n';
13 import { close, Icon } from '@wordpress/icons';
14 import { addQueryArgs } from '@wordpress/url';
15 import { motion } from 'framer-motion';
16
17 // Render-only after the selector unification: `agentBlock` is set
18 // by Quick Edit's Ask AI flow (or future workflows), and this component
19 // draws the outline + X-close indicator. Hover-bar owns hover + click
20 // selection on the live page; DOMHighlighter no longer listens for either.
21 export const DOMHighlighter = ({ busy = false }) => {
22 const [rect, setRect] = useState(null);
23 const mountNode = usePortal('extendify-agent-dom-mount');
24 const el = useRef(null);
25 const { getWorkflowsByFeature } = useWorkflowStore();
26 const block = useQuickEditStore((s) => s.agentBlock);
27 const selected = useQuickEditStore((s) => s.selected);
28 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
29 const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode);
30 const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
31 // When the QE canvas is mounted on the same block the agent is staged
32 // on, this overlay must NOT intercept clicks — otherwise text-selection
33 // inside the contenteditable underneath is eaten by the outline.
34 const sameBlockAsQE =
35 selected?.blockId != null &&
36 block?.id != null &&
37 String(selected.blockId) === String(block.id);
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 postId = window.extAgentData?.context?.postId;
49 if (!postId) return;
50 const queryArgs = {
51 postId: String(postId),
52 blockId: String(block.id),
53 };
54
55 const isAlive = { current: true };
56 (async () => {
57 const res = await apiFetch({
58 path: addQueryArgs(`extendify/v1/agent/get-block-code`, queryArgs),
59 signal: ac.signal,
60 }).catch(() => ({})); // Agent will get it later if fails
61 if (!res.block || !isAlive.current || ac.signal.aborted) return;
62 setBlockCode(res.block);
63 })();
64 return () => {
65 ac.abort();
66 isAlive.current = false;
67 };
68 }, [setBlockCode, block]);
69
70 // Re-syncs the rect for programmatic block changes (e.g. Ask AI)
71 // and after the wp-site-blocks open/close transform settles.
72 useEffect(() => {
73 if (!block?.id) return;
74 const attr = block.target || 'data-extendify-agent-block-id';
75 const match = document.querySelector(
76 `[${attr}="${CSS.escape(String(block.id))}"]`,
77 );
78 if (!match) return;
79 el.current = match;
80
81 const measure = () => {
82 const r = match.getBoundingClientRect();
83 if (r.width <= 0 || r.height <= 0) return;
84 setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
85 };
86 measure();
87
88 // transitionend covers the panel-open/close transform; the
89 // timeouts are belt-and-braces if transitions are disabled.
90 const wsb = document.querySelector('.wp-site-blocks');
91 const onTransitionEnd = (e) => {
92 if (e.propertyName === 'transform') measure();
93 };
94 wsb?.addEventListener('transitionend', onTransitionEnd);
95 const t1 = window.setTimeout(measure, 80);
96 const t2 = window.setTimeout(measure, 360);
97
98 return () => {
99 wsb?.removeEventListener('transitionend', onTransitionEnd);
100 window.clearTimeout(t1);
101 window.clearTimeout(t2);
102 };
103 }, [block]);
104
105 useEffect(() => {
106 const handle = () => {
107 setRect(null);
108 el.current = null;
109 };
110 window.addEventListener('extendify-agent:remove-block-highlight', handle);
111 return () =>
112 window.removeEventListener(
113 'extendify-agent:remove-block-highlight',
114 handle,
115 );
116 }, []);
117
118 // Use capture phase for `scroll` so we hear it on any scrollable
119 // ancestor (e.g. wp-site-blocks when something repositions it as
120 // the page scroll container). Bubble-phase `scroll` doesn't
121 // propagate, so a window-only listener misses those.
122 useEffect(() => {
123 const onScrollOrResize = () => {
124 if (!el.current) return;
125 const { top, left, width, height } = el.current.getBoundingClientRect();
126 setRect({ top, left, width, height });
127 };
128 window.addEventListener('scroll', onScrollOrResize, {
129 passive: true,
130 capture: true,
131 });
132 window.addEventListener('resize', onScrollOrResize);
133 return () => {
134 window.removeEventListener('scroll', onScrollOrResize, {
135 capture: true,
136 });
137 window.removeEventListener('resize', onScrollOrResize);
138 };
139 }, [el]);
140
141 useEffect(() => {
142 if (!el.current) return;
143
144 const resizeObserver = new ResizeObserver(() => {
145 if (!el.current) return;
146 const { top, left, width, height } = el.current.getBoundingClientRect();
147 setRect({ top, left, width, height });
148 });
149
150 resizeObserver.observe(el.current);
151
152 return () => {
153 resizeObserver.disconnect();
154 };
155 }, [el.current]);
156
157 // Workflows can mutate the page while the outline is up: a tool that
158 // re-renders the block produces a new DOM node with the same
159 // data-extendify-agent-block-id, and ancestor reflows can shift the
160 // element without changing its own size (ResizeObserver misses both).
161 // Re-query and re-measure on any wp-site-blocks subtree mutation,
162 // rAF-debounced so a burst of mutations costs one measurement.
163 useEffect(() => {
164 if (!block?.id) return;
165 const root = document.querySelector('.wp-site-blocks');
166 if (!root) return;
167 const attr = block.target || 'data-extendify-agent-block-id';
168 const sel = `[${attr}="${CSS.escape(String(block.id))}"]`;
169
170 let rafId = 0;
171 const observer = new MutationObserver(() => {
172 if (rafId) return;
173 rafId = window.requestAnimationFrame(() => {
174 rafId = 0;
175 const match = document.querySelector(sel);
176 if (!match) return;
177 el.current = match;
178 const r = match.getBoundingClientRect();
179 if (r.width <= 0 || r.height <= 0) return;
180 setRect({
181 top: r.top,
182 left: r.left,
183 width: r.width,
184 height: r.height,
185 });
186 });
187 });
188 observer.observe(root, {
189 childList: true,
190 subtree: true,
191 characterData: true,
192 });
193 return () => {
194 observer.disconnect();
195 if (rafId) window.cancelAnimationFrame(rafId);
196 };
197 }, [block]);
198
199 useEffect(() => {
200 if (!enabled) return;
201 const root = document.querySelector('.wp-site-blocks');
202 if (!root) return;
203 root.classList.add('extendify-agent-highlighter-mode');
204 return () => root.classList.remove('extendify-agent-highlighter-mode');
205 }, [enabled]);
206
207 useEffect(() => {
208 if (!busy) return;
209 const root = document.querySelector('.wp-site-blocks');
210 if (!root) return;
211 root.classList.add('extendify-agent-busy');
212 return () => root.classList.remove('extendify-agent-busy');
213 }, [busy]);
214
215 if (!enabled || !rect || !mountNode) return null;
216
217 const { top, left, width, height } = rect;
218 const animate = { x: left, y: top, width, height, opacity: 1 };
219 const transition = {
220 type: 'spring',
221 stiffness: 700,
222 damping: 40,
223 mass: 0.25,
224 };
225 return createPortal(
226 <>
227 {block && !busy ? (
228 // biome-ignore lint: Using <button> is complicated with unknown themes
229 <div
230 role="button"
231 className={
232 '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'
233 }
234 tabIndex={0}
235 onClick={clearBlock}
236 onKeyDown={clearBlock}
237 style={{
238 top,
239 left: width / 2 + left - 12,
240 backgroundColor: 'var(--wp--preset--color--primary, red)',
241 color: 'var(--wp--preset--color--background, white)',
242 }}
243 >
244 <Icon
245 className="pointer-events-none fill-current leading-none"
246 icon={close}
247 size={18}
248 />
249 <span className="sr-only">
250 {__('Remove highlight', 'extendify-local')}
251 </span>
252 </div>
253 ) : null}
254 <motion.div
255 initial={false}
256 aria-hidden
257 animate={animate}
258 transition={transition}
259 className="fixed z-8 mix-blend-hard-light outline-dashed outline-4"
260 style={{
261 top: 0,
262 left: 0,
263 willChange: 'transform,width,height,opacity',
264 outlineColor: 'var(--wp--preset--color--primary, red)',
265 pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none',
266 }}
267 />
268 </>,
269 mountNode,
270 );
271 };
272