Skip to content

Commit 9ef55ca

Browse files
author
robin
committed
feat(editor): integrate TipTap WYSIWYG editor with Markdown support and enhance editor functionalities
- Added TipTap extensions for image, placeholder, table, and markdown support. - Implemented WYSIWYG and Markdown editor components. - Refactored editor context and tool items to utilize the new editor interface. - Updated styles for the WYSIWYG editor. - Removed deprecated utility functions and integrated new command methods for better editor interaction.
1 parent 3000e3a commit 9ef55ca

37 files changed

Lines changed: 4819 additions & 762 deletions

ui/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
"@codemirror/language-data": "^6.5.0",
2222
"@codemirror/state": "^6.5.0",
2323
"@codemirror/view": "^6.26.1",
24+
"@tiptap/extension-image": "^3.11.1",
25+
"@tiptap/extension-placeholder": "^3.11.1",
26+
"@tiptap/extension-table": "^3.11.1",
27+
"@tiptap/markdown": "^3.11.1",
28+
"@tiptap/react": "^3.11.1",
29+
"@tiptap/starter-kit": "^3.11.1",
2430
"axios": "^1.7.7",
2531
"bootstrap": "^5.3.2",
2632
"bootstrap-icons": "^1.10.5",
@@ -100,4 +106,4 @@
100106
"pnpm": ">=9"
101107
},
102108
"license": "MIT"
103-
}
109+
}

ui/pnpm-lock.yaml

Lines changed: 2009 additions & 156 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/components/Editor/EditorContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@
1919

2020
import React from 'react';
2121

22-
import { IEditorContext } from './types';
22+
import { Editor } from './types';
2323

24-
export const EditorContext = React.createContext<IEditorContext | any>({});
24+
export const EditorContext = React.createContext<Editor | null>(null);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useEffect, useRef } from 'react';
21+
22+
import { EditorView } from '@codemirror/view';
23+
24+
import { Editor } from './types';
25+
import { useEditor } from './utils';
26+
27+
interface MarkdownEditorProps {
28+
value: string;
29+
onChange?: (value: string) => void;
30+
onFocus?: () => void;
31+
onBlur?: () => void;
32+
placeholder?: string;
33+
autoFocus?: boolean;
34+
onEditorReady?: (editor: Editor) => void;
35+
}
36+
37+
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
38+
value,
39+
onChange,
40+
onFocus,
41+
onBlur,
42+
placeholder,
43+
autoFocus,
44+
onEditorReady,
45+
}) => {
46+
const editorRef = useRef<HTMLDivElement>(null);
47+
const lastSyncedValueRef = useRef<string>(value);
48+
49+
const editor = useEditor({
50+
editorRef,
51+
onChange,
52+
onFocus,
53+
onBlur,
54+
placeholder,
55+
autoFocus,
56+
});
57+
58+
// 初始化内容(只在编辑器创建时执行)
59+
useEffect(() => {
60+
if (!editor) {
61+
return;
62+
}
63+
64+
// 初始化编辑器内容
65+
editor.setValue(value || '');
66+
lastSyncedValueRef.current = value || '';
67+
onEditorReady?.(editor);
68+
}, [editor]); // 只在编辑器创建时执行
69+
70+
// 当外部 value 变化时更新(但不是用户输入导致的)
71+
useEffect(() => {
72+
if (!editor) {
73+
return;
74+
}
75+
76+
// 如果 value 和 lastSyncedValueRef 相同,说明是用户输入导致的更新,跳过
77+
if (value === lastSyncedValueRef.current) {
78+
return;
79+
}
80+
81+
// 外部 value 真正变化,更新编辑器
82+
const currentValue = editor.getValue();
83+
if (currentValue !== value) {
84+
editor.setValue(value || '');
85+
lastSyncedValueRef.current = value || '';
86+
}
87+
}, [editor, value]);
88+
89+
// 清理:组件卸载时销毁编辑器
90+
useEffect(() => {
91+
return () => {
92+
if (editor) {
93+
// CodeMirror EditorView 有 destroy 方法
94+
const view = editor as unknown as EditorView;
95+
if (view.destroy) {
96+
view.destroy();
97+
}
98+
}
99+
};
100+
}, [editor]);
101+
102+
return (
103+
<div className="content-wrap">
104+
<div
105+
className="md-editor position-relative w-100 h-100"
106+
ref={editorRef}
107+
/>
108+
</div>
109+
);
110+
};
111+
112+
export default MarkdownEditor;

