This is an automated email from the ASF dual-hosted git repository. robin0716 pushed a commit to branch feat/1.7.2/ui in repository https://gitbox.apache.org/repos/asf/answer.git
commit d87726bd6e54e4fc88f8338f6f6499f878b00c4f Author: robin <[email protected]> AuthorDate: Wed Dec 17 10:38:31 2025 +0800 refactor(editor): enhance editor components with base props and initialization logic - Introduced BaseEditorProps interface to standardize props across MarkdownEditor and RichEditor components. - Improved initialization logic in RichEditor and MarkdownEditor to handle editor state more effectively. - Updated useEditor hook to support initial values and prevent unnecessary updates during prop changes. - Refactored command methods to utilize dispatch for state changes in CodeMirror editor. --- ui/src/components/Editor/MarkdownEditor.tsx | 33 ++-- ui/src/components/Editor/RichEditor.tsx | 137 +++++++++------- ui/src/components/Editor/ToolBars/chart.tsx | 181 --------------------- ui/src/components/Editor/ToolBars/index.ts | 2 - ui/src/components/Editor/index.tsx | 5 +- ui/src/components/Editor/types.ts | 15 +- .../components/Editor/utils/codemirror/commands.ts | 16 +- ui/src/components/Editor/utils/index.ts | 26 ++- ui/src/components/Editor/utils/tiptap/events.ts | 8 +- 9 files changed, 145 insertions(+), 278 deletions(-) diff --git a/ui/src/components/Editor/MarkdownEditor.tsx b/ui/src/components/Editor/MarkdownEditor.tsx index fe5d8bb4..068a408f 100644 --- a/ui/src/components/Editor/MarkdownEditor.tsx +++ b/ui/src/components/Editor/MarkdownEditor.tsx @@ -21,18 +21,10 @@ import { useEffect, useRef } from 'react'; import { EditorView } from '@codemirror/view'; -import { Editor } from './types'; +import { BaseEditorProps } from './types'; import { useEditor } from './utils'; -interface MarkdownEditorProps { - value: string; - onChange?: (value: string) => void; - onFocus?: () => void; - onBlur?: () => void; - placeholder?: string; - autoFocus?: boolean; - onEditorReady?: (editor: Editor) => void; -} +interface MarkdownEditorProps extends BaseEditorProps {} const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ value, @@ -45,6 +37,7 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ }) => { const editorRef = useRef<HTMLDivElement>(null); const lastSyncedValueRef = useRef<string>(value); + const isInitializedRef = useRef<boolean>(false); const editor = useEditor({ editorRef, @@ -53,24 +46,20 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ onBlur, placeholder, autoFocus, + initialValue: value, }); useEffect(() => { - if (!editor) { + if (!editor || isInitializedRef.current) { return; } - editor.setValue(value || ''); - lastSyncedValueRef.current = value || ''; + isInitializedRef.current = true; onEditorReady?.(editor); - }, [editor]); + }, [editor, onEditorReady]); useEffect(() => { - if (!editor) { - return; - } - - if (value === lastSyncedValueRef.current) { + if (!editor || value === lastSyncedValueRef.current) { return; } @@ -82,6 +71,9 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ }, [editor, value]); useEffect(() => { + lastSyncedValueRef.current = value; + isInitializedRef.current = false; + return () => { if (editor) { const view = editor as unknown as EditorView; @@ -89,8 +81,9 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ view.destroy(); } } + isInitializedRef.current = false; }; - }, [editor]); + }, []); return ( <div className="content-wrap"> diff --git a/ui/src/components/Editor/RichEditor.tsx b/ui/src/components/Editor/RichEditor.tsx index c574551c..916c8249 100644 --- a/ui/src/components/Editor/RichEditor.tsx +++ b/ui/src/components/Editor/RichEditor.tsx @@ -30,18 +30,10 @@ import { Markdown } from '@tiptap/markdown'; import Image from '@tiptap/extension-image'; import { TableKit } from '@tiptap/extension-table'; -import { Editor } from './types'; +import { Editor, BaseEditorProps } from './types'; import { createTipTapAdapter } from './utils/tiptap/adapter'; -interface RichEditorProps { - value: string; - onChange?: (value: string) => void; - onFocus?: () => void; - onBlur?: () => void; - placeholder?: string; - autoFocus?: boolean; - onEditorReady?: (editor: Editor) => void; -} +interface RichEditorProps extends BaseEditorProps {} const RichEditor: React.FC<RichEditorProps> = ({ value, @@ -53,14 +45,60 @@ const RichEditor: React.FC<RichEditorProps> = ({ onEditorReady, }) => { const lastSyncedValueRef = useRef<string>(value); - const adaptedEditorRef = useRef<Editor | null>(null); + const isInitializedRef = useRef<boolean>(false); + const isUpdatingFromPropsRef = useRef<boolean>(false); + const onEditorReadyRef = useRef(onEditorReady); + const autoFocusRef = useRef(autoFocus); + const initialValueRef = useRef<string>(value); + + useEffect(() => { + onEditorReadyRef.current = onEditorReady; + autoFocusRef.current = autoFocus; + }, [onEditorReady, autoFocus]); + + const isViewAvailable = (editorInstance: TipTapEditor | null): boolean => { + if (!editorInstance) { + return false; + } + if (editorInstance.isDestroyed) { + return false; + } + return !!(editorInstance.view && editorInstance.state); + }; + + const handleCreate = useCallback( + ({ editor: editorInstance }: { editor: TipTapEditor }) => { + if (isInitializedRef.current || !isViewAvailable(editorInstance)) { + return; + } + + isInitializedRef.current = true; + + const initialValue = initialValueRef.current; + if (initialValue && initialValue.trim() !== '') { + editorInstance.commands.setContent(initialValue, { + contentType: 'markdown', + }); + lastSyncedValueRef.current = initialValue; + } + + adaptedEditorRef.current = createTipTapAdapter(editorInstance); + onEditorReadyRef.current?.(adaptedEditorRef.current); + + if (autoFocusRef.current) { + editorInstance.commands.focus(); + } + }, + [], + ); const handleUpdate = useCallback( ({ editor: editorInstance }: { editor: TipTapEditor }) => { - if (onChange) { + if (onChange && !isUpdatingFromPropsRef.current) { const markdown = editorInstance.getMarkdown(); onChange(markdown); + lastSyncedValueRef.current = markdown; } }, [onChange], @@ -84,7 +122,7 @@ const RichEditor: React.FC<RichEditorProps> = ({ placeholder, }), ], - content: value || '', + onCreate: handleCreate, onUpdate: handleUpdate, onFocus: handleFocus, onBlur: handleBlur, @@ -96,67 +134,56 @@ const RichEditor: React.FC<RichEditorProps> = ({ }); useEffect(() => { - if (!editor) { + if ( + !editor || + !isInitializedRef.current || + !isViewAvailable(editor) || + value === lastSyncedValueRef.current + ) { return; } - const checkEditorReady = () => { - if (editor.view && editor.view.dom) { + try { + const currentMarkdown = editor.getMarkdown(); + if (currentMarkdown !== value) { + isUpdatingFromPropsRef.current = true; if (value && value.trim() !== '') { editor.commands.setContent(value, { contentType: 'markdown' }); } else { editor.commands.clearContent(); } lastSyncedValueRef.current = value || ''; - if (!adaptedEditorRef.current) { - adaptedEditorRef.current = createTipTapAdapter(editor); - } - onEditorReady?.(adaptedEditorRef.current); - } else { - setTimeout(checkEditorReady, 10); + setTimeout(() => { + isUpdatingFromPropsRef.current = false; + }, 0); } - }; - - checkEditorReady(); - }, [editor]); - - useEffect(() => { - if (!editor) { - return; - } - - if (value === lastSyncedValueRef.current) { - return; - } - - const currentMarkdown = editor.getMarkdown(); - if (currentMarkdown !== value) { - if (value && value.trim() !== '') { - editor.commands.setContent(value, { contentType: 'markdown' }); - } else { - editor.commands.clearContent(); - } - lastSyncedValueRef.current = value || ''; + } catch (error) { + console.warn('Editor view not available when syncing value:', error); } }, [editor, value]); useEffect(() => { - if (editor && autoFocus) { - setTimeout(() => { - editor.commands.focus(); - }, 100); - } - }, [editor, autoFocus]); + initialValueRef.current = value; + lastSyncedValueRef.current = value; + isInitializedRef.current = false; + adaptedEditorRef.current = null; + isUpdatingFromPropsRef.current = false; + + return () => { + if (editor) { + editor.destroy(); + } + isInitializedRef.current = false; + adaptedEditorRef.current = null; + isUpdatingFromPropsRef.current = false; + }; + }, [editor]); if (!editor) { return <div className="editor-loading">Loading editor...</div>; } - return ( - <div className="rich-editor-wrap"> - <EditorContent editor={editor} /> - </div> - ); + return <EditorContent className="rich-editor-wrap" editor={editor} />; }; export default RichEditor; diff --git a/ui/src/components/Editor/ToolBars/chart.tsx b/ui/src/components/Editor/ToolBars/chart.tsx deleted file mode 100644 index a26a5638..00000000 --- a/ui/src/components/Editor/ToolBars/chart.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FC, useEffect, useState, memo } from 'react'; -import { Dropdown } from 'react-bootstrap'; -import { useTranslation } from 'react-i18next'; - -import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; - -const Chart: FC<IEditorContext> = ({ editor }) => { - const { t } = useTranslation('translation', { keyPrefix: 'editor' }); - - const headerList = [ - { - label: t('chart.flow_chart'), - tpl: `graph TD - A[Christmas] -->|Get money| B(Go shopping) - B --> C{Let me think} - C -->|One| D[Laptop] - C -->|Two| E[iPhone] - C -->|Three| F[fa:fa-car Car]`, - }, - { - label: t('chart.sequence_diagram'), - tpl: `sequenceDiagram - Alice->>+John: Hello John, how are you? - Alice->>+John: John, can you hear me? - John-->>-Alice: Hi Alice, I can hear you! - John-->>-Alice: I feel great! - `, - }, - { - label: t('chart.state_diagram'), - tpl: `stateDiagram-v2 - [*] --> Still - Still --> [*] - Still --> Moving - Moving --> Still - Moving --> Crash - Crash --> [*] - `, - }, - { - label: t('chart.class_diagram'), - tpl: `classDiagram - Animal <|-- Duck - Animal <|-- Fish - Animal <|-- Zebra - Animal : +int age - Animal : +String gender - Animal: +isMammal() - Animal: +mate() - class Duck{ - +String beakColor - +swim() - +quack() - } - class Fish{ - -int sizeInFeet - -canEat() - } - class Zebra{ - +bool is_wild - +run() - } - `, - }, - { - label: t('chart.pie_chart'), - tpl: `pie title Pets adopted by volunteers - "Dogs" : 386 - "Cats" : 85 - "Rats" : 15 - `, - }, - { - label: t('chart.gantt_chart'), - tpl: `gantt - title A Gantt Diagram - dateFormat YYYY-MM-DD - section Section - A task :a1, 2014-01-01, 30d - Another task :after a1 , 20d - section Another - Task in sec :2014-01-12 , 12d - another task : 24d - `, - }, - { - label: t('chart.entity_relationship_diagram'), - tpl: `erDiagram - CUSTOMER }|..|{ DELIVERY-ADDRESS : has - CUSTOMER ||--o{ ORDER : places - CUSTOMER ||--o{ INVOICE : "liable for" - DELIVERY-ADDRESS ||--o{ ORDER : receives - INVOICE ||--|{ ORDER : covers - ORDER ||--|{ ORDER-ITEM : includes - PRODUCT-CATEGORY ||--|{ PRODUCT : contains - PRODUCT ||--o{ ORDER-ITEM : "ordered in" - `, - }, - ]; - const item = { - label: 'chart', - tip: `${t('chart.text')}`, - }; - const [isShow, setShowState] = useState(false); - const [isLocked, setLockState] = useState(false); - - useEffect(() => { - if (!editor) { - return; - } - editor.on('focus', () => { - setShowState(false); - }); - }, []); - - const click = (tpl) => { - const { ch } = editor.getCursor(); - - editor.replaceSelection(`${ch ? '\n' : ''}\`\`\`mermaid\n${tpl}\n\`\`\`\n`); - }; - - const onAddHeader = () => { - setShowState(!isShow); - }; - const handleMouseEnter = () => { - if (isLocked) { - return; - } - setLockState(true); - }; - - const handleMouseLeave = () => { - setLockState(false); - }; - return ( - <ToolItem - as="dropdown" - {...item} - onClick={onAddHeader} - onBlur={onAddHeader}> - <Dropdown.Menu - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave}> - {headerList.map((header) => { - return ( - <Dropdown.Item - key={header.label} - onClick={(e) => { - e.preventDefault(); - click(header.tpl); - }}> - {header.label} - </Dropdown.Item> - ); - })} - </Dropdown.Menu> - </ToolItem> - ); -}; - -export default memo(Chart); diff --git a/ui/src/components/Editor/ToolBars/index.ts b/ui/src/components/Editor/ToolBars/index.ts index 05912bc6..c3fa24be 100644 --- a/ui/src/components/Editor/ToolBars/index.ts +++ b/ui/src/components/Editor/ToolBars/index.ts @@ -31,7 +31,6 @@ import Link from './link'; import BlockQuote from './blockquote'; import Image from './image'; import Help from './help'; -import Chart from './chart'; import File from './file'; export { @@ -49,6 +48,5 @@ export { BlockQuote, Image, Help, - Chart, File, }; diff --git a/ui/src/components/Editor/index.tsx b/ui/src/components/Editor/index.tsx index c293fa65..eb798c9d 100644 --- a/ui/src/components/Editor/index.tsx +++ b/ui/src/components/Editor/index.tsx @@ -98,10 +98,10 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = ( return; } - setMode(newMode); setCurrentEditor(null); + setMode(newMode); }, - [mode, currentEditor], + [mode], ); const getHtml = useCallback(() => { @@ -172,6 +172,7 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = ( </div> <EditorComponent + key={mode} value={value} onChange={(markdown) => { onChange?.(markdown); diff --git a/ui/src/components/Editor/types.ts b/ui/src/components/Editor/types.ts index 926d3c20..e7d23081 100644 --- a/ui/src/components/Editor/types.ts +++ b/ui/src/components/Editor/types.ts @@ -116,11 +116,12 @@ export interface CodeMirrorEditor extends Editor { moduleType; } -// @deprecated 已废弃,请直接使用 Editor 接口 -// 保留此接口仅用于向后兼容,新代码不应使用 -export interface IEditorContext { - editor: Editor; - wrapText?; - replaceLines?; - appendBlock?; +export interface BaseEditorProps { + value: string; + onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + autoFocus?: boolean; + onEditorReady?: (editor: Editor) => void; } diff --git a/ui/src/components/Editor/utils/codemirror/commands.ts b/ui/src/components/Editor/utils/codemirror/commands.ts index 152bc632..546382d4 100644 --- a/ui/src/components/Editor/utils/codemirror/commands.ts +++ b/ui/src/components/Editor/utils/codemirror/commands.ts @@ -69,14 +69,26 @@ export function createCommandMethods(editor: Editor) { const newLines = lines.map(replace) as string[]; const newText = newLines.join('\n'); - editor.setValue(newText); + editor.dispatch({ + changes: { + from: 0, + to: editor.state.doc.length, + insert: newText, + }, + }); }, appendBlock: (content: string) => { const { doc } = editor.state; const currentText = doc.toString(); const newText = currentText ? `${currentText}\n\n${content}` : content; - editor.setValue(newText); + editor.dispatch({ + changes: { + from: 0, + to: editor.state.doc.length, + insert: newText, + }, + }); }, insertBold: (text?: string) => { diff --git a/ui/src/components/Editor/utils/index.ts b/ui/src/components/Editor/utils/index.ts index 938b3761..5ba7192c 100644 --- a/ui/src/components/Editor/utils/index.ts +++ b/ui/src/components/Editor/utils/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { minimalSetup } from 'codemirror'; import { EditorState, Compartment } from '@codemirror/state'; @@ -129,12 +129,14 @@ export const useEditor = ({ editorRef, placeholder: placeholderText, autoFocus, + initialValue, onChange, onFocus, onBlur, }) => { const [editor, setEditor] = useState<Editor | null>(null); - const [value, setValue] = useState<string>(''); + const isInternalUpdateRef = useRef<boolean>(false); + const init = async () => { const isDark = isDarkTheme(); @@ -162,6 +164,7 @@ export const useEditor = ({ }); const startState = EditorState.create({ + doc: initialValue || '', extensions: [ minimalSetup, markdown({ @@ -206,9 +209,20 @@ export const useEditor = ({ }, 10); } + const originalSetValue = cm.setValue; + cm.setValue = (newValue: string) => { + isInternalUpdateRef.current = true; + originalSetValue.call(cm, newValue); + setTimeout(() => { + isInternalUpdateRef.current = false; + }, 0); + }; + cm.on('change', () => { - const newValue = cm.getValue(); - setValue(newValue); + if (!isInternalUpdateRef.current && onChange) { + const newValue = cm.getValue(); + onChange(newValue); + } }); cm.on('focus', () => { @@ -224,10 +238,6 @@ export const useEditor = ({ return cm; }; - useEffect(() => { - onChange?.(value); - }, [value]); - useEffect(() => { if (!editorRef.current) { return; diff --git a/ui/src/components/Editor/utils/tiptap/events.ts b/ui/src/components/Editor/utils/tiptap/events.ts index 495ee6e5..2a86440d 100644 --- a/ui/src/components/Editor/utils/tiptap/events.ts +++ b/ui/src/components/Editor/utils/tiptap/events.ts @@ -25,7 +25,13 @@ import { logWarning } from './errorHandler'; * Checks if editor view is available */ function isViewAvailable(editor: TipTapEditor): boolean { - return !!(editor.view && editor.view.dom); + if (!editor) { + return false; + } + if (editor.isDestroyed) { + return false; + } + return !!(editor.view && editor.state); } /**
