PluginProbe
Extendify / trunk
Extendify vtrunk
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.1 → trunk 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,