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 1c2c733190376e62e8734918d542f61ebc3d9fa9 Author: robin <[email protected]> AuthorDate: Thu Dec 18 10:43:59 2025 +0800 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. --- ui/src/components/Editor/index.tsx | 6 +- ui/src/components/Editor/utils/tiptap/commands.ts | 20 +++-- ui/src/components/PluginRender/index.tsx | 105 +++++++++++++++------- ui/src/utils/pluginKit/index.ts | 104 +++++++++++++++++---- 4 files changed, 178 insertions(+), 57 deletions(-) diff --git a/ui/src/components/Editor/index.tsx b/ui/src/components/Editor/index.tsx index 91214ac3..ebe04876 100644 --- a/ui/src/components/Editor/index.tsx +++ b/ui/src/components/Editor/index.tsx @@ -29,7 +29,7 @@ import { import classNames from 'classnames'; import { PluginType, useRenderPlugin } from '@/utils/pluginKit'; -import PluginRender from '../PluginRender'; +import PluginRender, { PluginSlot } from '../PluginRender'; import { BlockQuote, @@ -86,7 +86,7 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = ( }, ref, ) => { - const [mode, setMode] = useState<'markdown' | 'rich'>('rich'); + const [mode, setMode] = useState<'markdown' | 'rich'>('markdown'); const [currentEditor, setCurrentEditor] = useState<Editor | null>(null); const previewRef = useRef<{ getHtml; element } | null>(null); @@ -145,10 +145,10 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = ( <Outdent /> <Hr /> <div className="toolbar-divider" /> + <PluginSlot /> <Help /> </PluginRender> </EditorContext.Provider> - <div className="btn-group ms-auto" role="group"> <button type="button" diff --git a/ui/src/components/Editor/utils/tiptap/commands.ts b/ui/src/components/Editor/utils/tiptap/commands.ts index ee32a4a3..08e2defc 100644 --- a/ui/src/components/Editor/utils/tiptap/commands.ts +++ b/ui/src/components/Editor/utils/tiptap/commands.ts @@ -633,15 +633,17 @@ export function createCommandMethods(editor: TipTapEditor) { if (text) { const { from } = editor.state.selection; const blockquoteText = `> ${text}`; - editor.commands.insertContent(blockquoteText, { - contentType: 'markdown', - }); - // Select the text part (excluding the '> ' marker) - const textStart = from + 2; // 2 for '> ' - editor.commands.setTextSelection({ - from: textStart, - to: textStart + text.length, - }); + + // Use chain to ensure selection happens after insertion + editor + .chain() + .focus() + .insertContent(blockquoteText, { contentType: 'markdown' }) + .setTextSelection({ + from: from + 1, + to: from + 1 + text.length, + }) + .run(); } else { editor.commands.toggleBlockquote(); } diff --git a/ui/src/components/PluginRender/index.tsx b/ui/src/components/PluginRender/index.tsx index 057f4952..b241bf05 100644 --- a/ui/src/components/PluginRender/index.tsx +++ b/ui/src/components/PluginRender/index.tsx @@ -17,9 +17,12 @@ * under the License. */ -import React, { FC, ReactNode } from 'react'; +import React, { FC, ReactNode, useEffect, useState } from 'react'; import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit'; + +// Marker component for plugin insertion point +export const PluginSlot: FC = () => null; /** * Noteļ¼Please set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered. * @@ -29,13 +32,16 @@ import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit'; * @field type: Used to formulate the rendering of all plugins of this type. * (if the `slug_name` attribute is set, it will be ignored) * @field prop: Any attribute you want to configure, e.g. `className` + * + * For editor type plugins, use <PluginSlot /> component as a marker to indicate where plugins should be inserted. */ interface Props { slug_name?: string; type: PluginType; children?: ReactNode; - [prop: string]: any; + className?: string; + [key: string]: unknown; } const Index: FC<Props> = ({ @@ -45,29 +51,70 @@ const Index: FC<Props> = ({ className, ...props }) => { - const pluginSlice: Plugin[] = []; - const plugins = PluginKit.getPlugins().filter((plugin) => plugin.activated); + const [pluginSlice, setPluginSlice] = useState<Plugin[]>([]); + const [isLoading, setIsLoading] = useState(true); - plugins.forEach((plugin) => { - if (type && slug_name) { - if (plugin.info.slug_name === slug_name && plugin.info.type === type) { - pluginSlice.push(plugin); - } - } else if (type) { - if (plugin.info.type === type) { - pluginSlice.push(plugin); - } - } else if (slug_name) { - if (plugin.info.slug_name === slug_name) { - pluginSlice.push(plugin); + useEffect(() => { + let mounted = true; + + const loadPlugins = async () => { + await PluginKit.initialization; + + if (!mounted) return; + + const plugins = PluginKit.getPlugins().filter( + (plugin) => plugin.activated, + ); + console.log( + '[PluginRender] Loaded plugins:', + plugins.map((p) => p.info.slug_name), + ); + const filtered: Plugin[] = []; + + plugins.forEach((plugin) => { + if (type && slug_name) { + if ( + plugin.info.slug_name === slug_name && + plugin.info.type === type + ) { + filtered.push(plugin); + } + } else if (type) { + if (plugin.info.type === type) { + filtered.push(plugin); + } + } else if (slug_name) { + if (plugin.info.slug_name === slug_name) { + filtered.push(plugin); + } + } + }); + + if (mounted) { + setPluginSlice(filtered); + setIsLoading(false); } - } - }); + }; + + loadPlugins(); + + return () => { + mounted = false; + }; + }, [slug_name, type]); /** * TODO: Rendering control for non-builtin plug-ins * ps: Logic such as version compatibility determination can be placed here */ + if (isLoading) { + // Don't render anything while loading to avoid flashing + if (type === 'editor') { + return <div className={className}>{children}</div>; + } + return null; + } + if (pluginSlice.length === 0) { if (type === 'editor') { return <div className={className}>{children}</div>; @@ -76,20 +123,17 @@ const Index: FC<Props> = ({ } if (type === 'editor') { - // index 16 is the position of the toolbar in the editor for plugins - const nodes = React.Children.map(children, (child, index) => { - if (index === 16) { + // Use PluginSlot marker to insert plugins at the correct position + const nodes = React.Children.map(children, (child) => { + // Check if this is the PluginSlot marker + if (React.isValidElement(child) && child.type === PluginSlot) { return ( <> - {child} {pluginSlice.map((ps) => { - const PluginFC = ps.component; - return ( - // @ts-ignore - <PluginFC key={ps.info.slug_name} {...props} /> - ); + const PluginFC = ps.component as FC<typeof props>; + return <PluginFC key={ps.info.slug_name} {...props} />; })} - <div className="toolbar-divider" /> + {pluginSlice.length > 0 && <div className="toolbar-divider" />} </> ); } @@ -102,9 +146,10 @@ const Index: FC<Props> = ({ return ( <> {pluginSlice.map((ps) => { - const PluginFC = ps.component; + const PluginFC = ps.component as FC< + { className?: string } & typeof props + >; return ( - // @ts-ignore <PluginFC key={ps.info.slug_name} className={className} {...props} /> ); })} diff --git a/ui/src/utils/pluginKit/index.ts b/ui/src/utils/pluginKit/index.ts index 0219da3f..a217d893 100644 --- a/ui/src/utils/pluginKit/index.ts +++ b/ui/src/utils/pluginKit/index.ts @@ -49,22 +49,45 @@ class Plugins { initialization: Promise<void>; + private isInitialized = false; + + private initializationError: Error | null = null; + constructor() { this.initialization = this.init(); } async init() { - this.registerBuiltin(); + if (this.isInitialized) { + return; + } - // Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given - const plugins = (await getPluginsStatus().catch(() => [])) || []; - this.registeredPlugins = plugins.filter((p) => p.enabled); - await this.registerPlugins(); + try { + this.registerBuiltin(); + + // Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given + const plugins = + (await getPluginsStatus().catch((error) => { + console.warn('Failed to get plugins status:', error); + return []; + })) || []; + this.registeredPlugins = plugins.filter((p) => p.enabled); + await this.registerPlugins(); + this.isInitialized = true; + this.initializationError = null; + } catch (error) { + this.initializationError = error as Error; + console.error('Plugin initialization failed:', error); + throw error; + } } - refresh() { + async refresh() { this.plugins = []; - this.init(); + this.isInitialized = false; + this.initializationError = null; + this.initialization = this.init(); + await this.initialization; } validate(plugin: Plugin) { @@ -95,17 +118,46 @@ class Plugins { }); } - registerPlugins() { - const plugins = this.registeredPlugins + async registerPlugins() { + console.log( + '[PluginKit] Registered plugins from API:', + this.registeredPlugins.map((p) => p.slug_name), + ); + + const pluginLoaders = this.registeredPlugins .map((p) => { const func = allPlugins[p.slug_name]; - - return func; + if (!func) { + console.warn( + `[PluginKit] Plugin loader not found for: ${p.slug_name}`, + ); + } + return { slug_name: p.slug_name, loader: func }; }) - .filter((p) => p); - return Promise.all(plugins.map((p) => p())).then((resolvedPlugins) => { - resolvedPlugins.forEach((plugin) => this.register(plugin)); - return true; + .filter((p) => p.loader); + + console.log( + '[PluginKit] Found plugin loaders:', + pluginLoaders.map((p) => p.slug_name), + ); + + // Use Promise.allSettled to prevent one plugin failure from breaking all plugins + const results = await Promise.allSettled( + pluginLoaders.map((p) => p.loader()), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + console.log( + `[PluginKit] Successfully loaded plugin: ${pluginLoaders[index].slug_name}`, + ); + this.register(result.value); + } else { + console.error( + `[PluginKit] Failed to load plugin ${pluginLoaders[index].slug_name}:`, + result.reason, + ); + } }); } @@ -114,6 +166,16 @@ class Plugins { if (!bool) { return; } + + // Prevent duplicate registration + const exists = this.plugins.some( + (p) => p.info.slug_name === plugin.info.slug_name, + ); + if (exists) { + console.warn(`Plugin ${plugin.info.slug_name} is already registered`); + return; + } + if (plugin.i18nConfig) { initI18nResource(plugin.i18nConfig); } @@ -133,6 +195,18 @@ class Plugins { getPlugins() { return this.plugins; } + + async getPluginsAsync() { + await this.initialization; + return this.plugins; + } + + getInitializationStatus() { + return { + isInitialized: this.isInitialized, + error: this.initializationError, + }; + } } const plugins = new Plugins();
