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

288 lines 8.8 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 apiFetch from '@wordpress/api-fetch';
4 import {
5 createPortal,
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10 } from '@wordpress/element';
11 import { __ } from '@wordpress/i18n';
12 import { close, Icon } from '@wordpress/icons';
13 import { addQueryArgs } from '@wordpress/url';
14 import { motion } from 'framer-motion';
15
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';
24
25 export const DOMHighlighter = ({ busy = false }) => {
26 const [rect, setRect] = useState(null);
27 const mountNode = usePortal('extendify-agent-dom-mount');
28 const raf = useRef(null);
29 const el = useRef(null);
30 const { getWorkflowsByFeature, block, setBlock, setBlockCode } =
31 useWorkflowStore();
32 const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
33
34 const clearBlock = useCallback(() => {
35 setBlock(null);
36 setRect(null);
37 el.current = null;
38 document.querySelector(HIGHLIGHTER_CLS)?.classList.remove(HIGHLIGHTER_CLS);
39 document
40 .querySelector(`[${SELECTED_ATTR}]`)
41 ?.removeAttribute(SELECTED_ATTR);
42 }, [setBlock, setRect]);
43
44 useEffect(() => {
45 if (!block?.id) return;
46 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 };
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 useEffect(() => {
70 const handle = () => {
71 setRect(null);
72 el.current = null;
73 };
74 window.addEventListener('extendify-agent:remove-block-highlight', handle);
75 return () =>
76 window.removeEventListener(
77 'extendify-agent:remove-block-highlight',
78 handle,
79 );
80 }, []);
81
82 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 });
117 };
118
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
126 useEffect(() => {
127 const onScrollOrResize = () => {
128 if (!el.current) return;
129 const { top, left, width, height } = el.current.getBoundingClientRect();
130 setRect({ top, left, width, height });
131 };
132 window.addEventListener('scroll', onScrollOrResize, { passive: true });
133 window.addEventListener('resize', onScrollOrResize);
134 return () => {
135 window.removeEventListener('scroll', onScrollOrResize);
136 window.removeEventListener('resize', onScrollOrResize);
137 };
138 }, [el]);
139
140 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 if (!el.current) return;
201
202 const resizeObserver = new ResizeObserver(() => {
203 if (!el.current) return;
204 const { top, left, width, height } = el.current.getBoundingClientRect();
205 setRect({ top, left, width, height });
206 });
207
208 resizeObserver.observe(el.current);
209
210 return () => {
211 resizeObserver.disconnect();
212 };
213 }, [el.current]);
214
215 useEffect(() => {
216 if (!enabled) return;
217 const root = document.querySelector('.wp-site-blocks');
218 if (!root) return;
219 root.classList.add('extendify-agent-highlighter-mode');
220 return () => root.classList.remove('extendify-agent-highlighter-mode');
221 }, [enabled]);
222
223 useEffect(() => {
224 if (!busy) return;
225 const root = document.querySelector('.wp-site-blocks');
226 if (!root) return;
227 root.classList.add('extendify-agent-busy');
228 return () => root.classList.remove('extendify-agent-busy');
229 }, [busy]);
230
231 if (!enabled || !rect || !mountNode) return null;
232
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,
240 };
241 return createPortal(
242 <>
243 {block && !busy ? (
244 // biome-ignore lint: Using <button> is complicated with unknown themes
245 <div
246 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 }
250 tabIndex={0}
251 onClick={() => setBlock(null)}
252 onKeyDown={() => setBlock(null)}
253 style={{
254 top,
255 left: width / 2 + left - 12,
256 backgroundColor: 'var(--wp--preset--color--primary, red)',
257 color: 'var(--wp--preset--color--background, white)',
258 }}
259 >
260 <Icon
261 className="pointer-events-none fill-current leading-none"
262 icon={close}
263 size={18}
264 />
265 <span className="sr-only">
266 {__('Remove highlight', 'extendify-local')}
267 </span>
268 </div>
269 ) : null}
270 <motion.div
271 initial={false}
272 aria-hidden
273 animate={animate}
274 transition={transition}
275 className="fixed z-8 mix-blend-hard-light outline-dashed outline-4"
276 style={{
277 top: 0,
278 left: 0,
279 willChange: 'transform,width,height,opacity',
280 outlineColor: 'var(--wp--preset--color--primary, red)',
281 pointerEvents: block && !busy ? 'auto' : 'none',
282 }}
283 />
284 </>,
285 mountNode,
286 );
287 };
288