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
← All changes | src/Agent/components/DOMHighlighter.jsx +108 -41 3.1.2 → 3.2.2 View file →
@@ -1,6 +1,10 @@
1 1 import { usePortal } from '@agent/hooks/usePortal';
2 +import { blockCodeQueryArgs } from '@agent/lib/block-code';
3 +import { findBlockEl, scopeOf } from '@agent/lib/block-el';
2 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';
3 7 import { useQuickEditStore } from '@quick-edit/state/store';
4 8 import apiFetch from '@wordpress/api-fetch';
5 9 import {
6 10 createPortal,
@@ -11,31 +15,32 @@
11 15 } from '@wordpress/element';
12 16 import { __ } from '@wordpress/i18n';
13 17 import { close, Icon } from '@wordpress/icons';
14 18 import { addQueryArgs } from '@wordpress/url';
19 +import classNames from 'classnames';
15 20 import { motion } from 'framer-motion';
16 21
22 +const MIN_OUTLINE_SIZE = 10;
23 +
17 24 // Render-only after the selector unification: `agentBlock` is set
18 25 // by Quick Edit's Ask AI flow (or future workflows), and this component
19 26 // draws the outline + X-close indicator. Hover-bar owns hover + click
20 27 // selection on the live page; DOMHighlighter no longer listens for either.
21 -export const DOMHighlighter = ({ busy = false }) => {
28 +export const DOMHighlighter = ({
29 + busy = false,
30 + working = false,
31 + waiting = false,
32 + taskActive = false,
33 +}) => {
22 34 const [rect, setRect] = useState(null);
35 + const [ringNeeded, setRingNeeded] = useState(false);
23 36 const mountNode = usePortal('extendify-agent-dom-mount');
24 37 const el = useRef(null);
25 38 const { getWorkflowsByFeature } = useWorkflowStore();
26 39 const block = useQuickEditStore((s) => s.agentBlock);
27 - const selected = useQuickEditStore((s) => s.selected);
28 40 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
29 41 const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode);
30 42 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 43
39 44 const clearBlock = useCallback(() => {
40 45 setBlock(null);
41 46 setRect(null);
@@ -44,14 +49,13 @@
44 49
45 50 useEffect(() => {
46 51 if (!block?.id) return;
47 52 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 - };
53 + const queryArgs = blockCodeQueryArgs(
54 + block,
55 + window.extAgentData?.context?.postId,
56 + );
57 + if (!queryArgs) return;
54 58
55 59 const isAlive = { current: true };
56 60 (async () => {
57 61 const res = await apiFetch({
@@ -70,14 +74,12 @@
70 74 // Re-syncs the rect for programmatic block changes (e.g. Ask AI)
71 75 // and after the wp-site-blocks open/close transform settles.
72 76 useEffect(() => {
73 77 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 + const match = findBlockEl(block.id, document, scopeOf(block));
78 79 if (!match) return;
79 80 el.current = match;
81 + setRingNeeded(needsContrastRing(match));
80 82
81 83 const measure = () => {
82 84 const r = match.getBoundingClientRect();
83 85 if (r.width <= 0 || r.height <= 0) return;
@@ -93,17 +95,26 @@
93 95 };
94 96 wsb?.addEventListener('transitionend', onTransitionEnd);
95 97 const t1 = window.setTimeout(measure, 80);
96 98 const t2 = window.setTimeout(measure, 360);
99 + const dropSettle = whenAnimationsSettle(match, measure);
97 100
98 101 return () => {
99 102 wsb?.removeEventListener('transitionend', onTransitionEnd);
100 103 window.clearTimeout(t1);
101 104 window.clearTimeout(t2);
105 + dropSettle();
102 106 };
103 107 }, [block]);
104 108
109 + // The chip's X clears the block without firing the event.
105 110 useEffect(() => {
111 + if (block?.id) return;
112 + setRect(null);
113 + el.current = null;
114 + }, [block]);
115 +
116 + useEffect(() => {
106 117 const handle = () => {
107 118 setRect(null);
108 119 el.current = null;
109 120 };
@@ -114,8 +125,30 @@
114 125 handle,
115 126 );
116 127 }, []);
117 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 +
118 151 // Use capture phase for `scroll` so we hear it on any scrollable
119 152 // ancestor (e.g. wp-site-blocks when something repositions it as
120 153 // the page scroll container). Bubble-phase `scroll` doesn't
121 154 // propagate, so a window-only listener misses those.
@@ -122,9 +155,10 @@
122 155 useEffect(() => {
123 156 const onScrollOrResize = () => {
124 157 if (!el.current) return;
125 158 const { top, left, width, height } = el.current.getBoundingClientRect();
126 - setRect({ top, left, width, height });
159 + // Animating this re-targets the spring mid-scroll, so it never lands.
160 + setRect({ top, left, width, height, instant: true });
127 161 };
128 162 window.addEventListener('scroll', onScrollOrResize, {
129 163 passive: true,
130 164 capture: true,
@@ -143,8 +177,10 @@
143 177
144 178 const resizeObserver = new ResizeObserver(() => {
145 179 if (!el.current) return;
146 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;
147 183 setRect({ top, left, width, height });
148 184 });
149 185
150 186 resizeObserver.observe(el.current);
@@ -153,20 +189,14 @@
153 189 resizeObserver.disconnect();
154 190 };
155 191 }, [el.current]);
156 192
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.
193 + // ResizeObserver misses a replaced node and an ancestor reflow, so the
194 + // outline stays on the old box.
163 195 useEffect(() => {
164 196 if (!block?.id) return;
165 197 const root = document.querySelector('.wp-site-blocks');
166 198 if (!root) return;
167 - const attr = block.target || 'data-extendify-agent-block-id';
168 - const sel = `[${attr}="${CSS.escape(String(block.id))}"]`;
169 199
170 200 let rafId = 0;
171 201 const observer = new MutationObserver(() => {
172 202 if (rafId) return;
@@ -171,9 +201,9 @@
171 201 const observer = new MutationObserver(() => {
172 202 if (rafId) return;
173 203 rafId = window.requestAnimationFrame(() => {
174 204 rafId = 0;
175 - const match = document.querySelector(sel);
205 + const match = findBlockEl(block.id, document, scopeOf(block));
176 206 if (!match) return;
177 207 el.current = match;
178 208 const r = match.getBoundingClientRect();
179 209 if (r.width <= 0 || r.height <= 0) return;
@@ -211,18 +241,52 @@
211 241 root.classList.add('extendify-agent-busy');
212 242 return () => root.classList.remove('extendify-agent-busy');
213 243 }, [busy]);
214 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 +
215 269 if (!enabled || !rect || !mountNode) return null;
216 270
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,
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,
224 280 };
281 + const transition = instant
282 + ? { duration: 0 }
283 + : {
284 + type: 'spring',
285 + stiffness: 700,
286 + damping: 40,
287 + mass: 0.25,
288 + };
225 289 return createPortal(
226 290 <>
227 291 {block && !busy ? (
228 292 // biome-ignore lint: Using <button> is complicated with unknown themes
@@ -227,11 +291,12 @@
227 291 {block && !busy ? (
228 292 // biome-ignore lint: Using <button> is complicated with unknown themes
229 293 <div
230 294 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 - }
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 + )}
234 299 tabIndex={0}
235 300 onClick={clearBlock}
236 301 onKeyDown={clearBlock}
237 302 style={{
@@ -255,15 +320,17 @@
255 320 initial={false}
256 321 aria-hidden
257 322 animate={animate}
258 323 transition={transition}
259 - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4"
324 + className="fixed z-8 outline-dashed outline-4"
260 325 style={{
261 326 top: 0,
262 327 left: 0,
263 328 willChange: 'transform,width,height,opacity',
264 329 outlineColor: 'var(--wp--preset--color--primary, red)',
265 - pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none',
330 + boxShadow: ringNeeded ? MEDIA_RING : undefined,
331 + // This mount sits outside the scroller; 'auto' eats page scroll.
332 + pointerEvents: 'none',
266 333 }}
267 334 />
268 335 </>,
269 336 mountNode,