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
← All changes | src/Agent/components/DOMHighlighter.jsx +87 -41 3.1.23.2.1 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,27 @@
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 = ({ busy = false, working = false }) => {
22 29 const [rect, setRect] = useState(null);
30 + const [ringNeeded, setRingNeeded] = useState(false);
23 31 const mountNode = usePortal('extendify-agent-dom-mount');
24 32 const el = useRef(null);
25 33 const { getWorkflowsByFeature } = useWorkflowStore();
26 34 const block = useQuickEditStore((s) => s.agentBlock);
27 - const selected = useQuickEditStore((s) => s.selected);
28 35 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
29 36 const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode);
30 37 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 38
39 39 const clearBlock = useCallback(() => {
40 40 setBlock(null);
41 41 setRect(null);
@@ -44,14 +44,13 @@
44 44
45 45 useEffect(() => {
46 46 if (!block?.id) return;
47 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 - };
48 + const queryArgs = blockCodeQueryArgs(
49 + block,
50 + window.extAgentData?.context?.postId,
51 + );
52 + if (!queryArgs) return;
54 53
55 54 const isAlive = { current: true };
56 55 (async () => {
57 56 const res = await apiFetch({
@@ -70,14 +69,12 @@
70 69 // Re-syncs the rect for programmatic block changes (e.g. Ask AI)
71 70 // and after the wp-site-blocks open/close transform settles.
72 71 useEffect(() => {
73 72 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 - );
73 + const match = findBlockEl(block.id, document, scopeOf(block));
78 74 if (!match) return;
79 75 el.current = match;
76 + setRingNeeded(needsContrastRing(match));
80 77
81 78 const measure = () => {
82 79 const r = match.getBoundingClientRect();
83 80 if (r.width <= 0 || r.height <= 0) return;
@@ -93,17 +90,26 @@
93 90 };
94 91 wsb?.addEventListener('transitionend', onTransitionEnd);
95 92 const t1 = window.setTimeout(measure, 80);
96 93 const t2 = window.setTimeout(measure, 360);
94 + const dropSettle = whenAnimationsSettle(match, measure);
97 95
98 96 return () => {
99 97 wsb?.removeEventListener('transitionend', onTransitionEnd);
100 98 window.clearTimeout(t1);
101 99 window.clearTimeout(t2);
100 + dropSettle();
102 101 };
103 102 }, [block]);
104 103
104 + // The chip's X clears the block without firing the event.
105 105 useEffect(() => {
106 + if (block?.id) return;
107 + setRect(null);
108 + el.current = null;
109 + }, [block]);
110 +
111 + useEffect(() => {
106 112 const handle = () => {
107 113 setRect(null);
108 114 el.current = null;
109 115 };
@@ -114,8 +120,30 @@
114 120 handle,
115 121 );
116 122 }, []);
117 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 +
118 146 // Use capture phase for `scroll` so we hear it on any scrollable
119 147 // ancestor (e.g. wp-site-blocks when something repositions it as
120 148 // the page scroll container). Bubble-phase `scroll` doesn't
121 149 // propagate, so a window-only listener misses those.
@@ -122,9 +150,10 @@
122 150 useEffect(() => {
123 151 const onScrollOrResize = () => {
124 152 if (!el.current) return;
125 153 const { top, left, width, height } = el.current.getBoundingClientRect();
126 - setRect({ top, left, width, height });
154 + // Animating this re-targets the spring mid-scroll, so it never lands.
155 + setRect({ top, left, width, height, instant: true });
127 156 };
128 157 window.addEventListener('scroll', onScrollOrResize, {
129 158 passive: true,
130 159 capture: true,
@@ -143,8 +172,10 @@
143 172
144 173 const resizeObserver = new ResizeObserver(() => {
145 174 if (!el.current) return;
146 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;
147 178 setRect({ top, left, width, height });
148 179 });
149 180
150 181 resizeObserver.observe(el.current);
@@ -153,20 +184,14 @@
153 184 resizeObserver.disconnect();
154 185 };
155 186 }, [el.current]);
156 187
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.
188 + // ResizeObserver misses a replaced node and an ancestor reflow, so the
189 + // outline stays on the old box.
163 190 useEffect(() => {
164 191 if (!block?.id) return;
165 192 const root = document.querySelector('.wp-site-blocks');
166 193 if (!root) return;
167 - const attr = block.target || 'data-extendify-agent-block-id';
168 - const sel = `[${attr}="${CSS.escape(String(block.id))}"]`;
169 194
170 195 let rafId = 0;
171 196 const observer = new MutationObserver(() => {
172 197 if (rafId) return;
@@ -171,9 +196,9 @@
171 196 const observer = new MutationObserver(() => {
172 197 if (rafId) return;
173 198 rafId = window.requestAnimationFrame(() => {
174 199 rafId = 0;
175 - const match = document.querySelector(sel);
200 + const match = findBlockEl(block.id, document, scopeOf(block));
176 201 if (!match) return;
177 202 el.current = match;
178 203 const r = match.getBoundingClientRect();
179 204 if (r.width <= 0 || r.height <= 0) return;
@@ -211,18 +236,36 @@
211 236 root.classList.add('extendify-agent-busy');
212 237 return () => root.classList.remove('extendify-agent-busy');
213 238 }, [busy]);
214 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 +
215 248 if (!enabled || !rect || !mountNode) return null;
216 249
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,
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,
224 259 };
260 + const transition = instant
261 + ? { duration: 0 }
262 + : {
263 + type: 'spring',
264 + stiffness: 700,
265 + damping: 40,
266 + mass: 0.25,
267 + };
225 268 return createPortal(
226 269 <>
227 270 {block && !busy ? (
228 271 // biome-ignore lint: Using <button> is complicated with unknown themes
@@ -227,11 +270,12 @@
227 270 {block && !busy ? (
228 271 // biome-ignore lint: Using <button> is complicated with unknown themes
229 272 <div
230 273 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 - }
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 + )}
234 278 tabIndex={0}
235 279 onClick={clearBlock}
236 280 onKeyDown={clearBlock}
237 281 style={{
@@ -255,15 +299,17 @@
255 299 initial={false}
256 300 aria-hidden
257 301 animate={animate}
258 302 transition={transition}
259 - className="fixed z-8 mix-blend-hard-light outline-dashed outline-4"
303 + className="fixed z-8 outline-dashed outline-4"
260 304 style={{
261 305 top: 0,
262 306 left: 0,
263 307 willChange: 'transform,width,height,opacity',
264 308 outlineColor: 'var(--wp--preset--color--primary, red)',
265 - pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none',
309 + boxShadow: ringNeeded ? MEDIA_RING : undefined,
310 + // This mount sits outside the scroller; 'auto' eats page scroll.
311 + pointerEvents: 'none',
266 312 }}
267 313 />
268 314 </>,
269 315 mountNode,