Files
test1/components/html-editor/hooks/useIframeMode.ts
2026-03-20 07:33:46 +00:00

262 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Position, EditorStyleConfig, HistoryState } from '../lib'
import { cleanDom, HTMLEditor } from '../lib'
interface UseInjectModeOptions {
styleConfig?: EditorStyleConfig
enableGlobalContentEditable?: boolean
onContentChange?: (srcDoc: string) => void
onHistoryChange?: (state: HistoryState, editor: HTMLEditor) => void
enabled?: boolean
}
const isValidDom = (innerText: string) => {
try {
const data = JSON.parse(innerText)
if (data && !data.success) {
return false
}
} catch {
return true
}
}
const isIfrmae = (target: HTMLElement) => {
return target.tagName === 'IFRAME' || target instanceof HTMLIFrameElement
}
export interface UseInjectModeReturn {
editor: HTMLEditor | null
editorIns: HTMLEditor | null
selectedElement: HTMLElement | null
position: Position | null
tipPosition: Position | null
injectScript: (targetContainer: HTMLElement) => Promise<void>
// 历史记录相关
canUndo: boolean
canRedo: boolean
clearHistory: () => void
loadSuccess: boolean
}
function waitForIframeReady(
iframe: HTMLIFrameElement,
timeout = 5000,
): Promise<void> {
return new Promise(resolve => {
const start = performance.now()
const tryAttach = () => {
const doc = iframe.contentDocument
if (!doc) {
if (performance.now() - start > timeout) return resolve()
return requestAnimationFrame(tryAttach)
}
// 如果已经 complete直接 resolve
if (doc.readyState === 'complete') {
return resolve()
}
let stableTimer: any
const observer = new MutationObserver(() => {
clearTimeout(stableTimer)
stableTimer = setTimeout(() => {
observer.disconnect()
resolve()
}, 800)
})
observer.observe(doc.documentElement, {
childList: true,
subtree: true,
attributes: true,
})
// 超时保护
setTimeout(() => {
observer.disconnect()
clearTimeout(stableTimer)
console.error('iframe ready timeout')
resolve()
}, timeout)
// 同时监听 readyState
const checkReady = () => {
if (doc.readyState === 'complete') {
observer.disconnect()
clearTimeout(stableTimer)
resolve()
} else if (performance.now() - start < timeout) {
requestAnimationFrame(checkReady)
}
}
checkReady()
}
tryAttach()
})
}
export function useIframeMode(
id: string,
container: React.RefObject<HTMLIFrameElement | HTMLElement | null>,
options?: UseInjectModeOptions,
scale = 1,
): UseInjectModeReturn {
const editorRef = useRef<HTMLEditor | null>(null)
const [editorIns, setEditorIns] = useState<HTMLEditor | null>(null)
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
null,
)
const [position, setPosition] = useState<Position | null>(null)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
const [loadSuccess, setLoadSuccess] = useState(false)
const injectScript = useCallback(async (targetContainer: HTMLElement) => {
if (!targetContainer) {
console.error('ifrmae is null')
return
}
if (editorRef.current) {
console.error('ifrmae is destroyed')
editorRef.current.destroy()
}
const editor = new HTMLEditor({
id,
styleConfig: options?.styleConfig,
enableGlobalContentEditable: options?.enableGlobalContentEditable,
onElementSelect: (element: HTMLElement | null, pos?: Position) => {
setSelectedElement(element)
if (element && pos) {
setPosition(pos)
} else {
setPosition(null)
}
},
onStyleChange: (element: HTMLElement) => {
if (element) {
const pos = element.getBoundingClientRect()
setPosition(pos)
}
},
onContentChange: () => {
// 触发内容变化回调,数据清洗
if (options?.onContentChange) {
if (editor.isIframe) {
const iframe = container.current as HTMLIFrameElement
const iframeDoc = iframe.contentDocument?.documentElement
if (iframeDoc) {
const srcDoc = cleanDom(iframeDoc)
options.onContentChange(srcDoc)
}
} else {
const srcDoc = cleanDom(container.current as HTMLElement)
options.onContentChange(srcDoc)
}
}
},
onHistoryChange: (state: HistoryState) => {
setCanUndo(state.canUndo)
setCanRedo(state.canRedo)
options?.onHistoryChange?.(state, editor)
},
enableMoveable: true,
helperBox: true,
enableHistory: true,
historyOptions: {
maxHistorySize: 100,
mergeInterval: 1000,
},
})
editor.init(targetContainer)
editorRef.current = editor
setEditorIns(editor)
}, [id, options, container])
useEffect(() => {
const element = container.current
if (!element || options?.enabled === false) {
return
}
const isIframeEle = isIfrmae(element)
const handleKeyDown = (e: KeyboardEvent) => {
// macos 快捷键 command + z 撤销 / command + shift + z 重做
if (e.metaKey && e.key === 'z' && e.shiftKey) {
e.preventDefault()
editorRef.current?.redo()
}
if (e.metaKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
editorRef.current?.undo()
}
}
if (isIframeEle) {
// iframe 加载
const onLoad = async () => {
await waitForIframeReady(element as HTMLIFrameElement)
const doc = (element as HTMLIFrameElement).contentDocument!
// 判断是否是合法的 iframe 内容
const loadSuccess = isValidDom(doc.body.innerText)
if (loadSuccess) {
injectScript(doc.body)
console.log('编辑器加载成功')
setLoadSuccess(true)
const document = (element as HTMLIFrameElement).contentDocument
?.documentElement
document?.addEventListener('keydown', handleKeyDown)
return () => {
document?.removeEventListener('keydown', handleKeyDown)
}
}
}
; (element as HTMLIFrameElement).onload = onLoad
} else {
injectScript(element)
setLoadSuccess(true)
document?.addEventListener('keydown', handleKeyDown)
return () => {
document?.removeEventListener('keydown', handleKeyDown)
}
}
}, [injectScript, container, options?.enabled])
useEffect(() => {
return () => {
if (editorRef.current && loadSuccess) {
console.log('销毁编辑器')
editorRef.current.destroy()
}
}
}, [loadSuccess])
const tipPosition = useMemo(() => {
if (!container.current || !position) {
return null
}
const elementRect = container.current.getBoundingClientRect()
return {
top: position.top * scale + elementRect.top,
left: position.left * scale,
width: position.width * scale,
height: position.height * scale,
bottom: position.bottom * scale + elementRect.top,
right: position.right * scale + elementRect.left,
}
}, [position, scale, container])
return {
editor: editorRef.current,
editorIns,
selectedElement,
position,
tipPosition,
injectScript,
canUndo,
canRedo,
loadSuccess,
clearHistory: () => editorRef.current?.clearHistory(),
}
}