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();

Reply via email to