262 lines
7.4 KiB
TypeScript
262 lines
7.4 KiB
TypeScript
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(),
|
||
}
|
||
}
|