ui/src/components/Editor/ToolBars/blockquote.tsx

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ import { memo } from 'react';
2121
import { useTranslation } from 'react-i18next';
2222

2323
import ToolItem from '../toolItem';
24-
import { IEditorContext } from '../types';
24+
import { Editor } from '../types';
2525

26-
let context: IEditorContext;
2726
const BlockQuote = () => {
2827
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
2928

@@ -33,21 +32,9 @@ const BlockQuote = () => {
3332
tip: `${t('blockquote.text')} (Ctrl+Q)`,
3433
};
3534

36-
const handleClick = (ctx) => {
37-
context = ctx;
38-
context.replaceLines((line) => {
39-
const FIND_BLOCKQUOTE_RX = /^>\s+?/g;
40-
41-
if (line === `> ${t('blockquote.text')}`) {
42-
line = '';
43-
} else if (line.match(FIND_BLOCKQUOTE_RX)) {
44-
line = line.replace(FIND_BLOCKQUOTE_RX, '');
45-
} else {
46-
line = `> ${line || t('blockquote.text')}`;
47-
}
48-
return line;
49-
}, 2);
50-
context.editor?.focus();
35+
const handleClick = (editor: Editor) => {
36+
editor.insertBlockquote(t('blockquote.text'));
37+
editor.focus();
5138
};
5239

5340
return <ToolItem {...item} onClick={handleClick} />;

ui/src/components/Editor/ToolBars/bold.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ import { memo } from 'react';
2121
import { useTranslation } from 'react-i18next';
2222

2323
import ToolItem from '../toolItem';
24-
import { IEditorContext } from '../types';
24+
import { Editor } from '../types';
2525

26-
let context: IEditorContext;
2726
const Bold = () => {
2827
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
2928
const item = {
@@ -33,10 +32,9 @@ const Bold = () => {
3332
};
3433
const DEFAULTTEXT = t('bold.text');
3534

36-
const handleClick = (ctx) => {
37-
context = ctx;
38-
context.wrapText('**', '**', DEFAULTTEXT);
39-
context.editor?.focus();
35+
const handleClick = (editor: Editor) => {
36+
editor.insertBold(DEFAULTTEXT);
37+
editor.focus();
4038
};
4139

4240
return <ToolItem {...item} onClick={handleClick} />;

ui/src/components/Editor/ToolBars/code.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next';
2323

2424
import Select from '../Select';
2525
import ToolItem from '../toolItem';
26-
import { IEditorContext } from '../types';
26+
import { Editor } from '../types';
2727

2828
const codeLanguageType = [
2929
'bash',
@@ -150,7 +150,6 @@ const codeLanguageType = [
150150
'yml',
151151
];
152152

153-
let context: IEditorContext;
154153
const Code = () => {
155154
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
156155

@@ -170,22 +169,20 @@ const Code = () => {
170169
const inputRef = useRef<HTMLTextAreaElement>(null);
171170

172171
const SINGLELINEMAXLENGTH = 40;
173-
const addCode = (ctx) => {
174-
context = ctx;
172+
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
175173

176-
const { wrapText, editor } = context;
177-
178-
const text = context.editor.getSelection();
174+
const addCode = (editor: Editor) => {
175+
setCurrentEditor(editor);
176+
const text = editor.getSelection();
179177

180178
if (!text) {
181179
setVisible(true);
182-
183180
return;
184181
}
185182
if (text.length > SINGLELINEMAXLENGTH) {
186-
context.wrapText('```\n', '\n```');
183+
editor.insertCodeBlock('', text);
187184
} else {
188-
wrapText('`', '`');
185+
editor.insertCode(text);
189186
}
190187
editor.focus();
191188
};
@@ -197,6 +194,10 @@ const Code = () => {
197194
}, [visible]);
198195

199196
const handleClick = () => {
197+
if (!currentEditor) {
198+
return;
199+
}
200+
200201
if (!code.value.trim()) {
201202
setCode({
202203
...code,
@@ -206,27 +207,26 @@ const Code = () => {
206207
return;
207208
}
208209

209-
let value;
210-
211210
if (
212211
code.value.split('\n').length > 1 ||
213212
code.value.length >= SINGLELINEMAXLENGTH
214213
) {
215-
value = `\n\`\`\`${lang}\n${code.value}\n\`\`\`\n`;
214+
currentEditor.insertCodeBlock(lang || undefined, code.value);
216215
} else {
217-
value = `\`${code.value}\``;
216+
currentEditor.insertCode(code.value);
218217
}
219-
context.editor.replaceSelection(value);
218+
220219
setCode({
221220
value: '',
222221
isInvalid: false,
223222
errorMsg: '',
224223
});
225224
setLang('');
226225
setVisible(false);
226+
currentEditor.focus();
227227
};
228228
const onHide = () => setVisible(false);
229-
const onExited = () => context.editor?.focus();
229+
const onExited = () => currentEditor?.focus();
230230

231231
return (
232232
<ToolItem {...item} onClick={addCode}>

ui/src/components/Editor/ToolBars/file.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,28 @@
1717
* under the License.
1818
*/
1919

20-
import { useState, memo, useRef } from 'react';
20+
import { memo, useRef, useContext } from 'react';
2121
import { useTranslation } from 'react-i18next';
2222

2323
import { Modal as AnswerModal } from '@/components';
2424
import ToolItem from '../toolItem';
25-
import { IEditorContext, Editor } from '../types';
25+
import { EditorContext } from '../EditorContext';
2626
import { uploadImage } from '@/services';
2727
import { writeSettingStore } from '@/stores';
2828

29-
let context: IEditorContext;
30-
const Image = ({ editorInstance }) => {
29+
const File = () => {
3130
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
3231
const { max_attachment_size = 8, authorized_attachment_extensions = [] } =
3332
writeSettingStore((state) => state.write);
3433
const fileInputRef = useRef<HTMLInputElement>(null);
35-
const [editor, setEditor] = useState<Editor>(editorInstance);
34+
const editor = useContext(EditorContext);
3635

3736
const item = {
3837
label: 'paperclip',
3938
tip: `${t('file.text')}`,
4039
};
4140

42-
const addLink = (ctx) => {
43-
context = ctx;
44-
setEditor(context.editor);
41+
const addLink = () => {
4542
fileInputRef.current?.click?.();
4643
};
4744

@@ -132,4 +129,4 @@ const Image = ({ editorInstance }) => {
132129
);
133130
};
134131

135-
export default memo(Image);
132+
export default memo(File);

ui/src/components/Editor/ToolBars/heading.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ import { Dropdown } from 'react-bootstrap';
2222
import { useTranslation } from 'react-i18next';
2323

2424
import ToolItem from '../toolItem';
25-
import { IEditorContext } from '../types';
25+
import { Editor, Level } from '../types';
2626

27-
let context: IEditorContext;
2827
const Heading = () => {
2928
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
3029
const headerList = [
@@ -61,19 +60,18 @@ const Heading = () => {
6160
};
6261
const [isShow, setShowState] = useState(false);
6362
const [isLocked, setLockState] = useState(false);
63+
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
6464

65-
const handleClick = (level = 2, label = '大标题') => {
66-
const { replaceLines } = context;
67-
68-
replaceLines((line) => {
69-
line = line.trim().replace(/^#*/, '').trim();
70-
line = `${'#'.repeat(level)} ${line || label}`;
71-
return line;
72-
}, level + 1);
65+
const handleClick = (level: Level = 2, label = '大标题') => {
66+
if (!currentEditor) {
67+
return;
68+
}
69+
currentEditor.insertHeading(level, label);
70+
currentEditor.focus();
7371
setShowState(false);
7472
};
75-
const onAddHeader = (ctx) => {
76-
context = ctx;
73+
const onAddHeader = (editor: Editor) => {
74+
setCurrentEditor(editor);
7775
if (isLocked) {
7876
return;
7977
}
@@ -104,7 +102,7 @@ const Heading = () => {
104102
onClick={(e) => {
105103
e.preventDefault();
106104
e.stopPropagation();
107-
handleClick(header.level, header.label);
105+
handleClick(header.level as Level, header.label);
108106
}}
109107
dangerouslySetInnerHTML={{ __html: header.text }}
110108
/>

0 commit comments

Comments
 (0)