提交 b11b04e1 authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): establish editor feature registry

上级 d180e973
......@@ -10,7 +10,7 @@
"preview": "vite preview",
"lint": "oxlint --fix .",
"format": "oxfmt src/",
"test": "node --test src/app/route-manifest.test.mjs src/app/routes.boundary.test.mjs src/store/storage-import.test.mjs src/utils/*.test.js src/layout/components/*.test.js",
"test": "node --test src/app/route-manifest.test.mjs src/app/routes.boundary.test.mjs src/store/storage-import.test.mjs src/utils/*.test.js src/layout/components/*.test.js && vitest run",
"test:unit": "vitest",
"test:unit:watch": "vitest --watch"
},
......
import { useEditorDialog } from './useEditorDialog'
export const EditorDialogHost = ({ editor, registry, runtime }) => {
const { activeDialog, closeDialog, openDialog } = useEditorDialog(editor)
if (!activeDialog) return null
const Dialog = registry.dialogs.get(activeDialog.id)
if (!Dialog) {
console.error(`Unknown editor dialog: ${activeDialog.id}`)
return null
}
return <Dialog
key={activeDialog.instance}
open
payload={activeDialog.payload}
onClose={closeDialog}
openDialog={openDialog}
runtime={runtime}
/>
}
const assertUnique = (values, label) => {
const seen = new Set()
values.forEach(value => {
if (seen.has(value)) throw new Error(`Duplicate ${label}: ${value}`)
seen.add(value)
})
}
const byOrder = (left, right) => left.order - right.order
export const createFeatureRegistry = features => {
assertUnique(features.map(feature => feature.id), 'feature id')
const menus = features.flatMap(feature => feature.menus).sort(byOrder)
const nodeModules = features.flatMap(feature => feature.nodeModules).sort(byOrder)
const dialogEntries = features.flatMap(feature => feature.dialogs)
assertUnique(menus.map(entry => entry.config.key), 'menu key')
assertUnique(nodeModules.flatMap(entry => entry.types), 'node type')
assertUnique(dialogEntries.map(entry => entry.id), 'dialog id')
return {
features: [...features],
menus,
nodeModules,
dialogs: new Map(dialogEntries.map(entry => [entry.id, entry.component])),
}
}
export const defineEditorFeature = ({
id,
menus = [],
nodeModules = [],
dialogs = [],
}) => ({ id, menus, nodeModules, dialogs })
export const EDITOR_DIALOG_EVENT = 'wangeditor-customer:dialog'
export const openEditorDialog = (editor, id, payload = {}) => {
if (!editor?.emit) throw new Error('An editor instance is required to open a dialog')
editor.emit(EDITOR_DIALOG_EVENT, { id, payload })
}
export const subscribeEditorDialog = (editor, listener) => {
if (!editor?.on || !editor?.off) return () => {}
editor.on(EDITOR_DIALOG_EVENT, listener)
return () => editor.off(EDITOR_DIALOG_EVENT, listener)
}
import { editorFeatureRegistry } from '../features'
const registrationKey = Symbol.for('ebook.admin.wangeditor-customer.registered')
export const registerEditorFeatures = Boot => {
if (Boot[registrationKey]) return
Boot.registerModule({
menus: editorFeatureRegistry.menus.map(entry => entry.config),
})
editorFeatureRegistry.nodeModules.forEach(entry => Boot.registerModule(entry.module))
// Feature registration changes require a page reload during HMR.
Object.defineProperty(Boot, registrationKey, { value: true })
}
import { useCallback, useEffect, useRef, useState } from 'react'
import { openEditorDialog, subscribeEditorDialog } from './editor-dialog'
export const useEditorDialog = editor => {
const [activeDialog, setActiveDialog] = useState(null)
const sequence = useRef(0)
useEffect(() => {
setActiveDialog(null)
return subscribeEditorDialog(editor, request => {
sequence.current += 1
setActiveDialog({ ...request, instance: sequence.current })
})
}, [editor])
const closeDialog = useCallback(() => setActiveDialog(null), [])
const openDialog = useCallback((id, payload) => openEditorDialog(editor, id, payload), [editor])
return { activeDialog, closeDialog, openDialog }
}
import { defineEditorFeature } from '../../core/define-editor-feature'
import AIDigitalHuman from '../../menu/AIDigitalHuman'
import AIImage from '../../menu/AIImage'
import AIVideo from '../../menu/AIVideo'
export default defineEditorFeature({
id: 'ai-media',
menus: [
{ order: 280, config: AIDigitalHuman },
{ order: 320, config: AIImage },
{ order: 330, config: AIVideo },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import AIQuestionGapFill from '../../menu/AIQuestionGapFill'
import AIQuestionJudge from '../../menu/AIQuestionJudge'
import AIQuestionMultiple from '../../menu/AIQuestionMultiple'
import AIQuestionOpenEnded from '../../menu/AIQuestionOpenEnded'
import AIQuestionSingle from '../../menu/AIQuestionSingle'
export default defineEditorFeature({
id: 'ai-question',
menus: [
{ order: 230, config: AIQuestionSingle },
{ order: 240, config: AIQuestionMultiple },
{ order: 250, config: AIQuestionJudge },
{ order: 260, config: AIQuestionGapFill },
{ order: 270, config: AIQuestionOpenEnded },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import AIBaiduSearch from '../../menu/AIBaiduSearch'
import AISearch from '../../menu/AISearch'
import AIWrite from '../../menu/AIWrite'
export default defineEditorFeature({
id: 'ai-research',
menus: [
{ order: 300, config: AISearch },
{ order: 310, config: AIWrite },
{ order: 340, config: AIBaiduSearch },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import AIChat from '../../menu/AIChat'
import AIContentInspect from '../../menu/AIContentInspect'
import AIExpand from '../../menu/AIExpand'
import AIPolishing from '../../menu/AIPolishing'
import AIPunctuation from '../../menu/AIPunctuation'
import AIRewrite from '../../menu/AIRewrite'
import AISummary from '../../menu/AISummary'
import AITranslate from '../../menu/AITranslate'
export default defineEditorFeature({
id: 'ai-text',
menus: [
{ order: 160, config: AIChat },
{ order: 170, config: AIRewrite },
{ order: 180, config: AIExpand },
{ order: 190, config: AISummary },
{ order: 200, config: AIPolishing },
{ order: 210, config: AIPunctuation },
{ order: 220, config: AIContentInspect },
{ order: 290, config: AITranslate },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Audio'
import nodeModule from '../../node/audio'
export default defineEditorFeature({
id: 'audio',
menus: [{ order: 50, config: menu }],
nodeModules: [{ order: 50, types: ['chapterAudio'], module: nodeModule }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import itemMenu from '../../customer/ChapterItem'
import titleMenu from '../../customer/ChapterTitle'
import sectionNodeModule from '../../node/chapterItem'
import headerNodeModule from '../../node/chapterTitle'
export default defineEditorFeature({
id: 'chapter-structure',
menus: [{ order: 80, config: titleMenu }, { order: 90, config: itemMenu }],
nodeModules: [
{ order: 10, types: ['chapterSection'], module: sectionNodeModule },
{ order: 20, types: ['chapterHeader'], module: headerNodeModule },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import convertTooltipMenu from '../../customer/ConvertTooltipType'
import removeSpacesMenu from '../../customer/RemoveSpaces'
export default defineEditorFeature({
id: 'content-tools',
menus: [{ order: 140, config: removeSpacesMenu }, { order: 150, config: convertTooltipMenu }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/ExpandRead'
import nodeModule from '../../node/expandRead'
import simpleNodeModule from '../../node/expandReadItem'
export default defineEditorFeature({
id: 'expand-read',
menus: [{ order: 130, config: menu }],
nodeModules: [
{ order: 120, types: ['chapterExpandRead'], module: nodeModule },
{ order: 130, types: ['chapterExpandReadSimple'], module: simpleNodeModule },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Formula'
import nodeModule from '../../node/formula'
export default defineEditorFeature({
id: 'formula',
menus: [{ order: 60, config: menu }],
nodeModules: [{ order: 80, types: ['chapterFormula'], module: nodeModule }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Gallery'
import onlineMenu from '../../customer/GalleryOnline'
import nodeModule from '../../node/gallery'
import inlineNodeModule from '../../node/galleryInline'
export default defineEditorFeature({
id: 'gallery',
menus: [{ order: 20, config: menu }, { order: 30, config: onlineMenu }],
nodeModules: [
{ order: 60, types: ['chapterGallery'], module: nodeModule },
{ order: 70, types: ['chapterGalleryInline'], module: inlineNodeModule },
],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
export default defineEditorFeature({ id: 'history' })
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../menu/Icon'
export default defineEditorFeature({ id: 'icon', menus: [{ order: 360, config: menu }] })
import { defineEditorFeature } from '../../core/define-editor-feature'
import editMenu from '../../customer/ImageEditor'
import onlineMenu from '../../customer/ImageOnline'
import nodeModule from '../../node/image'
export default defineEditorFeature({
id: 'image',
menus: [{ order: 10, config: onlineMenu }, { order: 110, config: editMenu }],
nodeModules: [{ order: 30, types: ['chapterImage'], module: nodeModule }],
})
import { createFeatureRegistry } from '../core/create-feature-registry'
import aiMedia from './ai-media/feature'
import aiQuestion from './ai-question/feature'
import aiResearch from './ai-research/feature'
import aiText from './ai-text/feature'
import audio from './audio/feature'
import chapterStructure from './chapter-structure/feature'
import contentTools from './content-tools/feature'
import expandRead from './expand-read/feature'
import formula from './formula/feature'
import gallery from './gallery/feature'
import history from './history/feature'
import icon from './icon/feature'
import image from './image/feature'
import link from './link/feature'
import practice from './practice/feature'
import preview from './preview/feature'
import tooltip from './tooltip/feature'
import video from './video/feature'
export const editorFeatures = [
image,
gallery,
video,
audio,
chapterStructure,
formula,
practice,
tooltip,
link,
expandRead,
contentTools,
icon,
aiText,
aiQuestion,
aiMedia,
aiResearch,
preview,
history,
]
export const editorFeatureRegistry = createFeatureRegistry(editorFeatures)
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/CustomerLink'
import nodeModule from '../../node/link'
export default defineEditorFeature({
id: 'link',
menus: [{ order: 120, config: menu }],
nodeModules: [{ order: 110, types: ['chapterLink'], module: nodeModule }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Practice'
import nodeModule from '../../node/practice'
export default defineEditorFeature({
id: 'practice',
menus: [{ order: 100, config: menu }],
nodeModules: [{ order: 90, types: ['chapterPractice'], module: nodeModule }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
export default defineEditorFeature({ id: 'preview' })
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Tooltip'
import nodeModule from '../../node/tooltip'
export default defineEditorFeature({
id: 'tooltip',
menus: [{ order: 70, config: menu }],
nodeModules: [{ order: 100, types: ['chapterTooltip'], module: nodeModule }],
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from '../../customer/Video'
import nodeModule from '../../node/video'
export default defineEditorFeature({
id: 'video',
menus: [{ order: 40, config: menu }],
nodeModules: [{ order: 40, types: ['chapterVideo'], module: nodeModule }],
})
......@@ -9,6 +9,7 @@ import { setPracticeRandom } from "@/store/modules/editor";
// import formulaEditor from 'easy-formula-editor'
import "./utils/jax.less";
import { Boot } from "@wangeditor/editor";
import { registerEditorFeatures } from "./core/register-editor-features";
import { SlateEditor, DomEditor, SlateElement, SlateTransforms } from "@wangeditor/editor";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
......@@ -21,46 +22,6 @@ dayjs.extend(relativeTime);
import { storageChange } from "@/utils/storage.js";
import ImageAutoOnlineConf from "./customer/ImageOnline";
import GalleryAutoConf from "./customer/Gallery";
import GalleryAutoOnlineConf from "./customer/GalleryOnline";
import VideoAutoConf from "./customer/Video";
import AudioAutoConf from "./customer/Audio";
import ChapterTitleAutoConf from "./customer/ChapterTitle";
import ChapterItemAutoConf from "./customer/ChapterItem";
import PracticeAutoConf from "./customer/Practice";
import FormulaAutoConf from "./customer/Formula";
import TooltipAutoConf from "./customer/Tooltip";
import ImageEditorConf from "./customer/ImageEditor";
import CustomerLinkConf from "./customer/CustomerLink";
import ExpandReadConf from "./customer/ExpandRead";
import RemoveSpacesConf from "./customer/RemoveSpaces";
import ConvertTooltipTypeConf from "./customer/ConvertTooltipType";
// AI对话
import AIChat from "./menu/AIChat";
// AI辅助
import AIRewrite from "./menu/AIRewrite";
import AIExpand from "./menu/AIExpand";
import AISummary from "./menu/AISummary";
import AIPolishing from "./menu/AIPolishing";
import AIPunctuation from "./menu/AIPunctuation";
import AIContentInspect from "./menu/AIContentInspect";
import AIQuestionSingle from "./menu/AIQuestionSingle";
import AIQuestionMultiple from "./menu/AIQuestionMultiple";
import AIQuestionJudge from "./menu/AIQuestionJudge";
import AIQuestionGapFill from "./menu/AIQuestionGapFill";
import AIQuestionOpenEnded from "./menu/AIQuestionOpenEnded";
import AIDigitalHuman from "./menu/AIDigitalHuman";
import AITranslate from "./menu/AITranslate";
import AISearch from "./menu/AISearch";
import AIWrite from "./menu/AIWrite";
import AIImage from "./menu/AIImage";
import AIVideo from "./menu/AIVideo";
import AIBaiduSearch from "./menu/AIBaiduSearch";
import Icon from "./menu/Icon";
import ImageModal from "./components/image";
import VideoModal from "./components/video";
import GalleryModal from "./components/gallery";
......@@ -73,20 +34,6 @@ import TooltipModal from "./components/tooltip";
import LinkModal from "./components/link";
import ExpandModal from "./components/expand";
import chapterSectionModule from "./node/chapterItem";
import chapterHeaderModule from "./node/chapterTitle";
import chapterImageModule from "./node/image";
import chapterVideoModule from "./node/video";
import chapterAudioModule from "./node/audio";
import chapterGalleryModule from "./node/gallery";
import chapterGalleryInlineModule from "./node/galleryInline";
import chapterPracticeModule from "./node/practice";
import formulaModule from "./node/formula";
import tooltipModule from "./node/tooltip";
import linkModule from "./node/link";
import expandRead from "./node/expandRead";
import expandReadSimple from "./node/expandReadItem";
import PracticeSettingModal from "./practice/index";
import PreviewModal from "./components/preview";
......@@ -94,71 +41,7 @@ import HistoryModal from "./history/";
import "./index.less";
const module = {
menus: [
// ImageAutoConf,
ImageAutoOnlineConf,
GalleryAutoConf,
GalleryAutoOnlineConf,
VideoAutoConf,
AudioAutoConf,
FormulaAutoConf,
TooltipAutoConf,
ChapterTitleAutoConf,
ChapterItemAutoConf,
PracticeAutoConf,
ImageEditorConf,
CustomerLinkConf,
ExpandReadConf,
RemoveSpacesConf,
ConvertTooltipTypeConf,
AIChat,
AIRewrite,
AIExpand,
AISummary,
AIPolishing,
AIPunctuation,
AIContentInspect,
AIQuestionSingle,
AIQuestionMultiple,
AIQuestionJudge,
AIQuestionGapFill,
AIQuestionOpenEnded,
AIDigitalHuman,
AITranslate,
AISearch,
AIWrite,
AIImage,
AIVideo,
AIBaiduSearch,
Icon,
],
};
Boot.registerModule(module);
// 注册节头内容
Boot.registerModule(chapterSectionModule);
// 注册章头内容
Boot.registerModule(chapterHeaderModule);
// 注册图片内容
Boot.registerModule(chapterImageModule);
// 注册视频内容
Boot.registerModule(chapterVideoModule);
// 注册音频内容
Boot.registerModule(chapterAudioModule);
// 注册画廊
Boot.registerModule(chapterGalleryModule);
Boot.registerModule(chapterGalleryInlineModule);
// 注册公式
Boot.registerModule(formulaModule);
// 注册练习
Boot.registerModule(chapterPracticeModule);
// 注册气泡
Boot.registerModule(tooltipModule);
// 注册链接
Boot.registerModule(linkModule);
// 注册扩展阅读
Boot.registerModule(expandRead);
Boot.registerModule(expandReadSimple);
registerEditorFeatures(Boot);
const tabsMenu = [
{ key: "text", title: "文本设置" },
......
import '@testing-library/jest-dom/vitest'
// The legacy iconfont side-effect reads the final script element while loading.
document.head.appendChild(document.createElement('script'))
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EditorDialogHost } from '@/common/wangeditor-customer/core/EditorDialogHost'
import { openEditorDialog } from '@/common/wangeditor-customer/core/editor-dialog'
const createEditor = () => {
const listeners = new Map<string, Set<(value: unknown) => void>>()
return {
on(type, listener) {
const group = listeners.get(type) || new Set()
group.add(listener)
listeners.set(type, group)
},
off(type, listener) {
listeners.get(type)?.delete(listener)
},
emit(type, value) {
listeners.get(type)?.forEach(listener => listener(value))
},
listenerCount(type) {
return listeners.get(type)?.size || 0
},
}
}
const TestDialog = ({ payload, onClose }) => (
<div>
<span>{payload.title}</span>
<button onClick={onClose}>close</button>
</div>
)
describe('EditorDialogHost', () => {
it('routes a request only to the host for the emitting editor', () => {
const first = createEditor()
const second = createEditor()
const registry = { dialogs: new Map([['test.open', TestDialog]]) }
render(<>
<EditorDialogHost editor={first} registry={registry} runtime={{ editor: first }} />
<EditorDialogHost editor={second} registry={registry} runtime={{ editor: second }} />
</>)
act(() => openEditorDialog(first, 'test.open', { title: 'first editor' }))
expect(screen.getAllByText('first editor')).toHaveLength(1)
})
it('unsubscribes when the host unmounts', () => {
const editor = createEditor()
const registry = { dialogs: new Map() }
const view = render(<EditorDialogHost editor={editor} registry={registry} runtime={{ editor }} />)
expect(editor.listenerCount('wangeditor-customer:dialog')).toBe(1)
view.unmount()
expect(editor.listenerCount('wangeditor-customer:dialog')).toBe(0)
})
it('reports an unknown dialog id without crashing the editor', () => {
const editor = createEditor()
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<EditorDialogHost editor={editor} registry={{ dialogs: new Map() }} runtime={{ editor }} />)
act(() => openEditorDialog(editor, 'missing.dialog'))
expect(error).toHaveBeenCalledWith('Unknown editor dialog: missing.dialog')
error.mockRestore()
})
})
import { describe, expect, it } from 'vitest'
import { defineEditorFeature } from '@/common/wangeditor-customer/core/define-editor-feature'
import { createFeatureRegistry } from '@/common/wangeditor-customer/core/create-feature-registry'
import { registerEditorFeatures } from '@/common/wangeditor-customer/core/register-editor-features'
import { editorFeatureRegistry } from '@/common/wangeditor-customer/features'
const menu = key => ({ key, factory: () => ({}) })
const dialog = () => null
const expectedMenuKeys = [
'ImageAutoOnline', 'GalleryAuto', 'GalleryAutoOnline', 'VideoAuto', 'AudioAuto',
'FormulaAuto', 'TooltipAuto', 'ChapterTitle', 'ChapterItem', 'Practice',
'ImageEditor', 'CustomerLink', 'ExpandRead', 'RemoveSpaces', 'ConvertTooltipType',
'AIChat', 'AIRewrite', 'AIExpand', 'AISummary', 'AIPolishing', 'AIPunctuation',
'AIContentInspect', 'AIQuestionSingle', 'AIQuestionMultiple', 'AIQuestionJudge',
'AIQuestionGapFill', 'AIQuestionOpenEnded', 'AIDigitalHuman', 'AITranslate',
'AISearch', 'AIWrite', 'AIImage', 'AIVideo', 'AIBaiduSearch', 'Icon',
]
const expectedNodeTypes = [
'chapterSection', 'chapterHeader', 'chapterImage', 'chapterVideo', 'chapterAudio',
'chapterGallery', 'chapterGalleryInline', 'chapterFormula', 'chapterPractice',
'chapterTooltip', 'chapterLink', 'chapterExpandRead', 'chapterExpandReadSimple',
]
describe('createFeatureRegistry', () => {
it('sorts menu and node registrations independently', () => {
const registry = createFeatureRegistry([
defineEditorFeature({
id: 'later-feature',
menus: [{ order: 20, config: menu('later') }],
nodeModules: [{ order: 10, types: ['node-a'], module: { editorPlugin() {} } }],
}),
defineEditorFeature({
id: 'earlier-feature',
menus: [{ order: 10, config: menu('earlier') }],
nodeModules: [{ order: 20, types: ['node-b'], module: { editorPlugin() {} } }],
}),
])
expect(registry.menus.map(entry => entry.config.key)).toEqual(['earlier', 'later'])
expect(registry.nodeModules.flatMap(entry => entry.types)).toEqual(['node-a', 'node-b'])
})
it('rejects duplicate feature, menu, node, and dialog ids', () => {
const make = overrides => defineEditorFeature({
id: 'feature-a',
menus: [{ order: 10, config: menu('menu-a') }],
nodeModules: [{ order: 10, types: ['node-a'], module: {} }],
dialogs: [{ id: 'dialog-a', component: dialog }],
...overrides,
})
expect(() => createFeatureRegistry([make(), make()])).toThrow('Duplicate feature id: feature-a')
expect(() => createFeatureRegistry([make(), make({ id: 'feature-b' })])).toThrow('Duplicate menu key: menu-a')
expect(() => createFeatureRegistry([make(), make({ id: 'feature-b', menus: [] })])).toThrow('Duplicate node type: node-a')
expect(() => createFeatureRegistry([make(), make({ id: 'feature-b', menus: [], nodeModules: [] })])).toThrow('Duplicate dialog id: dialog-a')
})
it('keeps the legacy menu and node registration orders', () => {
expect(editorFeatureRegistry.menus.map(entry => entry.config.key)).toEqual(expectedMenuKeys)
expect(editorFeatureRegistry.nodeModules.flatMap(entry => entry.types)).toEqual(expectedNodeTypes)
})
it('registers the menu module first and every node module once', () => {
const calls = []
const Boot = { registerModule: module => calls.push(module) }
registerEditorFeatures(Boot)
registerEditorFeatures(Boot)
expect(calls).toHaveLength(1 + expectedNodeTypes.length)
expect(calls[0].menus.map(menu => menu.key)).toEqual(expectedMenuKeys)
})
})
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论