File: /home/iddeczhh/public_html/wp-content/plugins/extendify/src/Agent/components/DOMHighlighter.jsx
import { usePortal } from '@agent/hooks/usePortal';
import { useWorkflowStore } from '@agent/state/workflows';
import { useQuickEditStore } from '@quick-edit/state/store';
import apiFetch from '@wordpress/api-fetch';
import {
createPortal,
useCallback,
useEffect,
useRef,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { close, Icon } from '@wordpress/icons';
import { addQueryArgs } from '@wordpress/url';
import { motion } from 'framer-motion';
// Render-only after the selector unification: `agentBlock` is set
// by Quick Edit's Ask AI flow (or future workflows), and this component
// draws the outline + X-close indicator. Hover-bar owns hover + click
// selection on the live page; DOMHighlighter no longer listens for either.
export const DOMHighlighter = ({ busy = false }) => {
const [rect, setRect] = useState(null);
const mountNode = usePortal('extendify-agent-dom-mount');
const el = useRef(null);
const { getWorkflowsByFeature } = useWorkflowStore();
const block = useQuickEditStore((s) => s.agentBlock);
const selected = useQuickEditStore((s) => s.selected);
const setBlock = useQuickEditStore((s) => s.setAgentBlock);
const setBlockCode = useQuickEditStore((s) => s.setAgentBlockCode);
const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
// When the QE canvas is mounted on the same block the agent is staged
// on, this overlay must NOT intercept clicks — otherwise text-selection
// inside the contenteditable underneath is eaten by the outline.
const sameBlockAsQE =
selected?.blockId != null &&
block?.id != null &&
String(selected.blockId) === String(block.id);
const clearBlock = useCallback(() => {
setBlock(null);
setRect(null);
el.current = null;
}, [setBlock, setRect]);
useEffect(() => {
if (!block?.id) return;
const ac = new AbortController();
const postId = window.extAgentData?.context?.postId;
if (!postId) return;
const queryArgs = {
postId: String(postId),
blockId: String(block.id),
};
const isAlive = { current: true };
(async () => {
const res = await apiFetch({
path: addQueryArgs(`extendify/v1/agent/get-block-code`, queryArgs),
signal: ac.signal,
}).catch(() => ({})); // Agent will get it later if fails
if (!res.block || !isAlive.current || ac.signal.aborted) return;
setBlockCode(res.block);
})();
return () => {
ac.abort();
isAlive.current = false;
};
}, [setBlockCode, block]);
// Re-syncs the rect for programmatic block changes (e.g. Ask AI)
// and after the wp-site-blocks open/close transform settles.
useEffect(() => {
if (!block?.id) return;
const attr = block.target || 'data-extendify-agent-block-id';
const match = document.querySelector(
`[${attr}="${CSS.escape(String(block.id))}"]`,
);
if (!match) return;
el.current = match;
const measure = () => {
const r = match.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
};
measure();
// transitionend covers the panel-open/close transform; the
// timeouts are belt-and-braces if transitions are disabled.
const wsb = document.querySelector('.wp-site-blocks');
const onTransitionEnd = (e) => {
if (e.propertyName === 'transform') measure();
};
wsb?.addEventListener('transitionend', onTransitionEnd);
const t1 = window.setTimeout(measure, 80);
const t2 = window.setTimeout(measure, 360);
return () => {
wsb?.removeEventListener('transitionend', onTransitionEnd);
window.clearTimeout(t1);
window.clearTimeout(t2);
};
}, [block]);
useEffect(() => {
const handle = () => {
setRect(null);
el.current = null;
};
window.addEventListener('extendify-agent:remove-block-highlight', handle);
return () =>
window.removeEventListener(
'extendify-agent:remove-block-highlight',
handle,
);
}, []);
// Use capture phase for `scroll` so we hear it on any scrollable
// ancestor (e.g. wp-site-blocks when something repositions it as
// the page scroll container). Bubble-phase `scroll` doesn't
// propagate, so a window-only listener misses those.
useEffect(() => {
const onScrollOrResize = () => {
if (!el.current) return;
const { top, left, width, height } = el.current.getBoundingClientRect();
setRect({ top, left, width, height });
};
window.addEventListener('scroll', onScrollOrResize, {
passive: true,
capture: true,
});
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, {
capture: true,
});
window.removeEventListener('resize', onScrollOrResize);
};
}, [el]);
useEffect(() => {
if (!el.current) return;
const resizeObserver = new ResizeObserver(() => {
if (!el.current) return;
const { top, left, width, height } = el.current.getBoundingClientRect();
setRect({ top, left, width, height });
});
resizeObserver.observe(el.current);
return () => {
resizeObserver.disconnect();
};
}, [el.current]);
// Workflows can mutate the page while the outline is up: a tool that
// re-renders the block produces a new DOM node with the same
// data-extendify-agent-block-id, and ancestor reflows can shift the
// element without changing its own size (ResizeObserver misses both).
// Re-query and re-measure on any wp-site-blocks subtree mutation,
// rAF-debounced so a burst of mutations costs one measurement.
useEffect(() => {
if (!block?.id) return;
const root = document.querySelector('.wp-site-blocks');
if (!root) return;
const attr = block.target || 'data-extendify-agent-block-id';
const sel = `[${attr}="${CSS.escape(String(block.id))}"]`;
let rafId = 0;
const observer = new MutationObserver(() => {
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const match = document.querySelector(sel);
if (!match) return;
el.current = match;
const r = match.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
setRect({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
});
});
});
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (rafId) window.cancelAnimationFrame(rafId);
};
}, [block]);
useEffect(() => {
if (!enabled) return;
const root = document.querySelector('.wp-site-blocks');
if (!root) return;
root.classList.add('extendify-agent-highlighter-mode');
return () => root.classList.remove('extendify-agent-highlighter-mode');
}, [enabled]);
useEffect(() => {
if (!busy) return;
const root = document.querySelector('.wp-site-blocks');
if (!root) return;
root.classList.add('extendify-agent-busy');
return () => root.classList.remove('extendify-agent-busy');
}, [busy]);
if (!enabled || !rect || !mountNode) return null;
const { top, left, width, height } = rect;
const animate = { x: left, y: top, width, height, opacity: 1 };
const transition = {
type: 'spring',
stiffness: 700,
damping: 40,
mass: 0.25,
};
return createPortal(
<>
{block && !busy ? (
// biome-ignore lint: Using <button> is complicated with unknown themes
<div
role="button"
className={
'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'
}
tabIndex={0}
onClick={clearBlock}
onKeyDown={clearBlock}
style={{
top,
left: width / 2 + left - 12,
backgroundColor: 'var(--wp--preset--color--primary, red)',
color: 'var(--wp--preset--color--background, white)',
}}
>
<Icon
className="pointer-events-none fill-current leading-none"
icon={close}
size={18}
/>
<span className="sr-only">
{__('Remove highlight', 'extendify-local')}
</span>
</div>
) : null}
<motion.div
initial={false}
aria-hidden
animate={animate}
transition={transition}
className="fixed z-8 mix-blend-hard-light outline-dashed outline-4"
style={{
top: 0,
left: 0,
willChange: 'transform,width,height,opacity',
outlineColor: 'var(--wp--preset--color--primary, red)',
pointerEvents: block && !busy && !sameBlockAsQE ? 'auto' : 'none',
}}
/>
</>,
mountNode,
);
};