import { autoUpdate, flip, offset, shift, useClick, useDismiss, useFloating, useInteractions, } from '@floating-ui/react'; import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; import { useFrame } from 'react-frame-component'; import { EditDialog } from './EditDialog'; export interface EditorOverlayProps { selectedElement: HTMLElement | null; hoveredElement: HTMLElement | null; setHoveredElement: (element: HTMLElement | null) => void; setSelectedElement: (element: HTMLElement | null) => void; } const shadowDomStyles = ` .overlay-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999996; } .hover-overlay { position: absolute; pointer-events: none; box-sizing: border-box; z-index: 999997; background-color: rgba(0, 102, 255, 0.1); border: 1px dashed rgb(0, 87, 255); } .select-overlay { position: absolute; pointer-events: none; box-sizing: border-box; z-index: 999997; border: 1px dashed rgb(0, 87, 255); } .editor-dialog { position: absolute; z-index: 999998; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); min-width: 320px; pointer-events: auto; max-height: 80vh; overflow: hidden; } /* 自定义箭头样式 */ .floating-arrow { position: absolute; width: 12px; height: 12px; transform: rotate(45deg); background: white; z-index: 999997; border: 1px solid #e2e8f0; } `; /** * 编辑器覆盖层组件,负责在 iframe 内创建和管理覆盖层。 * 覆盖层用于操作和修改 HTML 元素。 * 为防止样式覆盖,因此使用 Shadow DOM 创建覆盖层。 */ export const EditorOverlay: React.FC = ({ selectedElement, hoveredElement, setHoveredElement, setSelectedElement, }) => { const { document: iframeDocument, window: iframeWindow } = useFrame(); const [hoverRect, setHoverRect] = useState(null); const [selectRect, setSelectRect] = useState(null); const [shadowRoot, setShadowRoot] = useState(null); const { refs: hoverRefs, floatingStyles: hoverFloatingStyles } = useFloating({ elements: { reference: hoveredElement ?? undefined, }, whileElementsMounted: autoUpdate, middleware: [ offset(({ rects }) => { return -rects.reference.height / 2 - rects.floating.height / 2; }), ], }); const { refs: selectRefs, floatingStyles: selectFloatingStyles } = useFloating({ elements: { reference: selectedElement ?? undefined, }, whileElementsMounted: autoUpdate, middleware: [ offset(({ rects }) => { return -rects.reference.height / 2 - rects.floating.height / 2; }), ], }); const { refs, floatingStyles, context } = useFloating({ elements: { reference: selectedElement ?? undefined, }, whileElementsMounted: autoUpdate, placement: 'bottom', middleware: [ offset(10), flip({ fallbackPlacements: ['top'], crossAxis: true, boundary: iframeDocument?.body || undefined, }), shift({ padding: 10, limiter: { options: { offset: 100, }, fn: (state) => { const { x, y } = state; return { x, y, }; }, }, }), ], }); const { getFloatingProps } = useInteractions([useClick(context), useDismiss(context)]); useEffect(() => { if (hoveredElement && hoverRefs.reference.current !== hoveredElement) { hoverRefs.reference.current = hoveredElement; } }, [hoveredElement, hoverRefs]); useEffect(() => { if (selectedElement && selectRefs.reference.current !== selectedElement) { selectRefs.reference.current = selectedElement; } }, [selectedElement, selectRefs]); useEffect(() => { if (selectedElement && refs.reference.current !== selectedElement) { refs.reference.current = selectedElement; } }, [selectedElement, refs]); useEffect(() => { if (!iframeDocument || !iframeWindow) { return; } const container = iframeDocument.createElement('div'); container.id = 'editor-overlay'; iframeDocument.body.appendChild(container); const shadow = container.attachShadow({ mode: 'open' }); const style = iframeDocument.createElement('style'); style.textContent = shadowDomStyles; shadow.appendChild(style); const contentContainer = iframeDocument.createElement('div'); contentContainer.className = 'overlay-container'; shadow.appendChild(contentContainer); setShadowRoot(shadow); return () => { if (container && container.parentNode) { container.parentNode.removeChild(container); } }; }, [iframeDocument, iframeWindow]); useEffect(() => { if (!iframeDocument || !iframeWindow) { return; } const handleMouseMove = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( target === iframeDocument.body || target === iframeDocument.documentElement || target.closest('#editor-overlay') ) { if (hoveredElement) { setHoveredElement(null); setHoverRect(null); } return; } if (hoveredElement !== target) { setHoveredElement(target); const rect = target.getBoundingClientRect(); setHoverRect({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, x: rect.x, y: rect.y, toJSON: rect.toJSON, }); } }; const handleClick = (e: MouseEvent) => { e.preventDefault(); const target = e.target as HTMLElement; if (target === iframeDocument.body || target === iframeDocument.documentElement) { return; } setSelectedElement(target); const rect = target.getBoundingClientRect(); setSelectRect({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, x: rect.x, y: rect.y, toJSON: rect.toJSON, }); }; const handleSubmit = (e: Event) => { e.preventDefault(); }; const handleMouseOut = (e: MouseEvent) => { if (!e.relatedTarget || !iframeDocument.contains(e.relatedTarget as Node)) { setHoveredElement(null); setHoverRect(null); } }; iframeDocument.body.addEventListener('mousemove', handleMouseMove); iframeDocument.body.addEventListener('click', handleClick); iframeDocument.body.addEventListener('submit', handleSubmit); iframeDocument.addEventListener('mouseout', handleMouseOut); return () => { iframeDocument.body.removeEventListener('mousemove', handleMouseMove); iframeDocument.body.removeEventListener('click', handleClick); iframeDocument.body.removeEventListener('submit', handleSubmit); iframeDocument.removeEventListener('mouseout', handleMouseOut); }; }, [ iframeDocument, iframeWindow, selectedElement, hoveredElement, setHoveredElement, setSelectedElement, setHoverRect, setSelectRect, ]); if (!iframeDocument || !shadowRoot) { return null; } const overlayContainer = shadowRoot.querySelector('.overlay-container'); if (!overlayContainer) { return null; } return ReactDOM.createPortal( <> {hoveredElement && hoverRect && (
)} {selectedElement && selectRect && (
)} {selectedElement && (
setSelectedElement(null)} />
)} , overlayContainer as Element, ); };