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 78714e83cfcc2641abbb4e63d6557894f59128a3
Author: robin <[email protected]>
AuthorDate: Thu Dec 25 11:50:55 2025 +0800

    feat(editor): implement image upload functionality with validation and hooks
---
 ui/src/components/Editor/ToolBars/image.tsx      | 102 ++----------------
 ui/src/components/Editor/hooks/useImageUpload.ts | 129 +++++++++++++++++++++++
 ui/src/components/Editor/index.tsx               |  83 ++++++++++++++-
 ui/src/utils/pluginKit/index.ts                  |  39 +++++++
 ui/src/utils/pluginKit/interface.ts              |   2 +
 5 files changed, 259 insertions(+), 96 deletions(-)

diff --git a/ui/src/components/Editor/ToolBars/image.tsx 
b/ui/src/components/Editor/ToolBars/image.tsx
index 52b2e546..0783e22e 100644
--- a/ui/src/components/Editor/ToolBars/image.tsx
+++ b/ui/src/components/Editor/ToolBars/image.tsx
@@ -21,12 +21,10 @@ import { useEffect, useState, memo, useContext } from 
'react';
 import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
-import { Modal as AnswerModal } from '@/components';
 import ToolItem from '../toolItem';
 import { EditorContext } from '../EditorContext';
 import { Editor } from '../types';
-import { uploadImage } from '@/services';
-import { writeSettingStore } from '@/stores';
+import { useImageUpload } from '../hooks/useImageUpload';
 
 const Image = () => {
   const editor = useContext(EditorContext);
@@ -40,12 +38,7 @@ const Image = () => {
     }
   }, [editor]);
   const { t } = useTranslation('translation', { keyPrefix: 'editor' });
-  const {
-    max_image_size = 4,
-    max_attachment_size = 8,
-    authorized_image_extensions = [],
-    authorized_attachment_extensions = [],
-  } = writeSettingStore((state) => state.write);
+  const { verifyImageSize, uploadFiles } = useImageUpload();
 
   const loadingText = `![${t('image.uploading')}...]()`;
 
@@ -69,89 +62,6 @@ const Image = () => {
     errorMsg: '',
   });
 
