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

chore: update

上级 a9fbd6f2
......@@ -82,7 +82,6 @@ export function AppList<Row extends object, Filters extends object = Record<stri
{hasRefreshError ? (
<Alert
className='app-list__error'
type='error'
showIcon
title={data.error?.message}
......
import { render } from '@testing-library/react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useEditorFeatureEvents } from '@/components/editor/wangeditor-customer/core/useEditorFeatureEvents'
describe('useEditorFeatureEvents', () => {
it('subscribes registry menu events to the current editor and cleans them up', () => {
const editor = { on: vi.fn(), off: vi.fn() }
const handler = vi.fn()
const registry = { events: [{ name: 'ImageMenuClick', handler }] }
const Subscriber = () => {
useEditorFeatureEvents({ editor, registry, runtime: {} })
return null
}
const view = render(createElement(Subscriber))
expect(editor.on).toHaveBeenCalledWith('ImageMenuClick', expect.any(Function))
const listener = editor.on.mock.calls[0][1]
listener({ source: 'menu' })
expect(handler).toHaveBeenCalledWith({ editor, payload: { source: 'menu' }, runtime: {} })
view.unmount()
expect(editor.off).toHaveBeenCalledWith('ImageMenuClick', listener)
})
})
......@@ -44,7 +44,6 @@ const expectedMenuKeys = [
'AIWrite',
'AIImage',
'AIVideo',
'AIBaiduSearch',
'Icon',
]
......@@ -90,7 +89,6 @@ describe('createFeatureRegistry', () => {
menus: [{ order: 10, config: menu('menu-a') }],
nodeModules: [{ order: 10, types: ['node-a'], module: {} }],
dialogs: [{ id: 'dialog-a', component: dialog }],
events: [{ name: 'event-a', handler: () => {} }],
...overrides,
})
......@@ -104,12 +102,6 @@ describe('createFeatureRegistry', () => {
expect(() =>
createFeatureRegistry([make(), make({ id: 'feature-b', menus: [], nodeModules: [] })]),
).toThrow('Duplicate dialog id: dialog-a')
expect(() =>
createFeatureRegistry([
make(),
make({ id: 'feature-b', menus: [], nodeModules: [], dialogs: [] }),
]),
).toThrow('Duplicate editor event: event-a')
})
it('keeps the legacy menu and node registration orders', () => {
......@@ -130,13 +122,15 @@ describe('createFeatureRegistry', () => {
expect(calls[0].menus.map((menu) => menu.key)).toEqual(expectedMenuKeys)
})
it('opens legacy dialogs by the stable menu key', () => {
it('opens the dialog configured by the menu', () => {
const calls = []
const Boot = { registerModule: (module) => calls.push(module) }
const emit = vi.fn()
registerEditorFeatures(Boot)
calls[0].menus.find((menu) => menu.key === 'AIRewrite').factory().exec({ emit })
const menu = calls[0].menus.find((menu) => menu.key === 'AIRewrite').factory()
expect(menu.getValue()).toBe('')
menu.exec({ emit })
expect(emit).toHaveBeenCalledWith('wangeditor-customer:dialog', {
id: 'ai-text.action',
......
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import ImageMenu, {
imageWidth50MenuChapterConf,
} from '@/components/editor/wangeditor-customer/features/image/menu'
afterEach(() => vi.restoreAllMocks())
describe('image menus', () => {
it('opens the image dialog without trying to resize an image', () => {
const editor = { emit: vi.fn() }
const menu = ImageMenu.factory()
vi.spyOn(menu, 'isDisabled').mockReturnValue(false)
const resize = vi.spyOn(SlateTransforms, 'setNodes')
menu.exec(editor)
expect(editor.emit).toHaveBeenCalledWith('wangeditor-customer:dialog', {
id: 'image.insert',
payload: { isOnline: false },
})
expect(resize).not.toHaveBeenCalled()
})
it('resizes only the selected chapter image without opening the image dialog', () => {
const image = { type: 'chapterImage', style: { margin: 'auto' } }
const editor = { emit: vi.fn() }
vi.spyOn(DomEditor, 'getSelectedNodeByType').mockReturnValue(image)
vi.spyOn(DomEditor, 'getHoverbar').mockReturnValue(null)
vi.spyOn(DomEditor, 'findPath').mockReturnValue([2])
const resize = vi.spyOn(SlateTransforms, 'setNodes').mockImplementation(() => {})
imageWidth50MenuChapterConf.factory().exec(editor)
expect(editor.emit).not.toHaveBeenCalled()
expect(resize).toHaveBeenCalledWith(
editor,
{ style: { margin: 'auto', width: '50%', height: '' } },
{ at: [2] },
)
})
})
import { describe, expect, it, vi } from 'vitest'
import { normalizeInlineVoidNode } from '../shared/normalize-inline-void'
describe('normalizeInlineVoidNode', () => {
it('adds one trailing text node after an inline void at the end of a paragraph', () => {
const inlineNode = { type: 'chapterExpandReadSimple', children: [{ text: '' }] }
const paragraph = { type: 'paragraph', children: [{ text: '正文' }, inlineNode] }
const editor = { children: [paragraph] }
const insertNodes = vi.fn((_editor, node, options) => {
paragraph.children.splice(options.at[1], 0, node)
})
const input = {
editor,
node: inlineNode,
path: [0, 1],
type: 'chapterExpandReadSimple',
normalizeNode: vi.fn(),
transforms: { insertNodes },
}
normalizeInlineVoidNode(input)
normalizeInlineVoidNode(input)
expect(insertNodes).toHaveBeenCalledOnce()
expect(paragraph.children).toEqual([{ text: '正文' }, inlineNode, { text: '' }])
})
it('delegates nodes owned by another normalizer', () => {
const normalizeNode = vi.fn()
const node = { type: 'paragraph', children: [{ text: '' }] }
normalizeInlineVoidNode({
editor: { children: [node] },
node,
path: [0],
type: 'chapterFormula',
normalizeNode,
transforms: { insertNodes: vi.fn() },
})
expect(normalizeNode).toHaveBeenCalledWith([node, [0]])
})
})
import { describe, expect, it, vi } from 'vitest'
import RemoveSpacesMenu, {
removeSpacesFromChineseParagraphs,
} from '@/components/editor/wangeditor-customer/features/content-tools/remove-spaces-menu'
describe('removeSpacesFromChineseParagraphs', () => {
it('removes spaces and non-breaking spaces from Chinese paragraphs', () => {
const html = '<p>这 是 <strong>中文&nbsp;段落</strong>。</p><p>English text stays.</p>'
expect(removeSpacesFromChineseParagraphs(html)).toBe(
'<p>这是<strong>中文段落</strong>。</p><p>English text stays.</p>',
)
})
it('does not change content outside paragraphs', () => {
const html = '<pre><code>const value = 1</code></pre><div>中文 内容</div>'
expect(removeSpacesFromChineseParagraphs(html)).toBe(html)
})
it('updates the editor only when content changes', () => {
const editor = {
getHtml: vi.fn(() => '<p>中文 段落</p>'),
setHtml: vi.fn(),
}
RemoveSpacesMenu.factory().exec(editor)
expect(editor.setHtml).toHaveBeenCalledWith('<p>中文段落</p>')
})
})
......@@ -13,13 +13,10 @@ describe('WangEditorCustomer shell boundary', () => {
expect(shellSource).not.toMatch(/from\s+['"][^'"]*features\//)
})
it.each([
'ImageMenuClick',
'GalleryMenuClick',
'PracticeMenuClick',
'window.dispatchEvent',
'document.querySelector(',
])('does not contain %s', (forbidden) => {
it.each(['window.dispatchEvent', 'document.querySelector('])(
'does not contain %s',
(forbidden) => {
expect(shellSource).not.toContain(forbidden)
})
},
)
})
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlateTransforms } from '@wangeditor/editor'
import { withTrailingParagraph } from '@/components/editor/wangeditor-customer/shared/with-trailing-paragraph'
afterEach(() => vi.restoreAllMocks())
describe('withTrailingParagraph', () => {
it('adds an editable paragraph after a custom block at the document end', () => {
const node = { type: 'chapterVideo', children: [{ text: '' }] }
const editor = { children: [node], normalizeNode: vi.fn() }
const insert = vi.spyOn(SlateTransforms, 'insertNodes').mockImplementation(() => {})
withTrailingParagraph(editor, 'chapterVideo').normalizeNode([node, [0]])
expect(insert).toHaveBeenCalledWith(
editor,
{ type: 'paragraph', children: [{ text: '' }] },
{ at: [1] },
)
})
it('keeps allowed following blocks and delegates unrelated nodes', () => {
const node = { type: 'chapterVideo', children: [{ text: '' }] }
const next = { type: 'chapterGallery', children: [{ text: '' }] }
const normalizeNode = vi.fn()
const editor = { children: [node, next], normalizeNode }
const insert = vi.spyOn(SlateTransforms, 'insertNodes').mockImplementation(() => {})
withTrailingParagraph(editor, 'chapterVideo')
editor.normalizeNode([node, [0]])
editor.normalizeNode([next, [1]])
expect(insert).not.toHaveBeenCalled()
expect(normalizeNode).toHaveBeenCalledWith([next, [1]])
})
it('supports the narrower image and audio rule without duplicating the normalizer', () => {
const node = { type: 'chapterAudio', children: [{ text: '' }] }
const next = { type: 'chapterGallery', children: [{ text: '' }] }
const editor = { children: [node, next], normalizeNode: vi.fn() }
const insert = vi.spyOn(SlateTransforms, 'insertNodes').mockImplementation(() => {})
withTrailingParagraph(editor, 'chapterAudio', ['paragraph', 'blockquote']).normalizeNode([
node,
[0],
])
expect(insert).toHaveBeenCalledOnce()
})
})
import { openEditorDialog } from './editor-dialog'
export default class DialogMenu {
constructor(dialogId, payload = {}) {
this.dialogId = dialogId
this.payload = payload
this.tag = 'button'
}
getValue() {
return ''
}
isActive() {
return false
}
isDisabled() {
return false
}
exec(editor) {
if (!this.isDisabled(editor)) openEditorDialog(editor, this.dialogId, this.payload)
}
}
import { openEditorDialog } from './editor-dialog'
const dialogByMenu = {
AIChat: ['ai-text.chat', {}],
AIRewrite: ['ai-text.action', { action: 'rewrite' }],
AIExpand: ['ai-text.action', { action: 'expand' }],
AISummary: ['ai-text.action', { action: 'summary' }],
AIPolishing: ['ai-text.action', { action: 'abbreviate' }],
AIPunctuation: ['ai-text.action', { action: 'punctuation' }],
AIContentInspect: ['ai-text.action', { action: 'contentInspect' }],
AITranslate: ['ai-text.action', { action: 'translate' }],
AIQuestionSingle: ['ai-question.generate', { action: '1' }],
AIQuestionMultiple: ['ai-question.generate', { action: '2' }],
AIQuestionJudge: ['ai-question.generate', { action: '3' }],
AIQuestionGapFill: ['ai-question.generate', { action: '4' }],
AIQuestionOpenEnded: ['ai-question.generate', { action: '5' }],
AIDigitalHuman: ['ai-media.digital-human', {}],
AIImage: ['ai-media.image', {}],
AIVideo: ['ai-media.video', {}],
AISearch: ['ai-research.search', { provider: 'search' }],
AIBaiduSearch: ['ai-research.search', { provider: 'baidu' }],
AIWrite: ['ai-research.write', {}],
Icon: ['icon.insert', {}],
}
export default class BaseModalMenu {
constructor() {
this.tag = 'button'
}
isActive() {
return false // or true
}
isDisabled() {
return false // or true
}
exec(editor) {
const request = dialogByMenu[this.menuKey]
if (!request) return
openEditorDialog(editor, request[0], request[1])
}
}
......@@ -18,7 +18,6 @@ export const createFeatureRegistry = (features) => {
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)
const events = features.flatMap((feature) => feature.events)
assertUnique(
menus.map((entry) => entry.config.key),
......@@ -32,16 +31,10 @@ export const createFeatureRegistry = (features) => {
dialogEntries.map((entry) => entry.id),
'dialog id',
)
assertUnique(
events.map((entry) => entry.name),
'editor event',
)
return {
features: [...features],
menus,
nodeModules,
events,
dialogs: new Map(dialogEntries.map((entry) => [entry.id, entry.component])),
}
}
export const defineEditorFeature = ({
export const defineEditorFeature = ({ id, menus = [], nodeModules = [], dialogs = [] }) => ({
id,
menus = [],
nodeModules = [],
dialogs = [],
events = [],
}) => ({ id, menus, nodeModules, dialogs, events })
menus,
nodeModules,
dialogs,
})
......@@ -2,20 +2,11 @@ import { editorFeatureRegistry } from '../features'
const registrationKey = Symbol.for('ebook.admin.wangeditor-customer.registered')
const bindMenuKey = (config) => ({
...config,
factory(...args) {
const menu = config.factory.apply(config, args)
menu.menuKey = config.key
return menu
},
})
export const registerEditorFeatures = (Boot) => {
if (Boot[registrationKey]) return
Boot.registerModule({
menus: editorFeatureRegistry.menus.map((entry) => bindMenuKey(entry.config)),
menus: editorFeatureRegistry.menus.map((entry) => entry.config),
})
editorFeatureRegistry.nodeModules.forEach((entry) => Boot.registerModule(entry.module))
......
import { useEffect, useRef } from 'react'
export const useEditorFeatureEvents = ({ editor, registry, runtime }) => {
const runtimeRef = useRef(runtime)
runtimeRef.current = runtime
useEffect(() => {
if (!editor) return
const listeners = registry.events.map(({ name, handler }) => ({
name,
listener: (payload) => handler({ editor, payload, runtime: runtimeRef.current }),
}))
listeners.forEach(({ name, listener }) => editor.on(name, listener))
return () => listeners.forEach(({ name, listener }) => editor.off(name, listener))
}, [editor, registry])
}
......@@ -25,9 +25,7 @@ import './image.less'
import { useImage } from '@ezijing/ai-react'
import { uploadFileByUrl } from '@/utils/oss'
export default function AIImageModal(props) {
const { editor } = props
const [isModalOpen, setIsModalOpen] = useState(true)
export default function AIImageModal({ editor, open, onClose }) {
const [prompt, setPrompt] = useState('')
const [size, setSize] = useState('2048x2048')
const [style, setStyle] = useState('default')
......@@ -152,7 +150,7 @@ export default function AIImageModal(props) {
insertBlockNodesAndFocus(editor, SlateTransforms, [oImg, oP])
message.success('图片已插入编辑器!')
setIsModalOpen(false)
onClose()
} else {
message.error('编辑器未找到')
}
......@@ -208,13 +206,7 @@ export default function AIImageModal(props) {
<ConfigProvider
theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}
>
<Modal
title='AI图片生成'
open={isModalOpen}
footer={null}
onCancel={() => setIsModalOpen(false)}
width={1200}
>
<Modal title='AI图片生成' open={open} footer={null} onCancel={onClose} width={1200}>
<div className='ai-image-generator'>
{/* 输入区域 */}
<div className='input-container'>
......
......@@ -31,9 +31,7 @@ import './video.less'
const { TextArea } = Input
export default function AIVideoModal(props) {
const { editor } = props
const [isModalOpen, setIsModalOpen] = useState(true)
export default function AIVideoModal({ editor, open, onClose }) {
const [prompt, setPrompt] = useState('')
const [ratio, setRatio] = useState('16:9')
const [duration, setDuration] = useState(5)
......@@ -138,7 +136,7 @@ export default function AIVideoModal(props) {
children: [{ text: '' }],
})
message.success('视频已插入编辑器!')
setIsModalOpen(false)
onClose()
} else {
message.error('编辑器未找到')
}
......@@ -192,13 +190,7 @@ export default function AIVideoModal(props) {
<ConfigProvider
theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}
>
<Modal
title='AI视频生成'
open={isModalOpen}
footer={null}
onCancel={() => setIsModalOpen(false)}
width={1200}
>
<Modal title='AI视频生成' open={open} footer={null} onCancel={onClose} width={1200}>
<div className='ai-video-generator'>
<div className='input-container'>
<div className='input-box'>
......
......@@ -13,12 +13,11 @@ import './digital-human.less'
const CUSTOMIZE_WORKSPACE = 'customize'
export default function DigitalHumanDialog({ editor }) {
const [open, setOpen] = useState(true)
export default function DigitalHumanDialog({ editor, open, onClose }) {
const [activeWorkspace, setActiveWorkspace] = useState(getConfiguredDigitalHumanType)
const [historySelection, setHistorySelection] = useState(null)
const digitalHuman = useDigitalHuman()
const close = () => setOpen(false)
const close = onClose
const clearTaskSelection = useCallback(() => setHistorySelection(null), [])
const sharedProps = {
editor,
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIDigitalHumanModal from './DigitalHumanDialog'
import DialogMenu from '../../core/DialogMenu'
class AIDigitalHuman extends BaseModalMenu {
class AIDigitalHuman extends DialogMenu {
constructor() {
super()
super('ai-media.digital-human')
this.title = '数字人'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 28 28"><path fill="currentColor" d="M12 5.5a2 2 0 0 0 1.491 1.935c.337.053.68.053 1.018 0A2 2 0 1 0 12 5.5m-1.337 1.058a3.5 3.5 0 1 1 6.675 0l4.419-1.436a2.477 2.477 0 1 1 1.53 4.712L18 11.552v3.822c0 .16.03.32.091.468l2.728 6.752a2.477 2.477 0 0 1-4.594 1.856l-2.243-5.553l-2.232 5.56a2.46 2.46 0 0 1-3.21 1.362a2.477 2.477 0 0 1-1.364-3.215l2.734-6.812q.09-.224.09-.466v-3.774L4.712 9.834a2.477 2.477 0 0 1 1.531-4.712zm2.518 2.346a5 5 0 0 1-.649-.162L5.78 6.548a.977.977 0 0 0-.604 1.859l5.46 1.774c.515.168.864.648.864 1.189v3.957c0 .35-.067.698-.198 1.024l-2.734 6.811a.977.977 0 0 0 .538 1.267a.96.96 0 0 0 1.252-.531l2.463-6.136c.42-1.045 1.897-1.047 2.319-.003l2.476 6.129a.977.977 0 1 0 1.812-.732L16.7 16.404a2.8 2.8 0 0 1-.2-1.03V11.37c0-.541.349-1.021.864-1.189l5.46-1.774a.977.977 0 1 0-.604-1.859l-6.752 2.194q-.32.104-.649.162a3.5 3.5 0 0 1-1.639 0"/></svg>`
}
getValue(editor) {
return <AIDigitalHumanModal key={Date.now()} editor={editor}></AIDigitalHumanModal>
}
}
export default {
......
......@@ -7,7 +7,12 @@ import DigitalHumanDialog from './DigitalHumanDialog'
import AiImageDialog from './AiImageDialog'
import AiVideoDialog from './AiVideoDialog'
const dialog = (Component) => (props) => createElement(Component, { editor: props.runtime.editor })
const dialog = (Component) => (props) =>
createElement(Component, {
editor: props.runtime.editor,
open: props.open,
onClose: props.onClose,
})
export default defineEditorFeature({
id: 'ai-media',
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import DialogMenu from '../../core/DialogMenu'
import { SlateRange } from '@wangeditor/editor'
import AIImageModal from './AiImageDialog'
class AIImage extends BaseModalMenu {
class AIImage extends DialogMenu {
constructor() {
super()
super('ai-media.image')
this.title = 'AI图片'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 8.12811L20.4668 8.69379C20.2864 9.10792 19.7136 9.10792 19.5331 8.69379L19.2866 8.12811C18.8471 7.11947 18.0555 6.31641 17.0677 5.87708L16.308 5.53922C15.8973 5.35653 15.8973 4.75881 16.308 4.57612L17.0252 4.25714C18.0384 3.80651 18.8442 2.97373 19.2761 1.93083L19.5293 1.31953C19.7058 0.893489 20.2942 0.893489 20.4706 1.31953L20.7238 1.93083C21.1558 2.97373 21.9616 3.80651 22.9748 4.25714L23.6919 4.57612C24.1027 4.75881 24.1027 5.35653 23.6919 5.53922L22.9323 5.87708C21.9445 6.31641 21.1529 7.11947 20.7134 8.12811ZM2.9918 3H14V5H4V19L14 9L20 15V11H22V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z"></path></svg>`
}
getValue(editor) {
return <AIImageModal key={Date.now()} editor={editor}></AIImageModal>
}
isDisabled(editor) {
const { selection } = editor || {}
return !selection || SlateRange.isExpanded(selection)
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import DialogMenu from '../../core/DialogMenu'
import { SlateRange } from '@wangeditor/editor'
import AIVideoModal from './AiVideoDialog'
class AIVideo extends BaseModalMenu {
class AIVideo extends DialogMenu {
constructor() {
super()
super('ai-media.video')
this.title = 'AI视频'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.7134 8.12811L19.4668 8.69379C19.2864 9.10792 18.7136 9.10792 18.5331 8.69379L18.2866 8.12811C17.8471 7.11947 17.0555 6.31641 16.0677 5.87708L15.308 5.53922C14.8973 5.35653 14.8973 4.75881 15.308 4.57612L16.0252 4.25714C17.0384 3.80651 17.8442 2.97373 18.2761 1.93083L18.5293 1.31953C18.7058 0.893489 19.2942 0.893489 19.4706 1.31953L19.7238 1.93083C20.1558 2.97373 20.9616 3.80651 21.9748 4.25714L22.6919 4.57612C23.1027 4.75881 23.1027 5.35653 22.6919 5.53922L21.9323 5.87708C20.9445 6.31641 20.1529 7.11947 19.7134 8.12811ZM3.9934 3H13V5H5V19H19V11H21V20.0066C21 20.5552 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3ZM10.6219 8.41459L15.5008 11.6672C15.6846 11.7897 15.7343 12.0381 15.6117 12.2219C15.5824 12.2658 15.5447 12.3035 15.5008 12.3328L10.6219 15.5854C10.4381 15.708 10.1897 15.6583 10.0672 15.4745C10.0234 15.4088 10 15.3316 10 15.2526V8.74741C10 8.52649 10.1791 8.34741 10.4 8.34741C10.479 8.34741 10.5562 8.37078 10.6219 8.41459Z"></path></svg>`
}
getValue(editor) {
return <AIVideoModal key={Date.now()} editor={editor}></AIVideoModal>
}
isDisabled(editor) {
const { selection } = editor || {}
return !selection || SlateRange.isExpanded(selection)
......
......@@ -65,9 +65,8 @@ const actionMap = {
},
}
export default function AIQuestionModal({ editor, action }) {
export default function AIQuestionModal({ editor, action, open, onClose }) {
const { message } = App.useApp()
const [isModalOpen, setIsModalOpen] = useState(true)
const [content, setContent] = useState('')
const { text, fetch, isLoading } = useAIEdit()
const actionText = actionMap[action]?.name
......@@ -156,17 +155,17 @@ export default function AIQuestionModal({ editor, action }) {
question_list: JSON.stringify(question.question_list),
})
message.success(res.message)
setIsModalOpen(false)
onClose()
}
return (
<Modal
title='AI创建试题'
open={isModalOpen}
open={open}
footer={null}
width={800}
onOk={handlePrimary}
onCancel={() => setIsModalOpen(false)}
onCancel={onClose}
>
<Spin spinning={isLoading}>
<Form labelCol={{ span: 2 }}>
......@@ -198,7 +197,7 @@ export default function AIQuestionModal({ editor, action }) {
</Spin>
<br />
<Flex gap='small' justify='end'>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button onClick={onClose}>取消</Button>
<Button type='primary' onClick={handleFetch}>
重新出题
</Button>
......
......@@ -8,7 +8,12 @@ import AIQuestionSingle from './single-menu'
import AiQuestionDialog from './AiQuestionDialog'
const dialog = (props) =>
createElement(AiQuestionDialog, { editor: props.runtime.editor, action: props.payload.action })
createElement(AiQuestionDialog, {
editor: props.runtime.editor,
action: props.payload.action,
open: props.open,
onClose: props.onClose,
})
export default defineEditorFeature({
id: 'ai-question',
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIQuestionModal from './AiQuestionDialog'
import DialogMenu from '../../core/DialogMenu'
class AIQuestionJudge extends BaseModalMenu {
class AIQuestionJudge extends DialogMenu {
constructor() {
super()
super('ai-question.generate', { action: '3' })
this.title = '判断题'
this.iconSvg = `<svg t="1729144923785" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="713" width="200" height="200"><path d="M804.672 27.968H219.328A182.528 182.528 0 0 0 36.672 210.624v585.408a182.528 182.528 0 0 0 182.656 182.592h585.408a182.528 182.528 0 0 0 182.656-182.656V210.624A182.144 182.144 0 0 0 804.672 27.968z m110.08 768a109.952 109.952 0 0 1-110.08 110.08H219.328a109.952 109.952 0 0 1-110.08-110.08V210.624a109.952 109.952 0 0 1 110.08-110.08h585.408a109.952 109.952 0 0 1 110.08 110.08z m-172.096-573.632l-511.04 511.04-4.096 5.248a37.248 37.248 0 0 0 4.096 46.848 36.736 36.736 0 0 0 51.2 0l511.04-511.04 4.096-5.248a37.248 37.248 0 0 0-4.096-46.848 35.712 35.712 0 0 0-51.2 0z m-4.672 468.288l56.192-56.192 4.096-4.672a30.336 30.336 0 0 0-46.848-38.08l-56.192 56.192-56.192-56.192-4.672-4.096a30.336 30.336 0 0 0-38.08 46.848l56.192 56.192-56.192 56.192-4.096 4.672a30.336 30.336 0 0 0 46.848 38.08l56.192-56.192 56.192 56.192 4.672 4.096a30.336 30.336 0 0 0 38.08-46.848z m-459.52-255.36a32.704 32.704 0 0 0 22.272 11.136h1.152a34.496 34.496 0 0 0 22.848-9.344l148.032-148.608 2.368-2.944a35.968 35.968 0 0 0 0-43.328 28.544 28.544 0 0 0-22.272-11.136 36.288 36.288 0 0 0-24 9.344L309.568 359.296l-38.4-62.656-2.368-2.944a30.4 30.4 0 0 0-42.432-4.032 35.392 35.392 0 0 0-8.768 46.272l58.56 96.576 2.368 2.944z" fill="#999999" p-id="714"></path></svg>`
this.tag = 'button'
}
getValue(editor) {
return <AIQuestionModal key={Date.now()} editor={editor} action='3'></AIQuestionModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIQuestionModal from './AiQuestionDialog'
import DialogMenu from '../../core/DialogMenu'
class AIQuestionMultiple extends BaseModalMenu {
class AIQuestionMultiple extends DialogMenu {
constructor() {
super()
super('ai-question.generate', { action: '2' })
this.title = '多选题'
this.iconSvg = `<svg t="1729144537633" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4530" width="200" height="200"><path d="M358.4 256h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 1 1 0-102.4zM204.8 358.4a51.2 51.2 0 1 1 0-102.4 51.2 51.2 0 0 1 0 102.4z m0 204.8a51.2 51.2 0 1 1 0-102.4 51.2 51.2 0 0 1 0 102.4z m153.6-102.4h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 0 1 0-102.4z m0 204.8h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 0 1 0-102.4z" fill="#4C4C4C" p-id="4531"></path><path d="M204.8 51.2a153.6 153.6 0 0 0-153.6 153.6v614.4a153.6 153.6 0 0 0 153.6 153.6h614.4a153.6 153.6 0 0 0 153.6-153.6V204.8a153.6 153.6 0 0 0-153.6-153.6H204.8z m0-51.2h614.4a204.8 204.8 0 0 1 204.8 204.8v614.4a204.8 204.8 0 0 1-204.8 204.8H204.8a204.8 204.8 0 0 1-204.8-204.8V204.8a204.8 204.8 0 0 1 204.8-204.8z" fill="#4C4C4C" p-id="4532"></path></svg>`
this.tag = 'button'
}
getValue(editor) {
return <AIQuestionModal key={Date.now()} editor={editor} action='2'></AIQuestionModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIQuestionModal from './AiQuestionDialog'
import DialogMenu from '../../core/DialogMenu'
class AIQuestionSingle extends BaseModalMenu {
class AIQuestionSingle extends DialogMenu {
constructor() {
super()
super('ai-question.generate', { action: '1' })
this.title = '单选题'
this.iconSvg = `<svg t="1729144945895" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="860" width="200" height="200"><path d="M358.4 256h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 1 1 0-102.4zM204.8 358.4a51.2 51.2 0 1 1 0-102.4 51.2 51.2 0 0 1 0 102.4z m153.6 102.4h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 0 1 0-102.4z m0 204.8h460.8a51.2 51.2 0 0 1 0 102.4H358.4a51.2 51.2 0 0 1 0-102.4z" fill="#4C4C4C" p-id="861"></path><path d="M204.8 51.2a153.6 153.6 0 0 0-153.6 153.6v614.4a153.6 153.6 0 0 0 153.6 153.6h614.4a153.6 153.6 0 0 0 153.6-153.6V204.8a153.6 153.6 0 0 0-153.6-153.6H204.8z m0-51.2h614.4a204.8 204.8 0 0 1 204.8 204.8v614.4a204.8 204.8 0 0 1-204.8 204.8H204.8a204.8 204.8 0 0 1-204.8-204.8V204.8a204.8 204.8 0 0 1 204.8-204.8z" fill="#4C4C4C" p-id="862"></path></svg>`
this.tag = 'button'
}
getValue(editor) {
return <AIQuestionModal key={Date.now()} editor={editor} action='1'></AIQuestionModal>
}
}
export default {
......
......@@ -17,8 +17,7 @@ import { usePaper } from '@/components/editor/hooks/useWenku'
import { useCopyToClipboard } from 'react-use'
import { RiMore2Line } from '@remixicon/react'
export default function AIModal() {
const [isModalOpen, setIsModalOpen] = useState(true)
export default function AIModal({ open, onClose }) {
const [content, setContent] = useState('')
const [textIndent, setTextIndent] = useState(0)
const prePromptRef = useRef(null)
......@@ -40,7 +39,7 @@ export default function AIModal() {
const width = prePromptRef.current.offsetWidth + 10
setTextIndent(width)
}
}, [isModalOpen])
}, [open])
const [isEditMessage, setIsEditMessage] = useState(false)
useEffect(() => {
if (messageScrollRef.current && !isEditMessage) {
......@@ -237,13 +236,7 @@ export default function AIModal() {
<ConfigProvider
theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}
>
<Modal
title='长文写作/课程大纲:'
open={isModalOpen}
footer={null}
onCancel={() => setIsModalOpen(false)}
width={1000}
>
<Modal title='长文写作/课程大纲:' open={open} footer={null} onCancel={onClose} width={1000}>
<div className='message-scroll' ref={messageScrollRef}>
{messages.map((msg) => {
return <MessageRender msg={msg} key={msg.id}></MessageRender>
......
import { useState, useEffect, useRef } from 'react'
import { SendOutlined, UndoOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'
import { ConfigProvider, Modal, Input, Button } from 'antd'
const { TextArea } = Input
import './search.less'
import { useSearch } from '@/components/editor/hooks/useWenku'
import { useCopyToClipboard } from 'react-use'
export default function AIModal() {
const [isModalOpen, setIsModalOpen] = useState(true)
const [content, setContent] = useState('')
const [textIndent, setTextIndent] = useState(0)
const prePromptRef = useRef(null)
const messageScrollRef = useRef(null)
const { messages, isLoading, query } = useSearch()
useEffect(() => {
if (prePromptRef.current) {
const width = prePromptRef.current.offsetWidth + 10
setTextIndent(width)
}
}, [isModalOpen])
useEffect(() => {
if (messageScrollRef.current) {
const scrollContainer = messageScrollRef.current
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}, [messages])
const prePrompt = '帮我找一些文献资料,主题是:'
const handleEnterSearch = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
!isLoading && handleSearch()
}
}
const handleSearch = () => {
query(prePrompt + content)
setContent('')
}
const [, copyToClipboard] = useCopyToClipboard()
const handleCopy = (content) => {
copyToClipboard(content)
}
const MessageRender = ({ msg }) => {
if (msg.role === 'ai') {
const copyMessageContent = msg.searchReferList
?.map((refer, index) => `${index + 1}.《${refer.title}》\n${refer.abstract}\n`)
.join('\n')
return (
msg.searchReferList?.length > 0 && (
<div className={`message-item ${msg.role}`}>
<div className='message-box'>
<div className='message-content'>
{msg.searchReferList.map((refer, index) => {
return (
<div key={index} className='result-item'>
<p>
<strong>
{index + 1}.《{refer.title}》
</strong>
</p>
<p>
{refer.abstract}
<a href={refer.orgUrl} target='_blank' rel='noreferrer'>
<LinkOutlined />
</a>
</p>
</div>
)
})}
</div>
<div className='message-tools'>
<Button
type='text'
size='small'
icon={<UndoOutlined />}
disabled={isLoading}
onClick={() => query(msg.userQuery)}
>
重新生成
</Button>
<Button
type='text'
size='small'
icon={<CopyOutlined />}
onClick={() => handleCopy(copyMessageContent)}
>
复制内容
</Button>
</div>
</div>
</div>
)
)
} else {
return (
<div className={`message-item ${msg.role}`}>
<div className='message-box'>
<div className='message-content'>{msg.content}</div>
</div>
</div>
)
}
}
return (
<ConfigProvider
theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}
>
<Modal
title='学术/文献搜索:'
open={isModalOpen}
footer={null}
onCancel={() => setIsModalOpen(false)}
width={1000}
>
<div className='message-scroll' ref={messageScrollRef}>
{messages.map((msg, index) => {
return <MessageRender msg={msg} key={index}></MessageRender>
})}
</div>
<div className='input-container'>
<div className='input-box'>
<div className='edit-area'>
{prePrompt && (
<span className='pre-prompt' ref={prePromptRef}>
{prePrompt}
</span>
)}
<TextArea
className='content'
autoSize
value={content}
placeholder='请输入关键词'
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleEnterSearch}
style={{ textIndent }}
/>
</div>
<div className='input-tools'>
<Button
type='primary'
size='large'
icon={<SendOutlined />}
onClick={handleSearch}
loading={isLoading}
/>
</div>
</div>
</div>
</Modal>
</ConfigProvider>
)
}
......@@ -7,8 +7,7 @@ import { useSearch } from '@/components/editor/hooks/useWenku'
import { useCopyToClipboard } from 'react-use'
export default function AIModal() {
const [isModalOpen, setIsModalOpen] = useState(true)
export default function AIModal({ open, onClose }) {
const [content, setContent] = useState('')
const [textIndent, setTextIndent] = useState(0)
const prePromptRef = useRef(null)
......@@ -20,7 +19,7 @@ export default function AIModal() {
const width = prePromptRef.current.offsetWidth + 10
setTextIndent(width)
}
}, [isModalOpen])
}, [open])
useEffect(() => {
if (messageScrollRef.current) {
......@@ -113,13 +112,7 @@ export default function AIModal() {
<ConfigProvider
theme={{ components: { Modal: { headerBg: '#f7f8fa', contentBg: '#f7f8fa' } } }}
>
<Modal
title='学术/文献搜索:'
open={isModalOpen}
footer={null}
onCancel={() => setIsModalOpen(false)}
width={1000}
>
<Modal title='学术/文献搜索:' open={open} footer={null} onCancel={onClose} width={1000}>
<div className='message-scroll' ref={messageScrollRef}>
{messages.map((msg, index) => {
return <MessageRender msg={msg} key={index}></MessageRender>
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AISearchModal from './BaiduSearchDialog'
class AIBaiduSearch extends BaseModalMenu {
constructor() {
super()
this.title = '学术搜索'
this.iconSvg = `<svg style="fill:none" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-file-search"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M12 21h-5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v4.5" /><path d="M16.5 17.5m-2.5 0a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /><path d="M18.5 19.5l2.5 2.5" /></svg>`
}
getValue(editor) {
return <AISearchModal key={Date.now()} editor={editor}></AISearchModal>
}
}
export default {
key: 'AIBaiduSearch',
factory() {
return new AIBaiduSearch()
},
}
import { defineEditorFeature } from '../../core/define-editor-feature'
import { createElement } from 'react'
import AIBaiduSearch from './baidu-search-menu'
import AISearch from './search-menu'
import AIWrite from './write-menu'
import SearchDialog from './SearchDialog'
import BaiduSearchDialog from './BaiduSearchDialog'
import AiWriteDialog from './AiWriteDialog'
const searchDialog = (props) => {
const Component = props.payload.provider === 'baidu' ? BaiduSearchDialog : SearchDialog
return createElement(Component, { editor: props.runtime.editor })
}
const writeDialog = (props) => createElement(AiWriteDialog, { editor: props.runtime.editor })
const dialog = (Component) => (props) =>
createElement(Component, {
open: props.open,
onClose: props.onClose,
})
export default defineEditorFeature({
id: 'ai-research',
menus: [
{ order: 300, config: AISearch },
{ order: 310, config: AIWrite },
{ order: 340, config: AIBaiduSearch },
],
dialogs: [
{ id: 'ai-research.search', component: searchDialog },
{ id: 'ai-research.write', component: writeDialog },
{ id: 'ai-research.search', component: dialog(SearchDialog) },
{ id: 'ai-research.write', component: dialog(AiWriteDialog) },
],
})
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AISearchModal from './SearchDialog'
import DialogMenu from '../../core/DialogMenu'
class AISearch extends BaseModalMenu {
class AISearch extends DialogMenu {
constructor() {
super()
super('ai-research.search', { provider: 'search' })
this.title = '学术搜索'
this.iconSvg = `<svg style="fill:none" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-file-search"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M12 21h-5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v4.5" /><path d="M16.5 17.5m-2.5 0a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /><path d="M18.5 19.5l2.5 2.5" /></svg>`
}
getValue(editor) {
return <AISearchModal key={Date.now()} editor={editor}></AISearchModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIWriteModal from './AiWriteDialog'
import DialogMenu from '../../core/DialogMenu'
class AIWrite extends BaseModalMenu {
class AIWrite extends DialogMenu {
constructor() {
super()
super('ai-research.write')
this.title = '写作/大纲'
this.iconSvg = `<svg style="fill:none" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pen"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>`
}
getValue(editor) {
return <AIWriteModal key={Date.now()} editor={editor}></AIWriteModal>
}
}
export default {
......
......@@ -8,9 +8,8 @@ import { useAI } from '@/components/editor/hooks/useAI'
import './styles.less'
const AIChatDrawer = (props) => {
const selectText = props.editor.getSelectionText()
const [open, setOpen] = useState(true)
const AIChatDrawer = ({ editor, open, onClose }) => {
const selectText = editor.getSelectionText()
const { ai, setAI, options, messages, post, isLoading } = useAI()
......@@ -48,7 +47,7 @@ const AIChatDrawer = (props) => {
size={600}
title='AI对话'
destroyOnHidden
onClose={() => setOpen(false)}
onClose={onClose}
rootClassName='ai-drawer-wrapper'
className='ai-drawer-container'
>
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIChatDrawer from './AiChatDialog'
import DialogMenu from '../../core/DialogMenu'
class AIChat extends BaseModalMenu {
class AIChat extends DialogMenu {
constructor() {
super()
super('ai-text.chat')
this.title = 'AI'
this.iconSvg = `<svg t="1713246032755" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12352" width="16" height="16"><path d="M826.368 325.632c0-7.168 2.048-10.24 10.24-10.24h123.904c7.168 0 10.24 2.048 10.24 10.24v621.568c0 7.168-2.048 10.24-10.24 10.24h-122.88c-8.192 0-10.24-4.096-10.24-10.24l-1.024-621.568z m-8.192-178.176c0-50.176 35.84-79.872 79.872-79.872 48.128 0 79.872 32.768 79.872 79.872 0 52.224-33.792 79.872-81.92 79.872-46.08 1.024-77.824-27.648-77.824-79.872zM462.848 584.704C441.344 497.664 389.12 307.2 368.64 215.04h-2.048c-16.384 92.16-58.368 247.808-92.16 369.664h188.416zM243.712 712.704l-62.464 236.544c-2.048 7.168-4.096 8.192-12.288 8.192H54.272c-8.192 0-10.24-2.048-8.192-12.288l224.256-783.36c4.096-13.312 7.168-26.624 8.192-65.536 0-6.144 2.048-8.192 7.168-8.192H450.56c6.144 0 8.192 2.048 10.24 8.192l250.88 849.92c2.048 7.168 0 10.24-7.168 10.24H573.44c-7.168 0-10.24-2.048-12.288-7.168l-65.536-236.544c1.024 1.024-251.904 0-251.904 0z" fill="#cdcdcd" p-id="12353"></path></svg>`
this.tag = 'button'
}
getValue(editor) {
return <AIChatDrawer key={Date.now()} editor={editor}></AIChatDrawer>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AIContentInspect extends BaseModalMenu {
class AIContentInspect extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'contentInspect' })
this.title = '内容检查'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11.5 13.5q.525 0 .988-.137t.912-.413l1.525 1.575q.2.225.513.213t.537-.213q.225-.225.238-.537T16 13.45l-1.55-1.55q.275-.425.413-.9T15 10q0-1.475-1.038-2.488T11.5 6.5T9.037 7.513T8 10t1.038 2.488T11.5 13.5m0-1.5q-.825 0-1.412-.587T9.5 10t.588-1.412T11.5 8q.8 0 1.4.588T13.5 10t-.587 1.413T11.5 12M2 21q-.425 0-.712-.288T1 20t.288-.712T2 19h20q.425 0 .713.288T23 20t-.288.713T22 21zm2-3q-.825 0-1.412-.587T2 16V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v11q0 .825-.587 1.413T20 18zm0-2h16V5H4zm0 0V5z"/></svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='contentInspect'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
class AIExpand extends BaseModalMenu {
import DialogMenu from '../../core/DialogMenu'
class AIExpand extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'expand' })
this.title = '扩写'
this.iconSvg = `<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
......@@ -14,9 +13,6 @@ class AIExpand extends BaseModalMenu {
</g>
</svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='expand'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AIPolishing extends BaseModalMenu {
class AIPolishing extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'abbreviate' })
this.title = '润色'
this.iconSvg = `<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
......@@ -19,9 +18,6 @@ class AIPolishing extends BaseModalMenu {
</g>
</svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='abbreviate'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AIPunctuation extends BaseModalMenu {
class AIPunctuation extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'punctuation' })
this.title = '标点校对'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M920 416H616c-4.4 0-8 3.6-8 8v112c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8v-56h60v320h-46c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h164c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8h-46V480h60v56c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V424c0-4.4-3.6-8-8-8M656 296V168c0-4.4-3.6-8-8-8H104c-4.4 0-8 3.6-8 8v128c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8v-64h168v560h-92c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h264c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8h-92V232h168v64c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8"/></svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='punctuation'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AIRewrite extends BaseModalMenu {
class AIRewrite extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'rewrite' })
this.title = '改写'
this.iconSvg = `<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
......@@ -18,9 +17,6 @@ class AIRewrite extends BaseModalMenu {
</svg>`
this.tag = 'button'
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='rewrite'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AISummary extends BaseModalMenu {
class AISummary extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'summary' })
this.title = '总结'
this.iconSvg = `<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
......@@ -18,9 +17,6 @@ class AISummary extends BaseModalMenu {
</g>
</svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='summary'></AIModal>
}
}
export default {
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import AIModal from './AiTextDialog'
import DialogMenu from '../../core/DialogMenu'
class AITranslate extends BaseModalMenu {
class AITranslate extends DialogMenu {
constructor() {
super()
super('ai-text.action', { action: 'translate' })
this.title = '翻译'
this.iconSvg = `<svg fill="#000000" viewBox="0 0 32 32" id="icon" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><defs><style>.cls-1{fill:none;}</style></defs><title>translate</title><path d="M27.85,29H30L24,14H21.65l-6,15H17.8l1.6-4h6.85ZM20.2,23l2.62-6.56L25.45,23Z"></path><path d="M18,7V5H11V2H9V5H2V7H12.74a14.71,14.71,0,0,1-3.19,6.18A13.5,13.5,0,0,1,7.26,9H5.16a16.47,16.47,0,0,0,3,5.58A16.84,16.84,0,0,1,3,18l.75,1.86A18.47,18.47,0,0,0,9.53,16a16.92,16.92,0,0,0,5.76,3.84L16,18a14.48,14.48,0,0,1-5.12-3.37A17.64,17.64,0,0,0,14.8,7Z"></path><rect id="_Transparent_Rectangle_" data-name="<Transparent Rectangle>" class="cls-1" width="32" height="32"></rect></g></svg>`
}
getValue(editor) {
return <AIModal key={Date.now()} editor={editor} action='translate'></AIModal>
}
}
export default {
......
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import AudioDialog from './AudioDialog'
export default defineEditorFeature({
......@@ -12,11 +11,4 @@ export default defineEditorFeature({
{ id: 'audio.insert', component: AudioDialog },
{ id: 'audio.edit', component: AudioDialog },
],
events: [
{
name: 'AudioMenuClick',
handler: ({ editor, payload }) =>
openEditorDialog(editor, payload?.path ? 'audio.edit' : 'audio.insert', payload),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class AudioAuto {
constructor() {
......@@ -61,14 +62,14 @@ class AudioAuto {
const chapterAudio = DomEditor.getSelectedNodeByType(editor, 'chapterAudio')
if (chapterAudio) {
const path = DomEditor.findPath(editor, chapterAudio)
editor.emit('AudioMenuClick', {
openEditorDialog(editor, 'audio.edit', {
path,
audioUrl: chapterAudio.audioUrl || '',
title: chapterAudio.title || '',
})
return
}
editor.emit('AudioMenuClick')
openEditorDialog(editor, 'audio.insert')
}
}
......
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { DomEditor } from '@wangeditor/editor'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withAudioNode = (editor) => {
const { isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
newEditor.isVoid = (elem) => {
......@@ -12,35 +13,7 @@ const withAudioNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterAudio') {
// 未命中 chapterAudio ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterAudio', ['paragraph', 'blockquote'])
}
// 在编辑器中渲染新元素
......
......@@ -3,7 +3,6 @@ import itemMenu from './section-menu'
import titleMenu from './title-menu'
import sectionNodeModule from './section-node'
import headerNodeModule from './title-node'
import { openEditorDialog } from '../../core/editor-dialog'
import { ChapterTitleFeatureDialog, ChapterSectionFeatureDialog } from './FeatureDialogs'
export default defineEditorFeature({
......@@ -20,14 +19,4 @@ export default defineEditorFeature({
{ id: 'chapter-structure.title', component: ChapterTitleFeatureDialog },
{ id: 'chapter-structure.section', component: ChapterSectionFeatureDialog },
],
events: [
{
name: 'ChapterItemMenuClick',
handler: ({ editor }) => openEditorDialog(editor, 'chapter-structure.section'),
},
{
name: 'ChapterTitleClick',
handler: ({ editor }) => openEditorDialog(editor, 'chapter-structure.title'),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class ChapterItem {
constructor() {
......@@ -55,7 +56,7 @@ class ChapterItem {
if (this.isDisabled(editor)) {
return
}
editor.emit('ChapterItemMenuClick')
openEditorDialog(editor, 'chapter-structure.section')
}
}
......
......@@ -3,9 +3,10 @@ import iconClose from '@/assets/images/icon_chapter_close.png'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import { findNodeWithParent } from '../../shared/editor-settings'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withChapterSectionNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
// newEditor.isInline = (elem) => {
......@@ -20,41 +21,7 @@ const withChapterSectionNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterSection') {
// 未命中 chapterSection ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterSection')
}
// 在编辑器中渲染新元素
......
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class ChapterTitle {
constructor() {
......@@ -55,7 +56,7 @@ class ChapterTitle {
if (this.isDisabled(editor)) {
return
}
editor.emit('ChapterTitleClick')
openEditorDialog(editor, 'chapter-structure.title')
}
}
......
......@@ -3,9 +3,10 @@ import iconClose from '@/assets/images/icon_chapter_close.png'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import { findNodeWithParent } from '../../shared/editor-settings'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withChapterHeaderNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
// newEditor.isInline = (elem) => {
......@@ -20,40 +21,7 @@ const withChapterHeaderNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterHeader') {
// 未命中 chapterHeader ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote (否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterHeader')
}
// 在编辑器中渲染新元素
......
const CHINESE_TEXT = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/
const PARAGRAPH = /<p\b[^>]*>[\s\S]*?<\/p>/gi
const TEXT_BETWEEN_TAGS = />([^<]*)</g
const SPACES = /(?:&nbsp;|&#160;|&#x0*a0;|[ \u00a0])+/gi
export const removeSpacesFromChineseParagraphs = (html) =>
html.replace(PARAGRAPH, (paragraph) => {
const text = paragraph.replace(/<[^>]+>/g, '')
if (!CHINESE_TEXT.test(text)) return paragraph
return paragraph.replace(TEXT_BETWEEN_TAGS, (_, content) => {
return `>${content.replace(SPACES, '')}<`
})
})
class RemoveSpaces {
constructor() {
this.title = '去空格'
......@@ -5,49 +20,29 @@ class RemoveSpaces {
this.tag = 'button'
}
// eslint-disable-next-line no-unused-vars
getValue(editor) {
getValue() {
return ''
}
// eslint-disable-next-line no-unused-vars
isActive(editor) {
isActive() {
return false
}
// eslint-disable-next-line no-unused-vars
isDisabled(editor) {
isDisabled() {
return false
}
exec(editor) {
// 获取当前 HTML
const html = editor.getHtml()
if (!html) return
if (!html) {
return
}
// 使用正则表达式去除 HTML 标签之间文本的空格
// 匹配 >文本< 之间的内容,去除空格字符(包括多个相邻的空格)和 &nbsp;,保留其他字符和标签结构
const htmlWithoutSpaces = html.replace(/>([^<]*?)</g, (match, text) => {
// 先去除所有的 &nbsp; HTML 实体
let textWithoutSpaces = text.replace(/&nbsp;+/gi, '')
// 然后去除所有空格字符(包括多个相邻的空格),保留其他字符
textWithoutSpaces = textWithoutSpaces.replace(/ +/g, '')
return `>${textWithoutSpaces}<`
})
// 如果内容有变化,设置回去
if (htmlWithoutSpaces !== html) {
// 设置处理后的 HTML
editor.setHtml(htmlWithoutSpaces)
}
const nextHtml = removeSpacesFromChineseParagraphs(html)
if (nextHtml !== html) editor.setHtml(nextHtml)
}
}
export default {
key: 'RemoveSpaces', // 定义 menu key :要保证唯一、不重复(重要)
key: 'RemoveSpaces',
factory() {
return new RemoveSpaces()
},
......
......@@ -2,8 +2,6 @@ import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import simpleNodeModule from './simple-node'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
import ExpandReadFeatureDialog from './FeatureDialog'
export default defineEditorFeature({
......@@ -17,13 +15,4 @@ export default defineEditorFeature({
{ id: 'expand-read.insert', component: ExpandReadFeatureDialog },
{ id: 'expand-read.edit', component: ExpandReadFeatureDialog },
],
events: [
{
name: 'ExpandReadClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'expand-read.insert', {
selectionSize: getSelectionFontSize(editor),
}),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
class ExpandRead {
constructor() {
......@@ -54,7 +56,9 @@ class ExpandRead {
if (this.isDisabled(editor)) {
return
}
editor.emit('ExpandReadClick')
openEditorDialog(editor, 'expand-read.insert', {
selectionSize: getSelectionFontSize(editor),
})
}
}
......
import {
DomEditor,
SlateTransforms,
SlateEditor,
SlateElement,
SlateNode,
} from '@wangeditor/editor'
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import iconExpand from '@/assets/images/editor/icon_expand_read.png'
import iconClose from '@/assets/images/icon_chapter_close.png'
import { findNodeWithParent } from '../../shared/editor-settings'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withExpandReadNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
// newEditor.isInline = (elem) => {
......@@ -27,41 +22,7 @@ const withExpandReadNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterExpandRead') {
// 未命中 chapterExpandRead ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterExpandRead')
}
// 在编辑器中渲染新元素
......
import {
DomEditor,
SlateTransforms,
SlateEditor,
SlateElement,
SlateNode,
} from '@wangeditor/editor'
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import iconExpand from '@/assets/images/editor/icon_expand_tips.png'
import iconExpandT from '@/assets/images/editor/icon_expand_editor.png'
import { normalizeInlineVoidNode } from '../../shared/normalize-inline-void'
const withExpandReadNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
......@@ -28,43 +21,14 @@ const withExpandReadNode = (editor) => {
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterExpandReadSimple') {
// 未命中 chapterExpandReadSimple ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0]] || {}
if (nextNode.type === 'paragraph') {
const lastChild = nextNode.children[nextNode.children.length - 1]
if (lastChild.type === 'chapterExpandReadSimple') {
SlateTransforms.insertNodes(
newEditor,
{ type: 'span', children: [{ text: '&nbsp;' }] },
{
at: path, // 在 link-card 后面插入
},
)
}
}
// const nextNodeType = DomEditor.getNodeType(nextNode);
// console.log(nextNode, nextNodeType);
// if (
// nextNodeType !== 'paragraph' &&
// nextNodeType !== 'blockquote' &&
// !nextNodeType.startsWith('header')
// ) {
// // link-card node 后面不是 p 或 header ,则插入一个空 p
// const p = { type: 'paragraph', children: [{ text: '' }] };
// const insertPath = [path[0] + 1];
// SlateTransforms.insertNodes(newEditor, p, {
// at: insertPath, // 在 link-card 后面插入
// });
// }
return normalizeInlineVoidNode({
editor: newEditor,
node,
path,
type: 'chapterExpandReadSimple',
normalizeNode,
transforms: SlateTransforms,
})
}
return newEditor // 返回 newEditor ,重要!!!
......
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import FormulaDialog from './FormulaDialog'
export default defineEditorFeature({
......@@ -9,10 +8,4 @@ export default defineEditorFeature({
menus: [{ order: 60, config: menu }],
nodeModules: [{ order: 80, types: ['chapterFormula'], module: nodeModule }],
dialogs: [{ id: 'formula.insert', component: FormulaDialog }],
events: [
{
name: 'FormulaMenuClick',
handler: ({ editor }) => openEditorDialog(editor, 'formula.insert'),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class FormulaAuto {
constructor() {
......@@ -58,7 +59,7 @@ class FormulaAuto {
if (this.isDisabled(editor)) {
return
}
editor.emit('FormulaMenuClick')
openEditorDialog(editor, 'formula.insert')
}
}
......
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import icon_tooltip_editor from '@/assets/images/editor/icon_tooltip_editor.png'
import { h } from 'snabbdom'
import { normalizeInlineVoidNode } from '../../shared/normalize-inline-void'
const withChapterFormulaNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
......@@ -20,26 +20,14 @@ const withChapterFormulaNode = (editor) => {
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterFormula') {
// 未命中 chapterHeader ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
const topLevelNodes = newEditor.children || []
const nextNode = topLevelNodes[path[0]] || {}
if (nextNode.type === 'paragraph') {
const lastChild = nextNode.children[nextNode.children.length - 1]
if (lastChild.type === 'chapterExpandReadSimple') {
SlateTransforms.insertNodes(
newEditor,
{ type: 'span', children: [{ text: '' }] },
{
at: path, // 在 link-card 后面插入
},
)
}
}
return normalizeInlineVoidNode({
editor: newEditor,
node,
path,
type: 'chapterFormula',
normalizeNode,
transforms: SlateTransforms,
})
}
return newEditor // 返回 newEditor ,重要!!!
......
......@@ -3,8 +3,6 @@ import menu from './menu'
import onlineMenu from './online-menu'
import nodeModule from './node'
import inlineNodeModule from './inline-node'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
import GalleryFeatureDialog from './FeatureDialog'
export default defineEditorFeature({
......@@ -21,22 +19,4 @@ export default defineEditorFeature({
{ id: 'gallery.insert', component: GalleryFeatureDialog },
{ id: 'gallery.edit', component: GalleryFeatureDialog },
],
events: [
{
name: 'GalleryMenuClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'gallery.insert', {
isOnline: false,
selectionSize: getSelectionFontSize(editor),
}),
},
{
name: 'GalleryOnlineMenuClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'gallery.insert', {
isOnline: true,
selectionSize: getSelectionFontSize(editor),
}),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
class GalleryAuto {
constructor() {
......@@ -60,7 +62,10 @@ class GalleryAuto {
if (this.isDisabled(editor)) {
return
}
editor.emit('GalleryMenuClick')
openEditorDialog(editor, 'gallery.insert', {
isOnline: false,
selectionSize: getSelectionFontSize(editor),
})
}
}
......
......@@ -4,9 +4,10 @@ import iconGallery from '@/assets/images/editor/icon_gallery_editor.png'
import iconClose from '@/assets/images/icon_chapter_close.png'
import { findNodeWithParent } from '../../shared/editor-settings'
import { openEditorDialog } from '../../core/editor-dialog'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withGalleryNode = (editor) => {
const { isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
// newEditor.isInline = (elem) => {
......@@ -21,41 +22,7 @@ const withGalleryNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterGallery') {
// 未命中 chapterGallery ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterGallery')
}
// 在编辑器中渲染新元素
......
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
class GalleryAutoOnline {
constructor() {
......@@ -65,7 +67,10 @@ class GalleryAutoOnline {
if (this.isDisabled(editor)) {
return
}
editor.emit('GalleryOnlineMenuClick')
openEditorDialog(editor, 'gallery.insert', {
isOnline: true,
selectionSize: getSelectionFontSize(editor),
})
}
}
......
......@@ -21,8 +21,7 @@ export default function HistoryFeatureDialog({ open, onClose, runtime }) {
<HistoryDialog
chapterId={runtime.chapterId}
bookId={runtime.bookId}
saveContent={runtime.saveContent}
setContent={runtime.setContent}
onRestore={runtime.restoreContent}
setHistoryVisible={(visible) => !visible && onClose()}
/>
</Modal>
......
import React, { useState } from 'react'
import { Divider, Button, Spin, Modal, Row, Col, Space } from 'antd'
import { useEffect, useState } from 'react'
import { Button, Spin, Modal, Row, Space } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import { getRecordList, getRecordInfo } from '../../shared/request'
import './styles.less'
import { useEffect } from 'react'
const HistoryEditor = (props) => {
const { setHistoryVisible, chapterId, bookId, setContent, saveContent } = props
const { setHistoryVisible, chapterId, bookId, onRestore } = props
const [loading, setLoading] = useState(false)
const [delModal, setDelModal] = useState(false)
......@@ -16,9 +14,8 @@ const HistoryEditor = (props) => {
const [recordId, setRecordId] = useState(null) // 当前的记录id
// 确认还原
const handleSuccess = () => {
setContent(recordContent)
saveContent()
const handleSuccess = async () => {
await onRestore(recordContent)
setDelModal(false)
setHistoryVisible(false)
}
......@@ -34,7 +31,7 @@ const HistoryEditor = (props) => {
})
if (data.length > 0) {
setRecordList(data)
await getRecordInfoVal(data[0].name)
setRecordId(data[0].name)
}
setLoading(false)
}
......@@ -48,7 +45,7 @@ const HistoryEditor = (props) => {
name: name || recordId,
})
if (name) {
setRecordId[name]
setRecordId(name)
}
setRecordContent(data.content)
setLoading(false)
......@@ -140,10 +137,10 @@ const HistoryEditor = (props) => {
onCancel={() => setDelModal(false)}
footer={(_, { OkBtn, CancelBtn }) => {
return (
<Row justify={'end'} className='delModal' style={{ marginBottom: '30px' }}>
<Row justify={'end'} style={{ marginBottom: '30px' }}>
<Space size={20}>
<CancelBtn className='cancel' />
<OkBtn className='true' />
<CancelBtn />
<OkBtn />
</Space>
</Row>
)
......
import BaseModalMenu from '../../core/LegacyDialogMenu'
import DialogMenu from '../../core/DialogMenu'
import { SlateRange } from '@wangeditor/editor'
import IconModal from './IconDialog'
class Icon extends BaseModalMenu {
class Icon extends DialogMenu {
constructor() {
super()
super('icon.insert')
this.title = '插入图标'
// Using a smiley/icon-like SVG
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.5199 19.8634C10.5955 18.6615 10.8833 17.5172 11.3463 16.4676C9.81124 16.3252 8.41864 15.6867 7.33309 14.7151L8.66691 13.2248C9.55217 14.0172 10.7188 14.4978 12 14.4978C12.1763 14.4978 12.3501 14.4887 12.5211 14.471C14.227 12.2169 16.8661 10.7083 19.8634 10.5199C19.1692 6.80877 15.9126 4 12 4C7.58172 4 4 7.58172 4 12C4 15.9126 6.80877 19.1692 10.5199 19.8634ZM19.0233 12.636C15.7891 13.2396 13.2396 15.7891 12.636 19.0233L19.0233 12.636ZM22 12C22 12.1677 21.9959 12.3344 21.9877 12.5L12.5 21.9877C12.3344 21.9959 12.1677 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM10 10C10 10.8284 9.32843 11.5 8.5 11.5C7.67157 11.5 7 10.8284 7 10C7 9.17157 7.67157 8.5 8.5 8.5C9.32843 8.5 10 9.17157 10 10ZM17 10C17 10.8284 16.3284 11.5 15.5 11.5C14.6716 11.5 14 10.8284 14 10C14 9.17157 14.6716 8.5 15.5 8.5C16.3284 8.5 17 9.17157 17 10Z"></path></svg>`
}
getValue(editor) {
return <IconModal key={Date.now()} editor={editor}></IconModal>
}
isDisabled(editor) {
const { selection } = editor || {}
return !selection || SlateRange.isExpanded(selection)
......
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
const getSelectedImageInfo = (editor) => {
const image = DomEditor.getSelectedNodeByType(editor, 'image')
if (!image) return {}
const [node, path] = SlateEditor.parent(editor, DomEditor.findPath(editor, image))
return { image, node, path }
}
class ImageEditor {
constructor() {
......@@ -37,7 +46,10 @@ class ImageEditor {
if (this.isDisabled(editor)) {
return
}
editor.emit('ImageEditorClick')
openEditorDialog(editor, 'image.edit', {
isOnline: false,
imageInfo: getSelectedImageInfo(editor),
})
}
}
......
......@@ -2,26 +2,8 @@ import { defineEditorFeature } from '../../core/define-editor-feature'
import editMenu from './edit-menu'
import onlineMenu from './online-menu'
import nodeModule from './node'
import { SlateEditor, SlateElement } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import ImageFeatureDialog from './FeatureDialog'
const getSelectedImageInfo = (editor) => {
const entries = SlateEditor.nodes(editor, {
match: (node) => SlateElement.isElement(node) && node.type === 'paragraph',
universal: true,
})
const info = {}
for (const [node, path] of entries) {
node.children.forEach((item) => {
if (item.type === 'image') info.image = item
})
info.node = node
info.path = path
}
return info
}
export default defineEditorFeature({
id: 'image',
menus: [
......@@ -33,22 +15,4 @@ export default defineEditorFeature({
{ id: 'image.insert', component: ImageFeatureDialog },
{ id: 'image.edit', component: ImageFeatureDialog },
],
events: [
{
name: 'ImageMenuClick',
handler: ({ editor }) => openEditorDialog(editor, 'image.insert', { isOnline: false }),
},
{
name: 'ImageOnlineMenuClick',
handler: ({ editor }) => openEditorDialog(editor, 'image.insert', { isOnline: true }),
},
{
name: 'ImageEditorClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'image.edit', {
isOnline: false,
imageInfo: getSelectedImageInfo(editor),
}),
},
],
})
import { DomEditor, SlateTransforms as Transforms, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
// Extend menu
class ImageAuto {
......@@ -16,11 +17,11 @@ class ImageAuto {
</svg>`
this.tag = 'button'
}
getValue(editor) {
return 'hello, 图片, , , 图片'
getValue() {
return ''
}
isActive(editor) {
return false // or true
isActive() {
return false
}
isDisabled(editor) {
const { selection } = editor
......@@ -32,69 +33,74 @@ class ImageAuto {
const hasVoidElem = selectedElems.some((elem) => editor.isVoid(elem))
if (hasVoidElem) return true // 选中了 void 元素,禁用
// eslint-disable-next-line array-callback-return
const hasPreElem = selectedElems.some((elem) => {
const type = DomEditor.getNodeType(elem)
// 代码块 引用 表格 禁用
if (type === 'pre' || type === 'blockquote') return true
return type === 'pre' || type === 'blockquote'
})
if (hasPreElem) return true // 选中了 pre 原则,禁用
return false
return hasPreElem
}
getSelectedNode(editor) {
return DomEditor.getSelectedNodeByType(editor, 'chapterImage')
exec(editor) {
if (!this.isDisabled(editor)) openEditorDialog(editor, 'image.insert', { isOnline: false })
}
exec(editor, value) {
// editor.insertText(value) // value 即 this.getValue(editor) 的返回值
editor.emit('ImageMenuClick')
}
if (this.isDisabled(editor)) {
return
class ImageWidthMenu {
constructor(title, width) {
this.title = title
this.width = width
this.tag = 'button'
}
getValue() {
return ''
}
isActive() {
return false
}
isDisabled(editor) {
return !DomEditor.getSelectedNodeByType(editor, 'chapterImage')
}
exec(editor) {
const imageNode = DomEditor.getSelectedNodeByType(editor, 'chapterImage')
if (!imageNode) return
const imageNode = this.getSelectedNode(editor)
if (imageNode === null) return
// 隐藏 hoverbar
const hoverbar = DomEditor.getHoverbar(editor)
if (hoverbar) hoverbar.hideAndClean()
const { style = {} } = imageNode
const props = {
style: {
...style,
width: this.value, // 修改 width
height: '', // 清空 height
Transforms.setNodes(
editor,
{
style: { ...style, width: this.width, height: '' },
},
}
Transforms.setNodes(editor, props, {
match: (n) => DomEditor.checkNodeType(n, 'chapterImage'),
})
{
at: DomEditor.findPath(editor, imageNode),
},
)
}
}
class ImageWidthCustomer100 extends ImageAuto {
title = '100%' // 菜单标题
value = '100%' // css width 的值
class ImageWidthCustomer100 extends ImageWidthMenu {
constructor() {
super('100%', '100%')
}
}
class ImageWidthCustomer50 extends ImageAuto {
title = '50%' // 菜单标题
value = '50%' // css width 的值
class ImageWidthCustomer50 extends ImageWidthMenu {
constructor() {
super('50%', '50%')
}
}
class ImageWidthCustomer30 extends ImageAuto {
title = '30%' // 菜单标题
value = '30%' // css width 的值
class ImageWidthCustomer30 extends ImageWidthMenu {
constructor() {
super('30%', '30%')
}
}
export default {
key: 'ImageAuto', // 定义 menu key :要保证唯一、不重复(重要)
key: 'ImageAuto',
factory() {
return new ImageAuto() // 把 `YourMenuClass` 替换为你菜单的 class
return new ImageAuto()
},
}
......
......@@ -3,6 +3,7 @@ import { h } from 'snabbdom'
import { throttle } from 'lodash-es'
import $ from 'jquery'
import { getStyleValue } from '@/utils/common'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
import ImageAutoConf, {
imageWidth100MenuChapterConf,
......@@ -11,7 +12,7 @@ import ImageAutoConf, {
} from './menu'
const withImageNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
const { isInline, isVoid } = editor
const newEditor = editor
newEditor.isInline = (elem) => {
......@@ -26,35 +27,7 @@ const withImageNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterImage') {
// 未命中 chapterImage ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterImage', ['paragraph', 'blockquote'])
}
function genContainerId(editor, elemNode) {
......@@ -100,6 +73,8 @@ function renderResizeContainer(editor, elemNode, imageVnode, imageInfo) {
*/
function init(clientX) {
$container = getContainerElem()
$body.off('mousemove', onMousemove)
$body.off('mouseup', onMouseup)
// 记录当前 x 坐标值
originalX = clientX
......@@ -138,9 +113,11 @@ function renderResizeContainer(editor, elemNode, imageVnode, imageInfo) {
$container.css('height', `${newHeight}px`)
}, 100)
function onMouseup(e) {
function onMouseup() {
// 取消监听 mousemove
$body.off('mousemove', onMousemove)
$body.off('mouseup', onMouseup)
onMousemove.cancel()
if ($container === null) return
const newWidth = $container.width().toFixed(2)
......@@ -154,10 +131,7 @@ function renderResizeContainer(editor, elemNode, imageVnode, imageInfo) {
height: `${newHeight}px`,
},
}
Transforms.setNodes(editor, props, { at: DomEditor.findPath(editor, elemNode) })
// 取消监听 mouseup
$body.off('mouseup', onMouseup)
SlateTransforms.setNodes(editor, props, { at: DomEditor.findPath(editor, elemNode) })
}
const style = {}
......@@ -165,14 +139,6 @@ function renderResizeContainer(editor, elemNode, imageVnode, imageInfo) {
if (height) style.height = height
// style.boxShadow = '0 0 0 1px #B4D5FF' // 自定义 selected 样式,因为有拖拽触手
const normalStyle =
'width: 7px; height: 7px; background-color: #4290f7; position: absolute; cursor: nwse-resize; z-index: 12;'
const strDom = `${imageVnode}<div className='w-e-image-dragger left-top' style="${normalStyle} left: 0; top: 0;"></div>
<div className='w-e-image-dragger right-top' style="${normalStyle} right: 0; top: 0;"></div>
<div className='w-e-image-dragger left-bottom' style="${normalStyle} left: 0; bottom: 0;"></div>
<div className='w-e-image-dragger right-bottom' style="${normalStyle} right: 0; bottom: 0;"></div>`
return h(
'div',
{
......@@ -301,7 +267,7 @@ function renderResizeContainer(editor, elemNode, imageVnode, imageInfo) {
// 定义 renderElem 函数
const renderImage = (elem, children, editor) => {
// 获取“附件”的数据,参考上文 myResume 数据结构
const { title = '', imgUrl = '', imgDescript = '', style = {} } = elem
const { imgUrl = '', imgDescript = '', style = {} } = elem
const { width = '', height = '' } = style
let imageStyle = ''
......@@ -332,34 +298,7 @@ const renderImage = (elem, children, editor) => {
},
[str],
)
const titleNode = h(
'p',
{
props: {
className: 'chapter-image-title',
},
style: { color: '#666', fontSize: '14px', margin: '0', lineHeight: '20px' },
},
[title],
)
const titleDescriptNode = h(
'p',
{
props: {
className: 'chapter-image-descript',
},
style: { color: '#999', fontSize: '14px', margin: '0', lineHeight: '20px' },
},
[imgDescript],
)
const element = [imgNode]
if (title) {
// element.push(titleNode);
}
if (imgDescript) {
// element.push(titleDescriptNode);
}
// 附件元素 vnode
const attachVnode = h(
......@@ -373,11 +312,6 @@ const renderImage = (elem, children, editor) => {
className: 'chapter-image-container',
},
style: { padding: '10px 0px' }, // style ,驼峰式写法
on: {
click() {
console.log('clicked')
} /* 其他... */,
},
},
// 子节点
[...element],
......
import { DomEditor, SlateTransforms as Transforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
// Extend menu
class ImageAutoOnline {
......@@ -30,11 +31,11 @@ class ImageAutoOnline {
</svg>`
this.tag = 'button'
}
getValue(editor) {
return 'hello, 图片, , , 图片'
getValue() {
return ''
}
isActive(editor) {
return false // or true
isActive() {
return false
}
isDisabled(editor) {
const { selection } = editor
......@@ -46,63 +47,15 @@ class ImageAutoOnline {
const hasVoidElem = selectedElems.some((elem) => editor.isVoid(elem))
if (hasVoidElem) return true // 选中了 void 元素,禁用
// eslint-disable-next-line array-callback-return
const hasPreElem = selectedElems.some((elem) => {
const type = DomEditor.getNodeType(elem)
// 代码块 引用 表格 禁用
if (type === 'pre' || type === 'blockquote') return true
return type === 'pre' || type === 'blockquote'
})
if (hasPreElem) return true // 选中了 pre 原则,禁用
return false
return hasPreElem
}
getSelectedNode(editor) {
return DomEditor.getSelectedNodeByType(editor, 'chapterImage')
exec(editor) {
if (!this.isDisabled(editor)) openEditorDialog(editor, 'image.insert', { isOnline: true })
}
exec(editor, value) {
// editor.insertText(value) // value 即 this.getValue(editor) 的返回值
editor.emit('ImageOnlineMenuClick')
if (this.isDisabled(editor)) {
return
}
const imageNode = this.getSelectedNode(editor)
if (imageNode === null) return
// 隐藏 hoverbar
const hoverbar = DomEditor.getHoverbar(editor)
if (hoverbar) hoverbar.hideAndClean()
const { style = {} } = imageNode
const props = {
style: {
...style,
width: this.value, // 修改 width
height: '', // 清空 height
},
}
Transforms.setNodes(editor, props, {
match: (n) => DomEditor.checkNodeType(n, 'chapterImage'),
})
}
}
class ImageWidthCustomer100 extends ImageAutoOnline {
title = '100%' // 菜单标题
value = '100%' // css width 的值
}
class ImageWidthCustomer50 extends ImageAutoOnline {
title = '50%' // 菜单标题
value = '50%' // css width 的值
}
class ImageWidthCustomer30 extends ImageAutoOnline {
title = '30%' // 菜单标题
value = '30%' // css width 的值
}
export default {
......@@ -112,30 +65,4 @@ export default {
},
}
const imageWidth100MenuChapterConf = {
key: 'imageWidthChpater100',
factory() {
return new ImageWidthCustomer100()
},
}
const imageWidth50MenuChapterConf = {
key: 'imageWidthChpater50',
factory() {
return new ImageWidthCustomer50()
},
}
const imageWidth30MenuChapterConf = {
key: 'imageWidthChpater30',
factory() {
return new ImageWidthCustomer30()
},
}
export {
ImageAutoOnline,
imageWidth100MenuChapterConf,
imageWidth50MenuChapterConf,
imageWidth30MenuChapterConf,
}
export { ImageAutoOnline }
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
import LinkFeatureDialog from './FeatureDialog'
export default defineEditorFeature({
......@@ -13,14 +11,4 @@ export default defineEditorFeature({
{ id: 'link.insert', component: LinkFeatureDialog },
{ id: 'link.edit', component: LinkFeatureDialog },
],
events: [
{
name: 'CustomerLinkClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'link.insert', {
selectionSize: getSelectionFontSize(editor),
theme: '#ab1941',
}),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
class CustomerLink {
constructor() {
......@@ -44,7 +46,10 @@ class CustomerLink {
if (this.isDisabled(editor)) {
return
}
editor.emit('CustomerLinkClick')
openEditorDialog(editor, 'link.insert', {
selectionSize: getSelectionFontSize(editor),
theme: '#ab1941',
})
}
}
......
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import icon_tooltip_editor from '@/assets/images/editor/icon_tooltip_editor.png'
import { h } from 'snabbdom'
import iconLink from '@/assets/images/editor/icon_link_editor.png'
import { openEditorDialog } from '../../core/editor-dialog'
import { normalizeInlineVoidNode } from '../../shared/normalize-inline-void'
const withChapterLinkNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
......@@ -23,26 +22,14 @@ const withChapterLinkNode = (editor) => {
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterLink') {
// 未命中 chapterHeader ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
const topLevelNodes = newEditor.children || []
const nextNode = topLevelNodes[path[0]] || {}
if (nextNode.type === 'paragraph') {
const lastChild = nextNode.children[nextNode.children.length - 1]
if (lastChild.type === 'chapterExpandReadSimple') {
SlateTransforms.insertNodes(
newEditor,
{ type: 'span', children: [{ text: '' }] },
{
at: path, // 在 link-card 后面插入
},
)
}
}
return normalizeInlineVoidNode({
editor: newEditor,
node,
path,
type: 'chapterLink',
normalizeNode,
transforms: SlateTransforms,
})
}
return newEditor // 返回 newEditor ,重要!!!
......
......@@ -85,7 +85,7 @@ const PracticeSettingsDialog = ({ editor, chapterId, bookId, closePanel }) => {
}
return (
<Row gutter={20} className='practice-topic-row'>
<Row gutter={20}>
<Col span={12} className='common practice-left'>
<div className='practice-header'>
<div className='practice-l-img'>
......@@ -123,7 +123,7 @@ const PracticeSettingsDialog = ({ editor, chapterId, bookId, closePanel }) => {
/>
</div>
</Col>
<Col span={12} className='common practice-right'>
<Col span={12} className='common'>
<div className='practice-header right'>
<Button onClick={() => setManualAdd((value) => !value)}>
{manualAdd ? '从题库选择' : '新增试题'}
......
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import { PracticeInsertFeatureDialog, PracticeSettingsFeatureDialog } from './FeatureDialogs'
export default defineEditorFeature({
......@@ -12,10 +11,4 @@ export default defineEditorFeature({
{ id: 'practice.insert', component: PracticeInsertFeatureDialog },
{ id: 'practice.settings', component: PracticeSettingsFeatureDialog },
],
events: [
{
name: 'PracticeMenuClick',
handler: ({ editor }) => openEditorDialog(editor, 'practice.insert'),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class Practice {
constructor() {
......@@ -55,7 +56,7 @@ class Practice {
if (this.isDisabled(editor)) {
return
}
editor.emit('PracticeMenuClick')
openEditorDialog(editor, 'practice.insert')
}
}
......
import { DomEditor, SlateTransforms, SlateEditor } from '@wangeditor/editor'
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import iconClose from '@/assets/images/icon_chapter_close.png'
......@@ -6,9 +6,10 @@ import { delChapterTopic } from '../../shared/request'
import { hexToRgb } from '@/utils/common'
import { findNodeWithParent } from '../../shared/editor-settings'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withPracticeNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
// newEditor.isInline = (elem) => {
......@@ -23,41 +24,7 @@ const withPracticeNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterPractice') {
// 未命中 chapterPractice ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterPractice')
}
// 在编辑器中渲染新元素
......
......@@ -24,7 +24,6 @@ export default function PreviewFeatureDialog({ open, onClose, runtime }) {
onCancel={onClose}
>
<PreviewDialog
gData={runtime.gData}
editor={runtime.editor}
chapterId={runtime.chapterId}
bookId={runtime.bookId}
......
import { Input } from 'antd'
const QUESTION_TYPES = [
{ style: 1, title: '单选题' },
{ style: 2, title: '多选题' },
{ style: 3, title: '判断题' },
{ style: 4, title: '填空题' },
{ style: 5, title: '简答题' },
]
const SECTION_NUMBERS = ['一', '二', '三', '四', '五']
export function buildTopicGroups(topics) {
const normalizedTopics = (Array.isArray(topics) ? topics : []).map((topic) => {
const questionStyle = Number(topic.question_style)
let questionList = []
if ([1, 2, 3].includes(questionStyle)) {
try {
questionList = Array.isArray(topic.question_list)
? topic.question_list
: JSON.parse(topic.question_list)
} catch {
questionList = []
}
}
return { ...topic, question_style: questionStyle, question_list: questionList }
})
return QUESTION_TYPES.map((type) => ({
...type,
topics: normalizedTopics.filter((topic) => topic.question_style === type.style),
})).filter((group) => group.topics.length > 0)
}
function Question({ topic, index }) {
const isChoice = [1, 2, 3].includes(topic.question_style)
return (
<div className='practice-item'>
<div
className='topic-choose-title'
dangerouslySetInnerHTML={{
__html: `<span class="index">${index + 1}.</span>${topic.titles}`,
}}
/>
<div className='topic_choose-list'>
{isChoice &&
topic.question_list.map((option, optionIndex) => (
<div className='topic_choose-item' key={optionIndex}>
<div className='choose-ico'>
<i className={`ico ${topic.question_style === 2 ? 'checkbox' : 'radio'}`} />
</div>
<div
className='topic_content'
dangerouslySetInnerHTML={{
__html: `<span class="correct">${String.fromCharCode(65 + optionIndex)}.</span> ${option.option}`,
}}
/>
</div>
))}
{topic.question_style === 5 && (
<Input.TextArea disabled autoSize={{ minRows: 4, maxRows: 6 }} />
)}
</div>
</div>
)
}
export default function PracticePreview({ groups }) {
return (
<div className='practice-insert-topic'>
{groups.map((group, groupIndex) => (
<div className='topic_style' key={group.style}>
<h3>
{SECTION_NUMBERS[groupIndex]}. {group.title}
</h3>
<div className='practice-list'>
{group.topics.map((topic, index) => (
<Question topic={topic} index={index} key={topic.id ?? index} />
))}
</div>
</div>
))}
</div>
)
}
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import PracticePreview, { buildTopicGroups } from './PracticePreview'
describe('PracticePreview', () => {
afterEach(cleanup)
it('normalizes and groups questions returned by the API', () => {
const groups = buildTopicGroups([
{
id: 1,
question_style: '2',
titles: '多选题',
question_list: '[{"option":"选项 A"}]',
},
{ id: 2, question_style: 5, titles: '简答题' },
])
expect(groups.map((group) => group.style)).toEqual([2, 5])
expect(groups[0].topics[0].question_list).toEqual([{ option: '选项 A' }])
})
it('numbers only the question groups that have content', () => {
const groups = buildTopicGroups([
{ id: 1, question_style: 3, titles: '判断题', question_list: [] },
{ id: 2, question_style: 5, titles: '简答题' },
])
render(<PracticePreview groups={groups} />)
expect(screen.getByRole('heading', { name: '一. 判断题' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '二. 简答题' })).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeDisabled()
})
})
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PreviewDialog from './PreviewDialog'
import { expandReadInfo } from '../../shared/request'
import hljs from 'highlight.js'
vi.mock('@/modules/books/management/chapters/api', () => ({
getInfoByChapterId: vi.fn(),
}))
vi.mock('../../shared/request', () => ({
expandReadInfo: vi.fn(),
getChapterTopic: vi.fn(),
}))
vi.mock('highlight.js', () => ({
default: { highlightElement: vi.fn() },
}))
describe('PreviewDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(cleanup)
it('processes current chapter HTML after React renders it', async () => {
const editor = {
getHtml: () => '<pre><code>const value = 1</code></pre><video></video>',
}
render(<PreviewDialog editor={editor} bookId={86} chapterId={10} nowTitle='当前章节' />)
await waitFor(() => expect(hljs.highlightElement).toHaveBeenCalledOnce())
const video = document.querySelector('.preview-content-html video')
expect(video).toHaveAttribute('controls')
expect(video).toHaveAttribute('controlslist', 'nodownload')
})
it('handles images loaded later inside expand-read content', async () => {
vi.mocked(expandReadInfo).mockResolvedValue({
content: '<img src="/expand.png" alt="扩展图">',
})
const editor = {
getHtml: () =>
'<div class="chapter-expand" data-name="扩展阅读" data-random="position-1">查看扩展阅读</div>',
}
render(<PreviewDialog editor={editor} bookId={86} chapterId={10} nowTitle='当前章节' />)
fireEvent.click(await screen.findByText('查看扩展阅读'))
fireEvent.click(await screen.findByAltText('扩展图'))
expect(screen.getByText('扩展图片')).toBeInTheDocument()
expect(screen.getByAltText('预览图片')).toHaveAttribute('src', '/expand.png')
fireEvent.click(screen.getByRole('img', { name: 'left' }))
expect(screen.getByText('扩展阅读')).toBeInTheDocument()
expect(screen.getByAltText('扩展图')).toBeInTheDocument()
})
})
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
import TooltipFeatureDialog from './FeatureDialog'
export default defineEditorFeature({
......@@ -13,11 +11,4 @@ export default defineEditorFeature({
{ id: 'tooltip.insert', component: TooltipFeatureDialog },
{ id: 'tooltip.edit', component: TooltipFeatureDialog },
],
events: [
{
name: 'TooltipMenuClick',
handler: ({ editor }) =>
openEditorDialog(editor, 'tooltip.insert', { selectionSize: getSelectionFontSize(editor) }),
},
],
})
// Extend menu
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
import { getSelectionFontSize } from '../../core/selection'
class TooltipAuto {
constructor() {
......@@ -58,7 +60,9 @@ class TooltipAuto {
if (this.isDisabled(editor)) {
return
}
editor.emit('TooltipMenuClick')
openEditorDialog(editor, 'tooltip.insert', {
selectionSize: getSelectionFontSize(editor),
})
}
}
......
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import icon_tooltip_editor from '@/assets/images/editor/icon_tooltip_editor.png'
import { h } from 'snabbdom'
import { escapeHtml } from '../../shared/html'
import { openEditorDialog } from '../../core/editor-dialog'
import { normalizeInlineVoidNode } from '../../shared/normalize-inline-void'
const withChapterTooltipNode = (editor) => {
const { isInline, isVoid, normalizeNode } = editor
......@@ -23,30 +23,14 @@ const withChapterTooltipNode = (editor) => {
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterTooltip') {
// 未命中 chapterHeader ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// SlateTransforms.insertNodes(newEditor, {type: 'paragraph', children: [{ text: ' ' }]}, {
// at: path, // 在 link-card 后面插入
// });
const topLevelNodes = newEditor.children || []
const nextNode = topLevelNodes[path[0]] || {}
if (nextNode.type === 'paragraph') {
const lastChild = nextNode.children[nextNode.children.length - 1]
if (lastChild.type === 'chapterExpandReadSimple') {
SlateTransforms.insertNodes(
newEditor,
{ type: 'span', children: [{ text: '' }] },
{
at: path, // 在 link-card 后面插入
},
)
}
}
return normalizeInlineVoidNode({
editor: newEditor,
node,
path,
type: 'chapterTooltip',
normalizeNode,
transforms: SlateTransforms,
})
}
return newEditor // 返回 newEditor ,重要!!!
......@@ -61,7 +45,6 @@ const renderChapterTooltip = (elem, children, editor) => {
tooltipType = 1,
link = '',
content = '',
icon = '',
random = '',
theme = '#ab1941',
fontsize = 18,
......@@ -150,7 +133,6 @@ const chapterTooltipToHtml = (elem, childrenHtml) => {
tooltipType = 1,
link = '',
content = '',
icon = '',
random = '',
theme = '#ab1941',
fontsize = 18,
......
import { defineEditorFeature } from '../../core/define-editor-feature'
import menu from './menu'
import nodeModule from './node'
import { openEditorDialog } from '../../core/editor-dialog'
import VideoDialog from './VideoDialog'
export default defineEditorFeature({
......@@ -12,11 +11,4 @@ export default defineEditorFeature({
{ id: 'video.insert', component: VideoDialog },
{ id: 'video.edit', component: VideoDialog },
],
events: [
{
name: 'VideoMenuClick',
handler: ({ editor, payload }) =>
openEditorDialog(editor, payload?.path ? 'video.edit' : 'video.insert', payload),
},
],
})
import { DomEditor, SlateTransforms, SlateRange } from '@wangeditor/editor'
import { DomEditor, SlateRange } from '@wangeditor/editor'
import { openEditorDialog } from '../../core/editor-dialog'
class VideoAuto {
constructor() {
......@@ -61,14 +62,14 @@ class VideoAuto {
const chapterVideo = DomEditor.getSelectedNodeByType(editor, 'chapterVideo')
if (chapterVideo) {
const path = DomEditor.findPath(editor, chapterVideo)
editor.emit('VideoMenuClick', {
openEditorDialog(editor, 'video.edit', {
path,
videoUrl: chapterVideo.videoUrl || '',
title: chapterVideo.title || '',
})
return
}
editor.emit('VideoMenuClick')
openEditorDialog(editor, 'video.insert')
}
}
......
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { DomEditor } from '@wangeditor/editor'
import { h } from 'snabbdom'
import { openEditorDialog } from '../../core/editor-dialog'
import { withTrailingParagraph } from '../../shared/with-trailing-paragraph'
const withVideoNode = (editor) => {
const { isVoid, normalizeNode } = editor
const { isVoid } = editor
const newEditor = editor
newEditor.isVoid = (elem) => {
......@@ -12,41 +13,7 @@ const withVideoNode = (editor) => {
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'chapterVideo') {
// 未命中 chapterVideo ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// editor 顶级 node
const topLevelNodes = newEditor.children || []
// 后面必须跟一个 p header blockquote(否则后面无法继续输入文字)
const nextNode = topLevelNodes[path[0] + 1] || {}
const nextNodeType = DomEditor.getNodeType(nextNode)
if (
nextNodeType !== 'paragraph' &&
nextNodeType !== 'blockquote' &&
nextNodeType !== 'header' &&
nextNodeType !== 'chapterPractice' &&
nextNodeType !== 'chapterExpandRead' &&
nextNodeType !== 'chapterSection' &&
nextNodeType !== 'chapterHeader' &&
nextNodeType !== 'chapterGallery' &&
!nextNodeType.startsWith('header')
) {
// link-card node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
const insertPath = [path[0] + 1]
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath, // 在 link-card 后面插入
})
}
}
return newEditor // 返回 newEditor ,重要!!!
return withTrailingParagraph(newEditor, 'chapterVideo')
}
// 在编辑器中渲染新元素
......
......@@ -265,19 +265,6 @@
line-height: 14px;
}
}
.view {
&:disabled {
color: #666666 !important;
}
}
.history {
color: #666666;
&:hover {
.ant-btn-icon {
color: #ab1941;
}
}
}
}
}
}
......@@ -341,9 +328,8 @@
height: 50px;
display: flex;
width: 100%;
justify-content: center;
.tabs-item {
flex: 1;
flex: 1 1 auto;
text-align: center;
border-bottom: 1px solid #e5e5e5;
color: #333;
......
export const normalizeInlineVoidNode = ({
editor,
node,
path,
type,
normalizeNode,
transforms,
}) => {
if (node.type !== type) return normalizeNode([node, path])
const [parentIndex, childIndex] = path
const parent = editor.children?.[parentIndex]
if (path.length !== 2 || parent?.type !== 'paragraph') return
if (childIndex !== parent.children.length - 1) return
transforms.insertNodes(editor, { text: '' }, { at: [parentIndex, childIndex + 1] })
}
import { DomEditor, SlateTransforms } from '@wangeditor/editor'
const DEFAULT_ALLOWED_NEXT_TYPES = new Set([
'paragraph',
'blockquote',
'chapterPractice',
'chapterExpandRead',
'chapterSection',
'chapterHeader',
'chapterGallery',
])
export const withTrailingParagraph = (
editor,
nodeType,
allowedNextTypes = DEFAULT_ALLOWED_NEXT_TYPES,
) => {
const normalizeNode = editor.normalizeNode
const allowedTypes =
allowedNextTypes instanceof Set ? allowedNextTypes : new Set(allowedNextTypes)
editor.normalizeNode = ([node, path]) => {
if (DomEditor.getNodeType(node) !== nodeType) return normalizeNode([node, path])
const nextType = DomEditor.getNodeType(editor.children?.[path[0] + 1] || {})
if (allowedTypes.has(nextType) || nextType.startsWith('header')) return
SlateTransforms.insertNodes(
editor,
{ type: 'paragraph', children: [{ text: '' }] },
{ at: [path[0] + 1] },
)
}
return editor
}
......@@ -4,61 +4,33 @@ import dayjs from 'dayjs'
import timesave from '@/assets/images/timesave.png'
export const EditorHeader = ({
title,
autosaveTime,
isView,
disabled,
contentId,
onSave,
onPreview,
onHistory,
}) => (
<div className='title-head'>
<div className='left'>
export const EditorHeader = ({ title, autosaveTime, isView, disabled, contentId, onSave, onPreview, onHistory }) => (
<div className="title-head">
<div className="left">
<h4>{title}</h4>
</div>
<div className='right'>
<div className="right">
<Space>
{!isView && (
<>
<div className='save-time'>
<div className='img'>
<div className="save-time">
<div className="img">
<img src={timesave} /> <span>自动保存</span>
</div>
<span className='time'>
{autosaveTime > 0
? `上次保存发生在 ${dayjs(autosaveTime).format('YYYY-MM-DD HH:mm:ss')}`
: ''}
<span className="time">
{autosaveTime > 0 ? `上次保存发生在 ${dayjs(autosaveTime).format('YYYY-MM-DD HH:mm:ss')}` : ''}
</span>
</div>
<Button
type='primary'
icon={<SaveOutlined />}
className='view'
onClick={onSave}
style={{ color: '#fff' }}
disabled={disabled || !contentId}
>
<Button type="primary" icon={<SaveOutlined />} onClick={onSave} disabled={disabled || !contentId}>
保存
</Button>
</>
)}
<Button
icon={<EyeOutlined />}
className='history'
onClick={onPreview}
disabled={!contentId}
>
<Button icon={<EyeOutlined />} onClick={onPreview} disabled={!contentId}>
预览
</Button>
{!isView && (
<Button
icon={<HistoryOutlined />}
className='history'
onClick={onHistory}
disabled={disabled || !contentId}
>
<Button icon={<HistoryOutlined />} onClick={onHistory} disabled={disabled || !contentId}>
历史
</Button>
)}
......
import { cloneElement, isValidElement, useState } from 'react'
import { Button } from 'antd'
import { Toolbar } from '@wangeditor/editor-for-react'
......@@ -10,7 +11,17 @@ export const EditorSidebar = ({
colorList,
onApplyColor,
reviewCommentPanel,
}) => (
}) => {
const [reviewCommentMounted, setReviewCommentMounted] = useState(tabKey === 'comment')
const handleTabChange = (key) => {
if (key === 'comment') setReviewCommentMounted(true)
onTabChange(key)
}
const commentPanel = isValidElement(reviewCommentPanel)
? cloneElement(reviewCommentPanel, { active: tabKey === 'comment' })
: reviewCommentPanel
return (
<div className='menu-tabs-key'>
<div className='tabs'>
{[
......@@ -21,7 +32,7 @@ export const EditorSidebar = ({
<div
className={`tabs-item ${item.key === tabKey ? 'active' : ''}`}
key={item.key}
onClick={() => onTabChange(item.key)}
onClick={() => handleTabChange(item.key)}
>
{item.title}
<span />
......@@ -57,11 +68,10 @@ export const EditorSidebar = ({
</ul>
</div>
)}
{reviewCommentPanel && (
<div className='editor-review-comment-tab' hidden={tabKey !== 'comment'}>
{reviewCommentPanel}
</div>
{reviewCommentMounted && reviewCommentPanel && (
<div hidden={tabKey !== 'comment'}>{commentPanel}</div>
)}
</div>
</div>
)
)
}
import { fireEvent, render, screen } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { EditorSidebar } from './EditorSidebar'
vi.mock('@wangeditor/editor-for-react', () => ({
Toolbar: () => <div>编辑工具栏</div>,
}))
describe('EditorSidebar', () => {
it('mounts the comment panel on first open and keeps it mounted when hidden', () => {
const mounted = vi.fn()
const CommentPanel = ({ active }) => {
useEffect(() => mounted(), [])
return <div>评论面板:{active ? '显示' : '隐藏'}</div>
}
const Harness = () => {
const [tabKey, setTabKey] = useState('text')
return (
<EditorSidebar
editor={null}
toolbarConfig={{}}
toolbarRef={{ current: null }}
tabKey={tabKey}
onTabChange={setTabKey}
colorList={[]}
onApplyColor={() => {}}
reviewCommentPanel={<CommentPanel />}
/>
)
}
render(<Harness />)
expect(screen.queryByText(/评论面板/)).not.toBeInTheDocument()
fireEvent.click(screen.getByText('评论'))
expect(screen.getByText('评论面板:显示')).toBeInTheDocument()
fireEvent.click(screen.getByText('文本设置'))
expect(screen.getByText('评论面板:隐藏')).not.toBeVisible()
fireEvent.click(screen.getByText('评论'))
expect(screen.getByText('评论面板:显示')).toBeInTheDocument()
expect(mounted).toHaveBeenCalledOnce()
})
})
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
import { Button, Divider, Space, Modal } from 'antd'
import { EyeOutlined, SaveOutlined, HistoryOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { Divider } from 'antd'
import { useEditorStore } from '@/stores/editorStore'
// import formulaEditor from 'easy-formula-editor'
import '../shared/jax.less'
import { Boot } from '@wangeditor/editor'
import { registerEditorFeatures } from '../core/register-editor-features'
......@@ -15,16 +12,12 @@ import { editorFeatureRegistry } from '../features'
import { DomEditor, SlateEditor, SlateTransforms } from '@wangeditor/editor'
import { Editor } from '@wangeditor/editor-for-react'
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import timesave from '@/assets/images/timesave.png'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
import { EditorHeader } from './EditorHeader'
import { EditorSidebar } from './EditorSidebar'
import { useToolbarPresentation } from './useToolbarPresentation'
import { useEditorLifecycle } from './useEditorLifecycle'
import { createEditorConfig, defaultEditorContent, toolbarConfig } from './editor-config'
import { useEditorFeatureEvents } from '../core/useEditorFeatureEvents'
import { createAutosaveScheduler } from './autosave'
import { getEmptyParagraphPath, normalizeEmptyParagraph } from './paragraph-behavior'
......@@ -55,25 +48,28 @@ const WangEditorCustomer = (props, ref) => {
const [tabKey, setTabKey] = useState('text')
// editor 实例
const [editor, setEditor] = useState(null)
const [content, setContent] = useState(html)
const saveContentRef = useRef(saveContent)
const autosaveRef = useRef()
if (!autosaveRef.current) {
autosaveRef.current = createAutosaveScheduler(() => saveContentRef.current?.(), 1500)
}
const latestHtmlRef = useRef(html)
const [autosave] = useState(() => createAutosaveScheduler(() => saveContentRef.current?.(), 1500))
const [htmlSync] = useState(() =>
createAutosaveScheduler(() => setHtml(latestHtmlRef.current), 500),
)
useEffect(() => {
saveContentRef.current = saveContent
}, [saveContent])
useEffect(() => {
return () => autosaveRef.current?.cancel()
}, [])
return () => {
autosave.cancel()
htmlSync.cancel()
}
}, [autosave, htmlSync])
useEffect(() => {
autosaveRef.current?.cancel()
}, [chapterId, contentId, disabled, isView])
autosave.cancel()
htmlSync.cancel()
}, [autosave, chapterId, contentId, disabled, htmlSync, isView])
useImperativeHandle(ref, () => {
return {
......@@ -82,6 +78,12 @@ const WangEditorCustomer = (props, ref) => {
})
const updateContent = (editor) => {
const shouldSyncVideoAlignment = (editor.operations || []).some(
(operation) =>
(operation.type === 'insert_node' && operation.node?.type === 'video') ||
(operation.type === 'set_node' && 'align' in (operation.newProperties || {})),
)
if (shouldSyncVideoAlignment) {
for (const [node] of SlateEditor.nodes(editor, {
match: (node) => node.type === 'video',
universal: true,
......@@ -89,8 +91,10 @@ const WangEditorCustomer = (props, ref) => {
const dom = DomEditor.toDOMNode(editor, node)
Object.assign(dom.style, { textAlign: node.align || 'center' })
}
setHtml(editor.getHtml())
setContent(editor.getHtml())
}
latestHtmlRef.current = editor.getHtml()
htmlSync.schedule()
}
const editorConfig = createEditorConfig({
......@@ -100,10 +104,10 @@ const WangEditorCustomer = (props, ref) => {
editorConfig.onChange = (currentEditor) => {
updateContent(currentEditor)
if (!disabled && !isView && contentId) autosaveRef.current.schedule()
if (!disabled && !isView && contentId) autosave.schedule()
}
useEditorFeatureEvents({ editor, registry: editorFeatureRegistry, runtime: {} })
useEditorLifecycle({ editor, gData, disabled: disabled || isView })
useEffect(() => {
if (!editor) return
......@@ -149,7 +153,6 @@ const WangEditorCustomer = (props, ref) => {
editor.addMark('fontSize', '18px')
editor.addMark('fontFamily', '黑体')
editor.addMark('lineHeight', 1.5)
setContent(html)
return () => {
editor.destroy()
setEditor(null)
......@@ -166,7 +169,7 @@ const WangEditorCustomer = (props, ref) => {
// 预览
const previewIt = async () => {
if (!isView) {
autosaveRef.current.cancel()
autosave.cancel()
await saveContent()
}
openEditorDialog(editor, 'preview.open')
......@@ -178,10 +181,20 @@ const WangEditorCustomer = (props, ref) => {
}
const saveNow = () => {
autosaveRef.current.cancel()
autosave.cancel()
return saveContent()
}
const restoreContent = async (contentHtml) => {
if (!editor || !saveContent) return
editor.setHtml(contentHtml)
autosave.cancel()
htmlSync.cancel()
setHtml(contentHtml)
await saveContent(contentHtml)
}
const colorList = [
{ color: '#ab1941', name: '默认' },
{ color: '#2970f6', name: '蓝色' },
......@@ -229,8 +242,9 @@ const WangEditorCustomer = (props, ref) => {
<Divider style={{ margin: 0 }} />
<Editor
defaultConfig={editorConfig}
defaultContent={defaultEditorContent}
value={content}
defaultContent={html ? [] : defaultEditorContent}
defaultHtml={html}
value={html}
onCreated={setEditor}
mode='default'
style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}
......@@ -251,7 +265,7 @@ const WangEditorCustomer = (props, ref) => {
<EditorDialogHost
editor={editor}
registry={editorFeatureRegistry}
runtime={{ editor, chapterId, bookId, gData, nowTitle, saveContent, setContent }}
runtime={{ editor, chapterId, bookId, gData, nowTitle, saveContent, restoreContent }}
/>
</div>
)
......
......@@ -108,7 +108,7 @@ function MessageListItem({ item, onRead }) {
<BellOutlined />
</Badge>
</Col>
<Col span={22} className='process'>
<Col span={22}>
<div
className='head'
style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}
......
......@@ -177,8 +177,6 @@ export default function AppLayout() {
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem(SIDER_COLLAPSED_STORAGE_KEY) !== 'false',
)
const [siderHovered, setSiderHovered] = useState(false)
const siderCollapsed = collapsed && !siderHovered
const headerOverride = pageHeaderOverrides[pathname]
const pageTitle = headerOverride?.title || getCurrentMenuTitle(menuList, pathname)
......@@ -232,11 +230,9 @@ export default function AppLayout() {
className='layout-sider'
width={240}
collapsedWidth={64}
collapsed={siderCollapsed}
collapsed={collapsed}
theme='light'
trigger={null}
onMouseEnter={() => setSiderHovered(true)}
onMouseLeave={() => setSiderHovered(false)}
>
<div className='layout-brand'>
<Link className='layout-brand__logo' to='/' aria-label='返回工作台首页'>
......@@ -244,7 +240,7 @@ export default function AppLayout() {
</Link>
</div>
<div className='layout-sider__body'>
<Menu menuList={menuList} collapsed={siderCollapsed}></Menu>
<Menu menuList={menuList} collapsed={collapsed}></Menu>
</div>
<Tooltip title={collapsed ? '固定展开侧边菜单' : '收起侧边菜单'} placement='right'>
<Button
......
......@@ -14,10 +14,6 @@
.layout-sider-slot--collapsed {
flex-basis: 64px;
width: 64px;
.layout-sider:not(.ant-layout-sider-collapsed) {
z-index: 102;
}
}
.layout-sider {
......
......@@ -15,7 +15,7 @@ export default function DeleteAdminModal({ open, onCancel, onConfirm }: DeleteAd
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify='center' className='delModal' style={{ marginBottom: '30px' }}>
<Row justify='center' style={{ marginBottom: '30px' }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
......
......@@ -15,7 +15,7 @@ export default function DeleteRoleModal({ open, onCancel, onConfirm }: DeleteRol
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify='center' className='delModal' style={{ marginBottom: '30px' }}>
<Row justify='center' style={{ marginBottom: '30px' }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论