mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { useEffect, useRef, useCallback, RefObject } from 'react';
|
|
import { SelectedElement, useElementSelection } from '@/app/components/editor/ElementSelectionContext';
|
|
|
|
const OVERLAY_ID = '__clawd-select-overlay__';
|
|
const LABEL_ID = '__clawd-select-label__';
|
|
const STYLE_ID = '__clawd-select-style__';
|
|
|
|
const HIGHLIGHT_COLOR = '#3b82f6';
|
|
const HIGHLIGHT_BG = 'rgba(59, 130, 246, 0.08)';
|
|
const FLASH_COLOR = 'rgba(59, 130, 246, 0.25)';
|
|
const SELECTED_CLASS = '__clawd-selected__';
|
|
|
|
const KEY_CSS_PROPS = [
|
|
'display', 'position', 'width', 'height', 'margin', 'padding',
|
|
'color', 'background', 'backgroundColor', 'border', 'borderRadius',
|
|
'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'textAlign',
|
|
'flexDirection', 'justifyContent', 'alignItems', 'gap', 'gridTemplateColumns',
|
|
'overflow', 'opacity', 'zIndex', 'boxShadow',
|
|
];
|
|
|
|
function buildSelectorPath(el: Element): string {
|
|
const parts: string[] = [];
|
|
let current: Element | null = el;
|
|
|
|
while (current && current !== current.ownerDocument.documentElement) {
|
|
let selector = current.tagName.toLowerCase();
|
|
|
|
if (current.id) {
|
|
selector += `#${current.id}`;
|
|
parts.unshift(selector);
|
|
break;
|
|
}
|
|
|
|
const classes = Array.from(current.classList).filter((c) => !c.startsWith('__clawd'));
|
|
if (classes.length > 0) {
|
|
selector += '.' + classes.slice(0, 2).join('.');
|
|
} else {
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter((c) => c.tagName === current!.tagName);
|
|
if (siblings.length > 1) {
|
|
const idx = siblings.indexOf(current) + 1;
|
|
selector += `:nth-child(${idx})`;
|
|
}
|
|
}
|
|
}
|
|
|
|
parts.unshift(selector);
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return parts.join(' > ');
|
|
}
|
|
|
|
function getKeyStyles(el: Element): Record<string, string> {
|
|
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
if (!computed) return {};
|
|
|
|
const styles: Record<string, string> = {};
|
|
for (const prop of KEY_CSS_PROPS) {
|
|
const val = computed.getPropertyValue(prop);
|
|
if (val && val !== 'none' && val !== 'normal' && val !== 'auto' && val !== '0px' && val !== 'rgba(0, 0, 0, 0)') {
|
|
styles[prop] = val;
|
|
}
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
function truncateHTML(html: string, max = 5000): string {
|
|
if (html.length <= max) return html;
|
|
return html.slice(0, max) + '\n<!-- ... truncated -->';
|
|
}
|
|
|
|
async function captureScreenshot(
|
|
el: Element,
|
|
iframeDoc: Document,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const win = iframeDoc.defaultView;
|
|
if (!win) return undefined;
|
|
|
|
if (!(win as any).html2canvas) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const script = iframeDoc.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('Failed to load html2canvas'));
|
|
iframeDoc.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
const h2c = (win as any).html2canvas;
|
|
if (!h2c) return undefined;
|
|
|
|
const canvas = await h2c(el, {
|
|
backgroundColor: null,
|
|
scale: 1,
|
|
logging: false,
|
|
useCORS: true,
|
|
});
|
|
return canvas.toDataURL('image/png');
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function useIframeElementSelector(explicitIframeRef?: RefObject<HTMLIFrameElement | null>) {
|
|
const ctx = useElementSelection();
|
|
const hoveredRef = useRef<Element | null>(null);
|
|
const cleanupRef = useRef<(() => void) | null>(null);
|
|
const selectedDomEls = useRef<Map<string, Element>>(new Map());
|
|
|
|
const getIframe = useCallback((): HTMLIFrameElement | null => {
|
|
if (explicitIframeRef) return explicitIframeRef.current;
|
|
return ctx?.iframeRef.current ?? null;
|
|
}, [explicitIframeRef, ctx]);
|
|
|
|
const setupSelection = useCallback(() => {
|
|
if (!ctx) return;
|
|
const { addSelectedElement, updateSelectedElement } = ctx;
|
|
const iframe = getIframe();
|
|
if (!iframe) return;
|
|
|
|
const iframeDoc = iframe.contentDocument;
|
|
if (!iframeDoc) return;
|
|
|
|
const style = iframeDoc.createElement('style');
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
#${OVERLAY_ID} {
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 2147483647;
|
|
border: 2px solid ${HIGHLIGHT_COLOR};
|
|
background: ${HIGHLIGHT_BG};
|
|
transition: top 0.05s ease-out, left 0.05s ease-out, width 0.05s ease-out, height 0.05s ease-out;
|
|
box-sizing: border-box;
|
|
}
|
|
#${LABEL_ID} {
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 2147483647;
|
|
background: ${HIGHLIGHT_COLOR};
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
|
|
padding: 1px 5px;
|
|
border-radius: 0 0 4px 0;
|
|
white-space: nowrap;
|
|
line-height: 16px;
|
|
}
|
|
.${SELECTED_CLASS} {
|
|
outline: 2px solid ${HIGHLIGHT_COLOR} !important;
|
|
outline-offset: -1px !important;
|
|
box-shadow: inset 0 0 0 1000px ${HIGHLIGHT_BG} !important;
|
|
}
|
|
@keyframes __clawd-flash {
|
|
0% { background: ${FLASH_COLOR}; }
|
|
100% { background: ${HIGHLIGHT_BG}; }
|
|
}
|
|
`;
|
|
iframeDoc.head.appendChild(style);
|
|
|
|
const overlay = iframeDoc.createElement('div');
|
|
overlay.id = OVERLAY_ID;
|
|
overlay.style.display = 'none';
|
|
iframeDoc.body.appendChild(overlay);
|
|
|
|
const label = iframeDoc.createElement('div');
|
|
label.id = LABEL_ID;
|
|
label.style.display = 'none';
|
|
iframeDoc.body.appendChild(label);
|
|
|
|
const positionOverlay = (el: Element) => {
|
|
const rect = el.getBoundingClientRect();
|
|
overlay.style.display = 'block';
|
|
overlay.style.top = rect.top + 'px';
|
|
overlay.style.left = rect.left + 'px';
|
|
overlay.style.width = rect.width + 'px';
|
|
overlay.style.height = rect.height + 'px';
|
|
|
|
const tagName = el.tagName.toLowerCase();
|
|
const classes = Array.from(el.classList)
|
|
.filter((c) => !c.startsWith('__clawd'))
|
|
.slice(0, 2);
|
|
const labelText = classes.length > 0
|
|
? `${tagName}.${classes.join('.')}`
|
|
: tagName;
|
|
label.textContent = labelText;
|
|
label.style.display = 'block';
|
|
label.style.top = Math.max(0, rect.top) + 'px';
|
|
label.style.left = Math.max(0, rect.left) + 'px';
|
|
};
|
|
|
|
const hideOverlay = () => {
|
|
overlay.style.display = 'none';
|
|
label.style.display = 'none';
|
|
};
|
|
|
|
const isOurElement = (el: Element) =>
|
|
el.id === OVERLAY_ID || el.id === LABEL_ID || el.id === STYLE_ID;
|
|
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
const target = e.target as Element;
|
|
if (!target || isOurElement(target) || target === iframeDoc.body || target === iframeDoc.documentElement) {
|
|
hideOverlay();
|
|
hoveredRef.current = null;
|
|
return;
|
|
}
|
|
hoveredRef.current = target;
|
|
positionOverlay(target);
|
|
};
|
|
|
|
const onMouseLeave = () => {
|
|
hideOverlay();
|
|
hoveredRef.current = null;
|
|
};
|
|
|
|
const onClick = async (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const target = hoveredRef.current;
|
|
if (!target || isOurElement(target)) return;
|
|
|
|
overlay.style.animation = '__clawd-flash 0.3s ease-out';
|
|
setTimeout(() => { overlay.style.animation = ''; }, 300);
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
const elId = `el-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const selectedEl: SelectedElement = {
|
|
id: elId,
|
|
selectorPath: buildSelectorPath(target),
|
|
tagName: target.tagName,
|
|
className: Array.from(target.classList).filter((c) => !c.startsWith('__clawd')).join(' '),
|
|
outerHTML: truncateHTML(target.outerHTML),
|
|
computedStyles: getKeyStyles(target),
|
|
boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
};
|
|
|
|
addSelectedElement(selectedEl);
|
|
|
|
target.classList.add(SELECTED_CLASS);
|
|
selectedDomEls.current.set(elId, target);
|
|
|
|
captureScreenshot(target, iframeDoc).then((screenshot) => {
|
|
if (screenshot) {
|
|
updateSelectedElement(elId, { screenshot });
|
|
}
|
|
}).catch(() => {});
|
|
};
|
|
|
|
iframeDoc.body.style.cursor = 'crosshair';
|
|
|
|
iframeDoc.addEventListener('mousemove', onMouseMove, true);
|
|
iframeDoc.addEventListener('mouseleave', onMouseLeave, true);
|
|
iframeDoc.addEventListener('click', onClick, true);
|
|
|
|
cleanupRef.current = () => {
|
|
iframeDoc.removeEventListener('mousemove', onMouseMove, true);
|
|
iframeDoc.removeEventListener('mouseleave', onMouseLeave, true);
|
|
iframeDoc.removeEventListener('click', onClick, true);
|
|
iframeDoc.body.style.cursor = '';
|
|
selectedDomEls.current.forEach((el) => el.classList.remove(SELECTED_CLASS));
|
|
selectedDomEls.current.clear();
|
|
const existingOverlay = iframeDoc.getElementById(OVERLAY_ID);
|
|
const existingLabel = iframeDoc.getElementById(LABEL_ID);
|
|
const existingStyle = iframeDoc.getElementById(STYLE_ID);
|
|
if (existingOverlay) existingOverlay.remove();
|
|
if (existingLabel) existingLabel.remove();
|
|
if (existingStyle) existingStyle.remove();
|
|
hoveredRef.current = null;
|
|
};
|
|
}, [ctx, getIframe]);
|
|
|
|
const teardownSelection = useCallback(() => {
|
|
if (cleanupRef.current) {
|
|
cleanupRef.current();
|
|
cleanupRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!ctx) return;
|
|
|
|
if (ctx.selectMode) {
|
|
teardownSelection();
|
|
|
|
const iframe = getIframe();
|
|
if (!iframe) return;
|
|
|
|
const trySetup = () => {
|
|
try {
|
|
if (iframe.contentDocument?.body) {
|
|
setupSelection();
|
|
}
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
trySetup();
|
|
iframe.addEventListener('load', trySetup);
|
|
return () => {
|
|
iframe.removeEventListener('load', trySetup);
|
|
teardownSelection();
|
|
};
|
|
} else {
|
|
teardownSelection();
|
|
}
|
|
}, [ctx?.selectMode, setupSelection, teardownSelection, getIframe]);
|
|
|
|
useEffect(() => {
|
|
if (!ctx?.selectMode) return;
|
|
const iframe = getIframe();
|
|
if (!iframe) return;
|
|
|
|
const onLoad = () => {
|
|
if (ctx.selectMode && iframe.contentDocument?.body) {
|
|
teardownSelection();
|
|
setupSelection();
|
|
}
|
|
};
|
|
|
|
iframe.addEventListener('load', onLoad);
|
|
return () => iframe.removeEventListener('load', onLoad);
|
|
}, [ctx?.selectMode, setupSelection, teardownSelection, getIframe]);
|
|
|
|
useEffect(() => {
|
|
if (!ctx) return;
|
|
const currentIds = new Set(ctx.selectedElements.map((e) => e.id));
|
|
const staleIds: string[] = [];
|
|
selectedDomEls.current.forEach((el, id) => {
|
|
if (!currentIds.has(id)) {
|
|
el.classList.remove(SELECTED_CLASS);
|
|
staleIds.push(id);
|
|
}
|
|
});
|
|
staleIds.forEach((id) => selectedDomEls.current.delete(id));
|
|
}, [ctx?.selectedElements]);
|
|
}
|