-  const verifyImageSize = (files: FileList) => {
-    if (files.length === 0) {
-      return false;
-    }
-
-    /**
-     * When allowing attachments to be uploaded, verification logic for 
attachment information has been added. In order to avoid abnormal judgment 
caused by the order of drag and drop upload, the drag and drop upload 
verification of attachments and the drag and drop upload of images are put 
together.
-     *
-     */
-    const canUploadAttachment = authorized_attachment_extensions.length > 0;
-    const allowedAllType = [
-      ...authorized_image_extensions,
-      ...authorized_attachment_extensions,
-    ];
-    const unSupportFiles = Array.from(files).filter((file) => {
-      const fileName = file.name.toLowerCase();
-      return canUploadAttachment
-        ? !allowedAllType.find((v) => fileName.endsWith(v))
-        : file.type.indexOf('image') === -1;
-    });
-
-    if (unSupportFiles.length > 0) {
-      AnswerModal.confirm({
-        content: canUploadAttachment
-          ? t('file.not_supported', { file_type: allowedAllType.join(', ') })
-          : t('image.form_image.fields.file.msg.only_image'),
-        showCancel: false,
-      });
-      return false;
-    }
-
-    const otherFiles = Array.from(files).filter((file) => {
-      return file.type.indexOf('image') === -1;
-    });
-
-    if (canUploadAttachment && otherFiles.length > 0) {
-      const attachmentOverSizeFiles = otherFiles.filter(
-        (file) => file.size / 1024 / 1024 > max_attachment_size,
-      );
-      if (attachmentOverSizeFiles.length > 0) {
-        AnswerModal.confirm({
-          content: t('file.max_size', { size: max_attachment_size }),
-          showCancel: false,
-        });
-        return false;
-      }
-    }
-
-    const imageFiles = Array.from(files).filter(
-      (file) => file.type.indexOf('image') > -1,
-    );
-    const oversizedImages = imageFiles.filter(
-      (file) => file.size / 1024 / 1024 > max_image_size,
-    );
-    if (oversizedImages.length > 0) {
-      AnswerModal.confirm({
-        content: t('image.form_image.fields.file.msg.max_size', {
-          size: max_image_size,
-        }),
-        showCancel: false,
-      });
-      return false;
-    }
-
-    return true;
-  };
-
-  const upload = (
-    files: FileList,
-  ): Promise<{ url: string; name: string; type: string }[]> => {
-    const promises = Array.from(files).map(async (file) => {
-      const type = file.type.indexOf('image') > -1 ? 'post' : 
'post_attachment';
-      const url = await uploadImage({ file, type });
-
-      return {
-        name: file.name,
-        url,
-        type,
-      };
-    });
-
-    return Promise.all(promises);
-  };
   function dragenter(e) {
     e.stopPropagation();
     e.preventDefault();
@@ -178,7 +88,7 @@ const Image = () => {
 
     editorState.replaceSelection(loadingText);
     editorState.setReadOnly(true);
-    const urls = await upload(fileList)
+    const urls = await uploadFiles(fileList)
       .catch(() => {
         editorState.replaceRange('', startPos, endPos);
       })
@@ -217,7 +127,7 @@ const Image = () => {
 
       editorState?.replaceSelection(loadingText);
       editorState?.setReadOnly(true);
-      upload(clipboard.files)
+      uploadFiles(clipboard.files)
         .then((urls) => {
           const text = urls.map(({ name, url, type }) => {
             return `${type === 'post' ? '!' : ''}[${name}](${url})`;
@@ -358,6 +268,8 @@ const Image = () => {
     setVisible(true);
   };
 
+  const { uploadSingleFile } = useImageUpload();
+
   const onUpload = async (e) => {
     if (!editor) {
       return;
@@ -369,7 +281,7 @@ const Image = () => {
       return;
     }
 
-    uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => {
+    uploadSingleFile(e.target.files[0]).then((url) => {
       setLink({ ...link, value: url });
       setImageName({ ...imageName, value: files[0].name });
     });
diff --git a/ui/src/components/Editor/hooks/useImageUpload.ts 
b/ui/src/components/Editor/hooks/useImageUpload.ts
new file mode 100644
index 00000000..bf97e24d
--- /dev/null
+++ b/ui/src/components/Editor/hooks/useImageUpload.ts
@@ -0,0 +1,129 @@
+/*
+ * 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 { useTranslation } from 'react-i18next';
+
+import { Modal as AnswerModal } from '@/components';
+import { uploadImage } from '@/services';
+import { writeSettingStore } from '@/stores';
+
+export const useImageUpload = () => {
+  const { t } = useTranslation('translation', { keyPrefix: 'editor' });
+  const {
+    max_image_size = 4,
+    max_attachment_size = 8,
+    authorized_image_extensions = [],
+    authorized_attachment_extensions = [],
+  } = writeSettingStore((state) => state.write);
+
+  const verifyImageSize = (files: FileList | File[]): boolean => {
+    const fileArray = Array.isArray(files) ? files : Array.from(files);
+
+    if (fileArray.length === 0) {
+      return false;
+    }
+
+    const canUploadAttachment = authorized_attachment_extensions.length > 0;
+    const allowedAllType = [
+      ...authorized_image_extensions,
+      ...authorized_attachment_extensions,
+    ];
+
+    const unSupportFiles = fileArray.filter((file) => {
+      const fileName = file.name.toLowerCase();
+      return canUploadAttachment
+        ? !allowedAllType.find((v) => fileName.endsWith(v))
+        : file.type.indexOf('image') === -1;
+    });
+
+    if (unSupportFiles.length > 0) {
+      AnswerModal.confirm({
+        content: canUploadAttachment
+          ? t('file.not_supported', { file_type: allowedAllType.join(', ') })
+          : t('image.form_image.fields.file.msg.only_image'),
+        showCancel: false,
+      });
+      return false;
+    }
+
+    const otherFiles = fileArray.filter((file) => {
+      return file.type.indexOf('image') === -1;
+    });
+
+    if (canUploadAttachment && otherFiles.length > 0) {
+      const attachmentOverSizeFiles = otherFiles.filter(
+        (file) => file.size / 1024 / 1024 > max_attachment_size,
+      );
+      if (attachmentOverSizeFiles.length > 0) {
+        AnswerModal.confirm({
+          content: t('file.max_size', { size: max_attachment_size }),
+          showCancel: false,
+        });
+        return false;
+      }
+    }
+
+    const imageFiles = fileArray.filter(
+      (file) => file.type.indexOf('image') > -1,
+    );
+    const oversizedImages = imageFiles.filter(
+      (file) => file.size / 1024 / 1024 > max_image_size,
+    );
+    if (oversizedImages.length > 0) {
+      AnswerModal.confirm({
+        content: t('image.form_image.fields.file.msg.max_size', {
+          size: max_image_size,
+        }),
+        showCancel: false,
+      });
+      return false;
+    }
+
+    return true;
+  };
+
+  const uploadFiles = (
+    files: FileList | File[],
+  ): Promise<{ url: string; name: string; type: string }[]> => {
+    const fileArray = Array.isArray(files) ? files : Array.from(files);
+    const promises = fileArray.map(async (file) => {
+      const type = file.type.indexOf('image') > -1 ? 'post' : 
'post_attachment';
+      const url = await uploadImage({ file, type });
+
+      return {
+        name: file.name,
+        url,
+        type,
+      };
+    });
+
+    return Promise.all(promises);
+  };
+
+  const uploadSingleFile = async (file: File): Promise<string> => {
+    const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
+    return uploadImage({ file, type });
+  };
+
+  return {
+    verifyImageSize,
+    uploadFiles,
+    uploadSingleFile,
+  };
+};
diff --git a/ui/src/components/Editor/index.tsx 
b/ui/src/components/Editor/index.tsx
index c6136c3c..9c12bbb1 100644
--- a/ui/src/components/Editor/index.tsx
+++ b/ui/src/components/Editor/index.tsx
@@ -24,13 +24,21 @@ import {
   forwardRef,
   useImperativeHandle,
   useCallback,
+  useEffect,
 } from 'react';
+import { Spinner } from 'react-bootstrap';
 
 import classNames from 'classnames';
 
-import { PluginType, useRenderPlugin } from '@/utils/pluginKit';
+import {
+  PluginType,
+  useRenderPlugin,
+  getReplacementPlugin,
+} from '@/utils/pluginKit';
+import { writeSettingStore } from '@/stores';
 import PluginRender, { PluginSlot } from '../PluginRender';
 
+import { useImageUpload } from './hooks/useImageUpload';
 import {
   BlockQuote,
   Bold,
@@ -87,6 +95,32 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = 
(
 ) => {
   const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
   const previewRef = useRef<{ getHtml; element } | null>(null);
+  const [fullEditorPlugin, setFullEditorPlugin] = useState<any>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const { verifyImageSize, uploadSingleFile } = useImageUpload();
+  const {
+    max_image_size = 4,
+    authorized_image_extensions = [],
+    authorized_attachment_extensions = [],
+  } = writeSettingStore((state) => state.write);
+
+  useEffect(() => {
+    let mounted = true;
+
+    const loadPlugin = async () => {
+      const plugin = await getReplacementPlugin(PluginType.EditorReplacement);
+      if (mounted) {
+        setFullEditorPlugin(plugin);
+        setIsLoading(false);
+      }
+    };
+
+    loadPlugin();
+
+    return () => {
+      mounted = false;
+    };
+  }, []);
 
   useRenderPlugin(previewRef.current?.element);
 
@@ -104,6 +138,53 @@ const MDEditor: ForwardRefRenderFunction<EditorRef, Props> 
= (
 
   const EditorComponent = MarkdownEditor;
 
+  if (isLoading) {
+    return (
+      <div className={classNames('md-editor-wrap rounded', className)}>
+        <div
+          className="d-flex justify-content-center align-items-center"
+          style={{ minHeight: '200px' }}>
+          <Spinner animation="border" variant="secondary" />
+        </div>
+      </div>
+    );
+  }
+
+  if (fullEditorPlugin) {
+    const FullEditorComponent = fullEditorPlugin.component;
+
+    const handleImageUpload = async (file: File | string): Promise<string> => {
+      if (typeof file === 'string') {
+        return file;
+      }
+
+      if (!verifyImageSize([file])) {
+        throw new Error('File validation failed');
+      }
+
+      return uploadSingleFile(file);
+    };
+
+    return (
+      <FullEditorComponent
+        value={value}
+        onChange={onChange}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        placeholder={editorPlaceholder}
+        autoFocus={autoFocus}
+        imageUploadHandler={handleImageUpload}
+        uploadConfig={{
+          maxImageSizeMiB: max_image_size,
+          allowedExtensions: [
+            ...authorized_image_extensions,
+            ...authorized_attachment_extensions,
+          ],
+        }}
+      />
+    );
+  }
+
   return (
     <>
       <div className={classNames('md-editor-wrap rounded', className)}>
diff --git a/ui/src/utils/pluginKit/index.ts b/ui/src/utils/pluginKit/index.ts
index a217d893..b62d3f59 100644
--- a/ui/src/utils/pluginKit/index.ts
+++ b/ui/src/utils/pluginKit/index.ts
@@ -53,6 +53,8 @@ class Plugins {
 
   private initializationError: Error | null = null;
 
+  private replacementPlugins: Map<PluginType, Plugin> = new Map();
+
   constructor() {
     this.initialization = this.init();
   }
@@ -176,6 +178,23 @@ class Plugins {
       return;
     }
 
+    // Handle singleton plugins (only one per type allowed)
+    const mode = plugin.info.registrationMode || 'multiple';
+    if (mode === 'singleton') {
+      const existingPlugin = this.replacementPlugins.get(plugin.info.type);
+      if (existingPlugin) {
+        const error = new Error(
+          `[PluginKit] Plugin conflict: ` +
+            `Cannot register '${plugin.info.slug_name}' because 
'${existingPlugin.info.slug_name}' ` +
+            `is already registered as a singleton plugin of type 
'${plugin.info.type}'. ` +
+            `Only one singleton plugin per type is allowed.`,
+        );
+        console.error(error.message);
+        throw error;
+      }
+      this.replacementPlugins.set(plugin.info.type, plugin);
+    }
+
     if (plugin.i18nConfig) {
       initI18nResource(plugin.i18nConfig);
     }
@@ -207,6 +226,10 @@ class Plugins {
       error: this.initializationError,
     };
   }
+
+  getReplacementPlugin(type: PluginType): Plugin | null {
+    return this.replacementPlugins.get(type) || null;
+  }
 }
 
 const plugins = new Plugins();
@@ -242,6 +265,21 @@ const validateRoutePlugin = async (slugName) => {
   return Boolean(registeredPlugin?.enabled);
 };
 
+const getReplacementPlugin = async (
+  type: PluginType,
+): Promise<Plugin | null> => {
+  try {
+    await plugins.initialization;
+    return plugins.getReplacementPlugin(type);
+  } catch (error) {
+    console.error(
+      `[PluginKit] Failed to get replacement plugin of type ${type}:`,
+      error,
+    );
+    return null;
+  }
+};
+
 const mergeRoutePlugins = async (routes) => {
   const routePlugins = await getRoutePlugins();
   if (routePlugins.length === 0) {
@@ -348,6 +386,7 @@ export {
   mergeRoutePlugins,
   useCaptchaPlugin,
   useRenderPlugin,
+  getReplacementPlugin,
   PluginType,
 };
 export default plugins;
diff --git a/ui/src/utils/pluginKit/interface.ts 
b/ui/src/utils/pluginKit/interface.ts
index a1b641d8..1a2c4bee 100644
--- a/ui/src/utils/pluginKit/interface.ts
+++ b/ui/src/utils/pluginKit/interface.ts
@@ -26,6 +26,7 @@ export enum PluginType {
   Connector = 'connector',
   Search = 'search',
   Editor = 'editor',
+  EditorReplacement = 'editor_replacement',
   Route = 'route',
   Captcha = 'captcha',
   Render = 'render',
@@ -38,6 +39,7 @@ export interface PluginInfo {
   name?: string;
   description?: string;
   route?: string;
+  registrationMode?: 'multiple' | 'singleton';
 }
 
 export interface Plugin {

Reply via email to