Skip to content

Commit 1c2c733

Browse files
author
robin
committed
feat(editor): enhance plugin system and improve command methods
- Introduced PluginSlot component for better plugin insertion in the editor. - Updated MDEditor to default to 'markdown' mode for improved user experience. - Refactored command methods in TipTap to use chaining for better selection handling. - Enhanced PluginRender to load plugins asynchronously and prevent duplicate registrations.
1 parent ffa8dc2 commit 1c2c733

4 files changed

Lines changed: 178 additions & 57 deletions

File tree

ui/src/components/Editor/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
import classNames from 'classnames';
3030

3131
import { PluginType, useRenderPlugin } from '@/utils/pluginKit';
32-
import PluginRender from '../PluginRender';
32+
import PluginRender, { PluginSlot } from '../PluginRender';
3333

3434
import {
3535
BlockQuote,
@@ -86,7 +86,7 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
8686
},
8787
ref,
8888
) => {
89-
const [mode, setMode] = useState<'markdown' | 'rich'>('rich');
89+
const [mode, setMode] = useState<'markdown' | 'rich'>('markdown');
9090
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
9191
const previewRef = useRef<{ getHtml; element } | null>(null);
9292

@@ -145,10 +145,10 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
145145
<Outdent />
146146
<Hr />
147147
<div className="toolbar-divider" />
148+
<PluginSlot />
148149
<Help />
149150
</PluginRender>
150151
</EditorContext.Provider>
151-
152152
<div className="btn-group ms-auto" role="group">
153153
<button
154154
type="button"

ui/src/components/Editor/utils/tiptap/commands.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -633,15 +633,17 @@ export function createCommandMethods(editor: TipTapEditor) {
633633
if (text) {
634634
const { from } = editor.state.selection;
635635
const blockquoteText = `> ${text}`;
636-
editor.commands.insertContent(blockquoteText, {
637-
contentType: 'markdown',
638-
});
639-
// Select the text part (excluding the '> ' marker)
640-
const textStart = from + 2; // 2 for '> '
641-
editor.commands.setTextSelection({
642-
from: textStart,
643-
to: textStart + text.length,
644-
});
636+
637+
// Use chain to ensure selection happens after insertion
638+
editor
639+
.chain()
640+
.focus()
641+
.insertContent(blockquoteText, { contentType: 'markdown' })
642+
.setTextSelection({
643+
from: from + 1,
644+
to: from + 1 + text.length,
645+
})
646+
.run();
645647
} else {
646648
editor.commands.toggleBlockquote();
647649
}

ui/src/components/PluginRender/index.tsx

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

20-
import React, { FC, ReactNode } from 'react';
20+
import React, { FC, ReactNode, useEffect, useState } from 'react';
2121

2222
import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit';
23+
24+
// Marker component for plugin insertion point
25+
export const PluginSlot: FC = () => null;
2326
/**
2427
* Note:Please set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered.
2528
*
@@ -29,13 +32,16 @@ import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit';
2932
* @field type: Used to formulate the rendering of all plugins of this type.
3033
* (if the `slug_name` attribute is set, it will be ignored)
3134
* @field prop: Any attribute you want to configure, e.g. `className`
35+
*
36+
* For editor type plugins, use <PluginSlot /> component as a marker to indicate where plugins should be inserted.
3237
*/
3338

3439
interface Props {
3540
slug_name?: string;
3641
type: PluginType;
3742
children?: ReactNode;
38-
[prop: string]: any;
43+
className?: string;
44+
[key: string]: unknown;
3945
}
4046

4147
const Index: FC<Props> = ({
@@ -45,29 +51,70 @@ const Index: FC<Props> = ({
4551
className,
4652
...props
4753
}) => {
48-
const pluginSlice: Plugin[] = [];
49-
const plugins = PluginKit.getPlugins().filter((plugin) => plugin.activated);
54+
const [pluginSlice, setPluginSlice] = useState<Plugin[]>([]);
55+
const [isLoading, setIsLoading] = useState(true);
5056

51-
plugins.forEach((plugin) => {
52-
if (type && slug_name) {
53-
if (plugin.info.slug_name === slug_name && plugin.info.type === type) {
54-
pluginSlice.push(plugin);
55-
}
56-
} else if (type) {
57-
if (plugin.info.type === type) {
58-
pluginSlice.push(plugin);
59-
}
60-
} else if (slug_name) {
61-
if (plugin.info.slug_name === slug_name) {
62-
pluginSlice.push(plugin);
57+
useEffect(() => {
58+
let mounted = true;
59+
60+
const loadPlugins = async () => {
61+
await PluginKit.initialization;
62+
63+
if (!mounted) return;
64+
65+
const plugins = PluginKit.getPlugins().filter(
66+
(plugin) => plugin.activated,
67+
);
68+
console.log(
69+
'[PluginRender] Loaded plugins:',
70+
plugins.map((p) => p.info.slug_name),
71+
);
72+
const filtered: Plugin[] = [];
73+
74+
plugins.forEach((plugin) => {
75+
if (type && slug_name) {
76+
if (
77+
plugin.info.slug_name === slug_name &&
78+
plugin.info.type === type
79+
) {
80+
filtered.push(plugin);
81+
}
82+
} else if (type) {
83+
if (plugin.info.type === type) {
84+
filtered.push(plugin);
85+
}
86+
} else if (slug_name) {
87+
if (plugin.info.slug_name === slug_name) {
88+
filtered.push(plugin);
89+
}
90+
}
91+
});
92+
93+
if (mounted) {
94+
setPluginSlice(filtered);
95+
setIsLoading(false);
6396
}
64-
}
65-
});
97+
};
98+
99+
loadPlugins();
100+
101+
return () => {
102+
mounted = false;
103+
};
104+
}, [slug_name, type]);
66105

67106
/**
68107
* TODO: Rendering control for non-builtin plug-ins
69108
* ps: Logic such as version compatibility determination can be placed here
70109
*/
110+
if (isLoading) {
111+
// Don't render anything while loading to avoid flashing
112+
if (type === 'editor') {
113+
return <div className={className}>{children}</div>;
114+
}
115+
return null;
116+
}
117+
71118
if (pluginSlice.length === 0) {
72119
if (type === 'editor') {
73120
return <div className={className}>{children}</div>;
@@ -76,20 +123,17 @@ const Index: FC<Props> = ({
76123
}
77124

78125
if (type === 'editor') {
79-
// index 16 is the position of the toolbar in the editor for plugins
80-
const nodes = React.Children.map(children, (child, index) => {
81-
if (index === 16) {
126+
// Use PluginSlot marker to insert plugins at the correct position
127+
const nodes = React.Children.map(children, (child) => {
128+
// Check if this is the PluginSlot marker
129+
if (React.isValidElement(child) && child.type === PluginSlot) {
82130
return (
83131
<>
84-
{child}
85132
{pluginSlice.map((ps) => {
86-
const PluginFC = ps.component;
87-
return (
88-
// @ts-ignore
89-
<PluginFC key={ps.info.slug_name} {...props} />
90-
);
133+
const PluginFC = ps.component as FC<typeof props>;
134+
return <PluginFC key={ps.info.slug_name} {...props} />;
91135
})}
92-
<div className="toolbar-divider" />
136+
{pluginSlice.length > 0 && <div className="toolbar-divider" />}
93137
</>
94138
);
95139
}
@@ -102,9 +146,10 @@ const Index: FC<Props> = ({
102146
return (
103147
<>
104148
{pluginSlice.map((ps) => {
105-
const PluginFC = ps.component;
149+
const PluginFC = ps.component as FC<
150+
{ className?: string } & typeof props
151+
>;
106152
return (
107-
// @ts-ignore
108153
<PluginFC key={ps.info.slug_name} className={className} {...props} />
109154
);
110155
})}

ui/src/utils/pluginKit/index.ts

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,45 @@ class Plugins {
4949

5050
initialization: Promise<void>;
5151

52+
private isInitialized = false;
53+
54+
private initializationError: Error | null = null;
55+
5256
constructor() {
5357
this.initialization = this.init();
5458
}
5559

5660
async init() {
57-
this.registerBuiltin();
61+
if (this.isInitialized) {
62+
return;
63+
}
5864

59-
// Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given
60-
const plugins = (await getPluginsStatus().catch(() => [])) || [];
61-
this.registeredPlugins = plugins.filter((p) => p.enabled);
62-
await this.registerPlugins();
65+
try {
66+
this.registerBuiltin();
67+
68+
// Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given
69+
const plugins =
70+
(await getPluginsStatus().catch((error) => {
71+
console.warn('Failed to get plugins status:', error);
72+
return [];
73+
})) || [];
74+
this.registeredPlugins = plugins.filter((p) => p.enabled);
75+
await this.registerPlugins();
76+
this.isInitialized = true;
77+
this.initializationError = null;
78+
} catch (error) {
79+
this.initializationError = error as Error;
80+
console.error('Plugin initialization failed:', error);
81+
throw error;
82+
}
6383
}
6484

65-
refresh() {
85+
async refresh() {
6686
this.plugins = [];
67-
this.init();
87+
this.isInitialized = false;
88+
this.initializationError = null;
89+
this.initialization = this.init();
90+
await this.initialization;
6891
}
6992

7093
validate(plugin: Plugin) {
@@ -95,17 +118,46 @@ class Plugins {
95118
});
96119
}
97120

98-
registerPlugins() {
99-
const plugins = this.registeredPlugins
121+
async registerPlugins() {
122+
console.log(
123+
'[PluginKit] Registered plugins from API:',
124+
this.registeredPlugins.map((p) => p.slug_name),
125+
);
126+
127+
const pluginLoaders = this.registeredPlugins
100128
.map((p) => {
101129
const func = allPlugins[p.slug_name];
102-
103-
return func;
130+
if (!func) {
131+
console.warn(
132+
`[PluginKit] Plugin loader not found for: ${p.slug_name}`,
133+
);
134+
}
135+
return { slug_name: p.slug_name, loader: func };
104136
})
105-
.filter((p) => p);
106-
return Promise.all(plugins.map((p) => p())).then((resolvedPlugins) => {
107-
resolvedPlugins.forEach((plugin) => this.register(plugin));
108-
return true;
137+
.filter((p) => p.loader);
138+
139+
console.log(
140+
'[PluginKit] Found plugin loaders:',
141+
pluginLoaders.map((p) => p.slug_name),
142+
);
143+
144+
// Use Promise.allSettled to prevent one plugin failure from breaking all plugins
145+
const results = await Promise.allSettled(
146+
pluginLoaders.map((p) => p.loader()),
147+
);
148+
149+
results.forEach((result, index) => {
150+
if (result.status === 'fulfilled') {
151+
console.log(
152+
`[PluginKit] Successfully loaded plugin: ${pluginLoaders[index].slug_name}`,
153+
);
154+
this.register(result.value);
155+
} else {
156+
console.error(
157+
`[PluginKit] Failed to load plugin ${pluginLoaders[index].slug_name}:`,
158+
result.reason,
159+
);
160+
}
109161
});
110162
}
111163

@@ -114,6 +166,16 @@ class Plugins {
114166
if (!bool) {
115167
return;
116168
}
169+
170+
// Prevent duplicate registration
171+
const exists = this.plugins.some(
172+
(p) => p.info.slug_name === plugin.info.slug_name,
173+
);
174+
if (exists) {
175+
console.warn(`Plugin ${plugin.info.slug_name} is already registered`);
176+
return;
177+
}
178+
117179
if (plugin.i18nConfig) {
118180
initI18nResource(plugin.i18nConfig);
119181
}
@@ -133,6 +195,18 @@ class Plugins {
133195
getPlugins() {
134196
return this.plugins;
135197
}
198+
199+
async getPluginsAsync() {
200+
await this.initialization;
201+
return this.plugins;
202+
}
203+
204+
getInitializationStatus() {
205+
return {
206+
isInitialized: this.isInitialized,
207+
error: this.initializationError,
208+
};
209+
}
136210
}
137211

138212
const plugins = new Plugins();

0 commit comments

Comments
 (0)