Files
upage-git/app/.client/components/editor/EditorOverlay.tsx

331 lines
8.4 KiB
TypeScript

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<EditorOverlayProps> = ({
selectedElement,
hoveredElement,
setHoveredElement,
setSelectedElement,
}) => {
const { document: iframeDocument, window: iframeWindow } = useFrame();
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
const [selectRect, setSelectRect] = useState<DOMRect | null>(null);
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(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 && (
<div
ref={hoverRefs.setFloating}
className="hover-overlay"
style={{
...hoverFloatingStyles,
width: `${hoverRect.width}px`,
height: `${hoverRect.height}px`,
}}
/>
)}
{selectedElement && selectRect && (
<div
ref={selectRefs.setFloating}
className="select-overlay"
style={{
...selectFloatingStyles,
width: `${selectRect.width}px`,
height: `${selectRect.height}px`,
}}
/>
)}
{selectedElement && (
<div ref={refs.setFloating} className="editor-dialog" style={floatingStyles} {...getFloatingProps()}>
<EditDialog element={selectedElement} onClose={() => setSelectedElement(null)} />
</div>
)}
</>,
overlayContainer as Element,
);
};