Files
upage-git/app/components/editor/PageRender.tsx
2025-09-24 17:02:44 +08:00

428 lines
13 KiB
TypeScript

import { motion, type Variants } from 'framer-motion';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import Frame from 'react-frame-component';
import type { DocumentProperties } from '~/types/editor';
import { executeScripts } from '~/utils/execute-scripts';
import { isMac } from '~/utils/os';
import { EditorOverlay } from './EditorOverlay';
export interface PageRenderRef {
element: HTMLDivElement | null;
iframe: HTMLIFrameElement | null;
}
export interface EditorRenderProps {
document: DocumentProperties;
onUpdate?: (pageName: string, html: string) => void;
onSave?: (pageName: string, html: string) => void;
isCurrentPage?: boolean;
}
const pageAnimationVariants: Variants = {
hidden: {
opacity: 0,
scale: 0.98,
zIndex: 1,
},
visible: {
opacity: 1,
scale: 1,
zIndex: 2,
transition: {
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
},
},
inactive: {
opacity: 0,
scale: 0.98,
zIndex: 1,
pointerEvents: 'none',
display: 'none',
transition: {
duration: 0.2,
},
},
};
/**
* 使用 HTML 来渲染页面。并在当前页面的 HTML 有所变化时,调用更新函数。
* 为了保证纯净性,此函数将只考虑渲染 HTML 以及更新,与外部的所有交互无关。
*/
export const PageRender = forwardRef<PageRenderRef, EditorRenderProps>(
({ document, onUpdate, onSave, isCurrentPage }, ref) => {
const frameRef = useRef<HTMLIFrameElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<MutationObserver | null>(null);
const lastContentRef = useRef<string | null>(null);
const isMountedRef = useRef<boolean>(false);
const documentContentRef = useRef<string>(document.content);
const previousSelectedElementRef = useRef<HTMLElement | null>(null);
const hasUnsavedChangesRef = useRef<boolean>(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
// 解决 react-frame-component 首次加载时可能无法加载的问题。
// https://github.com/ryanseddon/react-frame-component/issues/192
const [show, setShow] = useState(false);
useEffect(() => {
setShow(true);
}, []);
useImperativeHandle(ref, () => {
return {
element: contentRef.current,
iframe: frameRef.current,
};
}, [frameRef.current, contentRef.current]);
const setElementEditable = useCallback((element: HTMLElement, isEditable: boolean) => {
if (isEditable) {
element.contentEditable = 'true';
element.focus();
return;
}
element.removeAttribute('contenteditable');
element.blur();
}, []);
useEffect(() => {
documentContentRef.current = document.content;
}, [document.content]);
const handleSave = useCallback(() => {
if (!onSave || !hasUnsavedChangesRef.current || !frameRef.current) {
return;
}
const iframeDocument = frameRef.current.contentDocument;
if (!iframeDocument) {
return;
}
const editorContent = iframeDocument.getElementById('page-content');
if (!editorContent) {
return;
}
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
if (!contentHTML) {
return;
}
const currentContent = contentHTML.innerHTML;
onSave(document.name, currentContent);
hasUnsavedChangesRef.current = false;
}, [onSave, document.name]);
useEffect(() => {
if (selectedElement) {
setElementEditable(selectedElement, true);
}
if (previousSelectedElementRef.current !== selectedElement) {
setTimeout(() => {
handleSave();
}, 1000);
}
if (previousSelectedElementRef.current) {
setElementEditable(previousSelectedElementRef.current, false);
}
previousSelectedElementRef.current = selectedElement;
}, [selectedElement, setElementEditable, handleSave]);
const processContentUpdate = useCallback(
(contentHTML: Element | null) => {
if (!contentHTML) {
return;
}
const currentContent = contentHTML.innerHTML;
if (currentContent !== lastContentRef.current) {
lastContentRef.current = currentContent;
hasUnsavedChangesRef.current = true;
if (onUpdate) {
onUpdate(document.name, currentContent);
}
}
},
[onUpdate, document.name],
);
const setupMutationObserver = useCallback(() => {
if (!frameRef.current) {
return;
}
const iframeDocument = frameRef.current.contentDocument;
if (!iframeDocument) {
return;
}
const editorContent = iframeDocument.getElementById('page-content');
if (!editorContent) {
return;
}
if (observerRef.current) {
observerRef.current.disconnect();
}
let updateTimer: NodeJS.Timeout | null = null;
const observer = new MutationObserver((mutations) => {
if (mutations.length === 0) {
return;
}
const hasRealChanges = mutations.some(
(mutation) => !(mutation.type === 'attributes' && mutation.attributeName === 'contenteditable'),
);
if (!hasRealChanges) {
return;
}
if (updateTimer) {
clearTimeout(updateTimer);
}
updateTimer = setTimeout(() => {
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
if (!contentHTML) {
return;
}
const currentContent = contentHTML.innerHTML;
if (currentContent !== lastContentRef.current) {
processContentUpdate(contentHTML);
}
}, 0);
});
const config: MutationObserverInit = {
attributes: true,
childList: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true,
};
observer.observe(editorContent, config);
observerRef.current = observer;
return () => {
if (updateTimer) {
clearTimeout(updateTimer);
}
observer.disconnect();
observerRef.current = null;
};
}, [processContentUpdate]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if ((isMac ? e.metaKey : e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
},
[handleSave],
);
useEffect(() => {
if (isCurrentPage && frameRef.current) {
if (frameRef.current.style.display === 'none') {
frameRef.current.style.display = 'block';
}
if (frameRef.current.style.visibility === 'hidden') {
frameRef.current.style.visibility = 'visible';
}
setupMutationObserver();
// 添加键盘事件监听器
window.addEventListener('keydown', handleKeyDown);
// 在 iframe 内也添加键盘事件监听器
const iframeDocument = frameRef.current.contentDocument;
if (iframeDocument) {
iframeDocument.addEventListener('keydown', handleKeyDown);
}
} else if (!isCurrentPage && observerRef.current) {
observerRef.current.disconnect();
if (frameRef.current) {
frameRef.current.style.visibility = 'hidden';
frameRef.current.style.display = 'none';
}
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
window.removeEventListener('keydown', handleKeyDown);
if (frameRef.current?.contentDocument) {
frameRef.current.contentDocument.removeEventListener('keydown', handleKeyDown);
}
};
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
const handleFrameMount = useCallback(() => {
isMountedRef.current = true;
if (frameRef.current) {
if (isCurrentPage || isCurrentPage === undefined) {
frameRef.current.style.visibility = 'visible';
frameRef.current.style.display = 'block';
setupMutationObserver();
const iframeDocument = frameRef.current.contentDocument;
if (iframeDocument) {
iframeDocument.addEventListener('keydown', handleKeyDown);
}
} else {
frameRef.current.style.visibility = 'hidden';
frameRef.current.style.display = 'none';
}
}
if (documentContentRef.current) {
const iframeDocument = frameRef.current?.contentDocument;
if (!iframeDocument) {
return;
}
const editorContent = iframeDocument.getElementById('page-content');
if (!editorContent) {
return;
}
initialPageContent();
}
// 如果 document 的 content 不为空,则设置为初始内容。
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
const initialPageContent = useCallback(() => {
if (!contentRef.current) {
return;
}
hasUnsavedChangesRef.current = false;
lastContentRef.current = documentContentRef.current;
contentRef.current.innerHTML = documentContentRef.current;
executeScripts(contentRef.current);
const event = new Event('DOMContentLoaded', {
bubbles: true,
cancelable: true,
});
frameRef.current?.contentDocument?.dispatchEvent(event);
}, [ref, documentContentRef]);
// 初始化的 HTML 内容,如果有 HTML 所需的一些外部资源,可以在这里添加。但需要注意的是,导出时,需要将这些资源也导出。
const initialContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${document.title}</title>
<script src="${import.meta.env.BASE_URL}tailwindcss.js"></script>
<script src="${import.meta.env.BASE_URL}iconify-icon.min.js"></script>
<style>
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#page-content {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
display: block;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="page-content"></div>
</body>
</html>
`;
return (
<motion.div
className="page-render w-full h-full absolute"
initial="hidden"
animate={isCurrentPage ? 'visible' : 'inactive'}
variants={pageAnimationVariants}
key={document.name}
style={{
width: '100%',
height: '100%',
top: 0,
left: 0,
margin: 0,
padding: 0,
overflow: 'hidden',
display: isCurrentPage ? 'block' : 'none',
position: 'absolute',
}}
>
{show && (
<Frame
ref={frameRef}
initialContent={initialContent}
mountTarget="#page-content"
height="100%"
width="100%"
className="page-iframe"
loading="lazy"
style={{
border: 'none',
margin: 0,
padding: 0,
visibility: isCurrentPage ? 'visible' : 'hidden',
display: isCurrentPage ? 'block' : 'none',
}}
contentDidMount={handleFrameMount}
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
head={
<>
<style>
{`
*:focus-visible {
outline: none;
}
.page-editing {
outline: 2px dashed #3b82f6 !important;
outline-offset: -2px;
min-height: 1em;
position: relative;
}
[contenteditable="true"] {
cursor: text;
}
`}
</style>
</>
}
>
<div id={`page-${document.name}`} ref={contentRef}></div>
<EditorOverlay
selectedElement={selectedElement}
hoveredElement={hoveredElement}
setHoveredElement={setHoveredElement}
setSelectedElement={setSelectedElement}
/>
</Frame>
)}
</motion.div>
);
},
);