Current File : /home/mdkeenpw/public_html/wp-content/plugins/extendify/src/Agent/components/DOMHighlighter.jsx |
import { Tooltip } from '@wordpress/components';
import { createPortal, useEffect, useRef, useState } from '@wordpress/element';
import { __, isRTL } from '@wordpress/i18n';
import { Icon, close } from '@wordpress/icons';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import { usePortal } from '@agent/hooks/usePortal';
import { useWorkflowStore } from '@agent/state/workflows';
const scopedTo = 'main.wp-block-group';
// TODO: maybe scope this a bit better?
// TODO: text in a p tag has no block class - need to handle that
const classSelectors = ['[class^="wp-block-"]', '[class*=" wp-block-"]'];
const ignored = [
'wp-block-image',
'wp-block-cover',
'wp-block-video',
'wp-block-columns',
'wp-block-buttons',
];
// Build selector from class list: ".foo, .bar"
const selector = classSelectors.map((c) => `${scopedTo} ${c}`).join(', ');
export const DOMHighlighter = ({ busy = false }) => {
const [rect, setRect] = useState(null);
const mountNode = usePortal('extendify-agent-dom-mount');
const raf = useRef(null);
const el = useRef(null);
const { getWorkflowsByFeature, block, setBlock } = useWorkflowStore();
const enabled = getWorkflowsByFeature({ requires: ['block'] })?.length > 0;
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,
);
}, []);
useEffect(() => {
if (busy || block) return;
if (!mountNode || !enabled) return setRect(null);
const onMove = (e) => {
if (raf.current) return;
raf.current = requestAnimationFrame(() => {
raf.current = null;
const target = e.target;
if (!target) return setRect(null);
const match = target.closest(selector);
if (!match) return setRect(null);
// It should have a block ID
if (!match.getAttribute('data-extendify-agent-block-id')) {
return setRect(null);
}
// Ignore some blocks like columns
if (ignored.some((c) => match.classList.contains(c))) {
return setRect(null);
}
// TODO: later images?
const hasMeaningfulText = /\S/.test(
(match.textContent || '').replace(/\u200B/g, ''),
);
const innerBlockCount = Array.from(
match.querySelectorAll(classSelectors.join(', ')),
).filter((el) => !ignored.some((c) => el.classList.contains(c))).length;
// Keep complexity low for now
if (!hasMeaningfulText || innerBlockCount > 5) {
return setRect(null);
}
el.current = match;
const r = match.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return setRect(null);
const { top, left, width, height } = r;
setRect({ top, left, width, height });
});
};
window.addEventListener('mousemove', onMove, { passive: true });
return () => {
window.removeEventListener('mousemove', onMove);
if (raf.current) cancelAnimationFrame(raf.current);
};
}, [busy, mountNode, enabled, block]);
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 });
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize);
window.removeEventListener('resize', onScrollOrResize);
};
}, [el]);
useEffect(() => {
if (!enabled) return;
const onClickCapture = (e) => {
if (!rect) return;
// find the real element under cursor
const stack = document.elementsFromPoint(e.clientX, e.clientY) || [];
if (!stack[0]) return;
if (!stack[0].closest('.wp-site-blocks')) return;
e.preventDefault();
e.stopPropagation();
const match = stack[0].closest(selector);
if (!match) return;
setBlock(match.getAttribute('data-extendify-agent-block-id'));
document.querySelector('#extendify-agent-chat-textarea')?.focus();
};
// capture=true so we stop clicks before app code or link navigation
window.addEventListener('click', onClickCapture, { capture: true });
return () =>
window.removeEventListener('click', onClickCapture, { capture: true });
}, [enabled, setBlock, rect]);
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 ? (
<Tooltip text={__('Remove highlight', 'extendify-local')}>
<div
role="button"
className={classNames(
'fixed z-higher h-6 w-6 -translate-y-3 cursor-pointer select-none items-center justify-center rounded-full text-center font-bold ring-1 ring-black',
{
'-translate-x-6 rtl:translate-x-6':
left + width >= window.innerWidth - 12,
'-translate-x-3 rtl:translate-x-3':
left + width < window.innerWidth,
},
)}
onClick={() => setBlock(null)}
style={{
top,
left: isRTL() ? 'auto' : left + width,
right: isRTL() ? left : 'auto',
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>
</Tooltip>
) : null}
<motion.div
initial={false}
aria-hidden
animate={animate}
transition={transition}
className="pointer-events-none fixed z-high 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)',
}}
/>
</>,
mountNode,
);
};