初始化模版工程
This commit is contained in:
35
components/html-editor/hooks/useDiff.ts
Normal file
35
components/html-editor/hooks/useDiff.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePPTEditContext } from '../context'
|
||||
import { useToolPostion } from './useToolPostion'
|
||||
import type { UseInjectModeReturn } from '../hooks/useIframeMode'
|
||||
|
||||
export const useDiff = (
|
||||
useIframeReturn: UseInjectModeReturn,
|
||||
isDoc?: boolean,
|
||||
) => {
|
||||
const editorContext = usePPTEditContext()
|
||||
const { selectedElement, editor, tipPosition } = useIframeReturn
|
||||
|
||||
const docModeToolPosition = useToolPostion(editor, !!isDoc)
|
||||
|
||||
useEffect(() => {
|
||||
const hasActive = editor?.EditorRegistry.hasActiveEditor()
|
||||
const reWriteState = { ...useIframeReturn }
|
||||
if (isDoc) {
|
||||
// 重写状态
|
||||
reWriteState.position = docModeToolPosition
|
||||
reWriteState.tipPosition = docModeToolPosition
|
||||
editorContext?.setState(reWriteState)
|
||||
return
|
||||
}
|
||||
if (hasActive && selectedElement) {
|
||||
// 有激活的实例且为当前的实例
|
||||
editorContext?.setState(reWriteState)
|
||||
} else if (!selectedElement && !hasActive) {
|
||||
// 失焦后清空所有状态,关闭tip
|
||||
reWriteState.position = null
|
||||
reWriteState.tipPosition = null
|
||||
editorContext?.setState(reWriteState)
|
||||
}
|
||||
}, [selectedElement, editor, tipPosition, isDoc, docModeToolPosition])
|
||||
}
|
||||
42
components/html-editor/hooks/useEditState.ts
Normal file
42
components/html-editor/hooks/useEditState.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { HTMLEditor } from '../lib';
|
||||
|
||||
export type SaveType = 'manual' | 'auto' | null;
|
||||
|
||||
export const useEditState = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveType, setSaveType] = useState<SaveType>(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const redo = useRef(() => { });
|
||||
const undo = useRef(() => { });
|
||||
|
||||
const handleHistoryChangeEvent = (instance: HTMLEditor) => {
|
||||
if (!instance) return null
|
||||
const canRedo = instance.EditorRegistry.canRedo()
|
||||
const canUndo = instance.EditorRegistry.canUndo()
|
||||
setCanRedo(canRedo)
|
||||
setCanUndo(canUndo)
|
||||
redo.current = () => {
|
||||
instance.EditorRegistry.redo()
|
||||
}
|
||||
undo.current = () => {
|
||||
instance.EditorRegistry.undo()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
saveType,
|
||||
setSaveType,
|
||||
handleHistoryChangeEvent,
|
||||
canRedo,
|
||||
setCanRedo,
|
||||
canUndo,
|
||||
setCanUndo,
|
||||
redo,
|
||||
undo,
|
||||
}
|
||||
}
|
||||
261
components/html-editor/hooks/useIframeMode.ts
Normal file
261
components/html-editor/hooks/useIframeMode.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
25
components/html-editor/hooks/useLoadContent.ts
Normal file
25
components/html-editor/hooks/useLoadContent.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNovaKit } from '@/components/nova-sdk/context/useNovaKit';
|
||||
import type { TaskArtifact } from '@/components/nova-sdk/types';
|
||||
|
||||
export const useLoadContent = (
|
||||
taskArtifact: TaskArtifact
|
||||
) => {
|
||||
const [content, setContent] = useState('');
|
||||
const { api } = useNovaKit();
|
||||
useEffect(() => {
|
||||
if (taskArtifact.path) {
|
||||
api
|
||||
.getArtifactUrl?.(taskArtifact)
|
||||
.then(async (res) => {
|
||||
const url = res?.data || '';
|
||||
if (url) {
|
||||
const content = await fetch(url).then((res) => res.text());
|
||||
setContent(content);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}
|
||||
}, [taskArtifact, api]);
|
||||
return content;
|
||||
};
|
||||
82
components/html-editor/hooks/useToolPostion.ts
Normal file
82
components/html-editor/hooks/useToolPostion.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { HTMLEditor } from '../lib'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export const useToolPostion = (editor: HTMLEditor | null, isDoc: boolean) => {
|
||||
const [position, setPosition] = useState<{ left: number; top: number, bottom: number, right: number, width: number, height: number } | null>(null)
|
||||
// 标识是否从编辑器内开始选择
|
||||
const isSelectingRef = useRef(false)
|
||||
const updatePositionFromSelection = () => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
const view = editor.getDoc().view
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
const selection = view.getSelection()
|
||||
if (!selection) return
|
||||
// 如果选区为空,不需要更新
|
||||
const hasSelectAnything = selection.rangeCount === 0 || selection.toString().trim() === ''
|
||||
if (selection.isCollapsed || selection.rangeCount === 0 || hasSelectAnything) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取选区的 Range 对象
|
||||
const range = selection.getRangeAt(0);
|
||||
// 获取选区末尾的位置
|
||||
// 创建一个新的 Range,只包含选区的末尾点
|
||||
const endRange = range.cloneRange();
|
||||
endRange.collapse(false); // false 表示折叠到末尾
|
||||
const rect = endRange.getBoundingClientRect();
|
||||
|
||||
const { top, left, bottom, right, width, height } = rect
|
||||
setPosition({ top, left, bottom, right, width, height })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !isDoc) {
|
||||
return
|
||||
}
|
||||
const dom = editor.getDoc().document
|
||||
if (!dom) {
|
||||
return
|
||||
}
|
||||
|
||||
const onMouseDown = () => {
|
||||
isSelectingRef.current = true
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
// 只有从编辑器内开始选择时才处理
|
||||
if (!isSelectingRef.current) {
|
||||
return
|
||||
}
|
||||
isSelectingRef.current = false
|
||||
setTimeout(() => {
|
||||
updatePositionFromSelection()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
updatePositionFromSelection()
|
||||
}
|
||||
|
||||
dom.addEventListener('mousedown', onMouseDown)
|
||||
dom.addEventListener('mouseup', onMouseUp)
|
||||
dom.addEventListener('scroll', onScrollOrResize)
|
||||
|
||||
window.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
|
||||
return () => {
|
||||
dom.removeEventListener('mousedown', onMouseDown)
|
||||
dom.removeEventListener('mouseup', onMouseUp)
|
||||
dom.removeEventListener('scroll', onScrollOrResize)
|
||||
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
}, [editor, updatePositionFromSelection, isDoc])
|
||||
|
||||
return position
|
||||
}
|
||||
Reference in New Issue
Block a user