Selaa lähdekoodia

fix(@vben/plugins): 根据代码审查意见修复 tiptap 图片上传

- 提取 findPlaceholderPos 辅助函数,消除重复的 descendants 查找
- 添加 editor.isDestroyed 守卫,防止操作已销毁编辑器
- renderHTML 不输出上传状态属性,防止 blob URL 泄露到序列化 HTML
- uploadImage 命令返回 boolean,添加 Commands 类型增强,移除 as any
- 拖拽/粘贴多图时仅处理第一张并提示仅支持单图上传
- 自定义 extensions 时不传 imageUpload 给工具栏,toolbar action 加运行时守卫
yuan.ji 1 kuukausi sitten
vanhempi
commit
244c0a5884

+ 69 - 77
packages/effects/plugins/src/tiptap/extensions.ts

@@ -56,6 +56,22 @@ function handleUploadError(error: unknown, options: ImageUploadOptions): void {
   }
 }
 
+function findPlaceholderPos(doc: ProseMirrorNode, blobUrl: string): number {
+  let found = -1;
+  doc.descendants((node: ProseMirrorNode, offset: number) => {
+    if (found !== -1) return false;
+    if (
+      node.type.name === 'image' &&
+      node.attrs.src === blobUrl &&
+      node.attrs['data-uploading'] === 'true'
+    ) {
+      found = offset;
+      return false;
+    }
+  });
+  return found;
+}
+
 interface UploadContext {
   blobUrl: string;
   pos: number;
@@ -85,63 +101,33 @@ function createUploadProcess(
     })
     .run();
 
-  // Find the node we just inserted to track its position
-  let nodePos = insertPos;
-  const { doc } = editor.state;
-  doc.descendants((node: ProseMirrorNode, offset: number) => {
-    if (
-      node.type.name === 'image' &&
-      node.attrs.src === blobUrl &&
-      node.attrs['data-uploading'] === 'true'
-    ) {
-      nodePos = offset;
-      return false;
-    }
-  });
+  const nodePos = findPlaceholderPos(editor.state.doc, blobUrl);
 
   const uploadContext: UploadContext = { blobUrl, pos: nodePos };
 
   options
     .upload(file, (percent: number) => {
-      // Update progress attribute on the placeholder image
-      const currentState = editor.state;
-      let currentPos = -1;
-      currentState.doc.descendants((node: ProseMirrorNode, offset: number) => {
-        if (
-          node.type.name === 'image' &&
-          node.attrs.src === blobUrl &&
-          node.attrs['data-uploading'] === 'true'
-        ) {
-          currentPos = offset;
-          return false;
-        }
-      });
+      if (editor.isDestroyed) return;
 
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
       if (currentPos === -1) return;
 
-      const node = currentState.doc.nodeAt(currentPos);
+      const node = editor.state.doc.nodeAt(currentPos);
       if (!node) return;
 
-      const transaction = currentState.tr.setNodeMarkup(currentPos, undefined, {
+      const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
         ...node.attrs,
         'data-upload-progress': percent,
       });
       editor.view.dispatch(transaction);
     })
     .then((url: string) => {
-      // Replace blob URL with real URL and remove uploading attributes
-      const currentState = editor.state;
-      let currentPos = -1;
-      currentState.doc.descendants((node: ProseMirrorNode, offset: number) => {
-        if (
-          node.type.name === 'image' &&
-          node.attrs.src === blobUrl &&
-          node.attrs['data-uploading'] === 'true'
-        ) {
-          currentPos = offset;
-          return false;
-        }
-      });
+      if (editor.isDestroyed) {
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
 
       if (currentPos === -1) {
         blobUrlTracker?.delete(blobUrl);
@@ -149,14 +135,14 @@ function createUploadProcess(
         return;
       }
 
-      const node = currentState.doc.nodeAt(currentPos);
+      const node = editor.state.doc.nodeAt(currentPos);
       if (!node) {
         blobUrlTracker?.delete(blobUrl);
         URL.revokeObjectURL(blobUrl);
         return;
       }
 
-      const transaction = currentState.tr.setNodeMarkup(currentPos, undefined, {
+      const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
         ...node.attrs,
         'data-upload-progress': null,
         'data-uploading': null,
@@ -167,24 +153,17 @@ function createUploadProcess(
       URL.revokeObjectURL(blobUrl);
     })
     .catch((error: unknown) => {
-      // Remove placeholder image on failure
-      const currentState = editor.state;
-      let currentPos = -1;
-      currentState.doc.descendants((node: ProseMirrorNode, offset: number) => {
-        if (
-          node.type.name === 'image' &&
-          node.attrs.src === blobUrl &&
-          node.attrs['data-uploading'] === 'true'
-        ) {
-          currentPos = offset;
-          return false;
-        }
-      });
+      if (editor.isDestroyed) {
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
 
       if (currentPos !== -1) {
-        const transaction = currentState.tr.delete(
+        const transaction = editor.state.tr.delete(
           currentPos,
-          currentPos + (currentState.doc.nodeAt(currentPos)?.nodeSize ?? 1),
+          currentPos + (editor.state.doc.nodeAt(currentPos)?.nodeSize ?? 1),
         );
         editor.view.dispatch(transaction);
       }
@@ -208,23 +187,15 @@ function createCustomImage(
         'data-upload-progress': {
           default: null,
           parseHTML: (element) => element.dataset.uploadProgress,
-          renderHTML: (attributes) => {
-            if (
-              attributes['data-upload-progress'] === null ||
-              attributes['data-upload-progress'] === undefined
-            )
-              return {};
-            return {
-              'data-upload-progress': attributes['data-upload-progress'],
-            };
+          renderHTML: () => {
+            return {};
           },
         },
         'data-uploading': {
           default: null,
           parseHTML: (element) => element.dataset.uploading,
-          renderHTML: (attributes) => {
-            if (!attributes['data-uploading']) return {};
-            return { 'data-uploading': attributes['data-uploading'] };
+          renderHTML: () => {
+            return {};
           },
         },
       };
@@ -325,6 +296,7 @@ function createCustomImage(
 
             document.body.append(input);
             input.click();
+            return true;
           },
       };
     },
@@ -339,18 +311,29 @@ function createCustomImage(
             handleDrop: (view: EditorView, event: DragEvent) => {
               if (!event.dataTransfer?.files.length) return false;
 
-              const file = event.dataTransfer.files[0];
-              if (!file || !file.type.startsWith('image/')) return false;
+              const imageFiles = [...event.dataTransfer.files].filter((f) =>
+                f.type.startsWith('image/'),
+              );
+              if (imageFiles.length === 0) return false;
 
               event.preventDefault();
 
+              // Only support single image upload
+              const file = imageFiles[0];
+              if (!file) return false;
+              if (imageFiles.length > 1) {
+                handleUploadError(
+                  new Error($t('ui.tiptap.upload.onlySingleImage')),
+                  imageUpload,
+                );
+              }
+
               const error = validateFile(file, imageUpload);
               if (error) {
                 handleUploadError(new Error(error), imageUpload);
                 return true;
               }
 
-              // Calculate drop position
               const coordinates = view.posAtCoords({
                 left: event.clientX,
                 top: event.clientY,
@@ -376,18 +359,27 @@ function createCustomImage(
               const items = event.clipboardData?.items;
               if (!items) return false;
 
-              let imageFile: File | undefined;
+              const imageFiles: File[] = [];
               for (const item of items) {
                 if (item.type.startsWith('image/')) {
-                  imageFile = item.getAsFile() ?? undefined;
-                  break;
+                  const file = item.getAsFile();
+                  if (file) imageFiles.push(file);
                 }
               }
 
-              if (!imageFile) return false;
+              if (imageFiles.length === 0) return false;
 
               event.preventDefault();
 
+              const imageFile = imageFiles[0];
+              if (!imageFile) return false;
+              if (imageFiles.length > 1) {
+                handleUploadError(
+                  new Error($t('ui.tiptap.upload.onlySingleImage')),
+                  imageUpload,
+                );
+              }
+
               const error = validateFile(imageFile, imageUpload);
               if (error) {
                 handleUploadError(new Error(error), imageUpload);

+ 4 - 1
packages/effects/plugins/src/tiptap/tiptap.vue

@@ -74,7 +74,10 @@ const editor = useEditor({
   },
 });
 const toolbarGroups = computed<ToolbarAction[][]>(() => {
-  return createToolbarGroups(props.imageUpload);
+  // Only show upload toolbar option when using default extensions
+  // (custom extensions may not include the uploadImage command)
+  const effectiveImageUpload = props.extensions ? undefined : props.imageUpload;
+  return createToolbarGroups(effectiveImageUpload);
 });
 const previewContent = computed(
   () => editor.value?.getHTML() ?? modelValue.value,

+ 5 - 1
packages/effects/plugins/src/tiptap/toolbar.ts

@@ -290,7 +290,11 @@ export function createToolbarGroups(
               menu: {
                 items: [
                   {
-                    action: (editor) => (editor.commands as any).uploadImage(),
+                    action: (editor) => {
+                      if (typeof editor.commands.uploadImage === 'function') {
+                        editor.commands.uploadImage();
+                      }
+                    },
                     label: $t('ui.tiptap.toolbar.imageUpload'),
                     shortLabel: 'UPL',
                   },

+ 8 - 0
packages/effects/plugins/src/tiptap/types.ts

@@ -3,6 +3,14 @@ import type { Editor } from '@tiptap/vue-3';
 
 import type { Component } from 'vue';
 
+declare module '@tiptap/core' {
+  interface Commands<ReturnType> {
+    imageUpload: {
+      uploadImage: () => ReturnType;
+    };
+  }
+}
+
 export interface ImageUploadOptions {
   /** 允许的文件类型,默认 'image/*' */
   accept?: string;

+ 1 - 0
packages/locales/src/langs/en-US/ui.json

@@ -101,6 +101,7 @@
     "upload": {
       "fileTooLarge": "File size exceeds the limit",
       "fileTypeNotAllowed": "File type is not allowed",
+      "onlySingleImage": "Only single image upload is supported, the first one is selected",
       "uploadFailed": "Upload Failed"
     }
   },

+ 1 - 0
packages/locales/src/langs/zh-CN/ui.json

@@ -101,6 +101,7 @@
     "upload": {
       "fileTooLarge": "文件大小超出限制",
       "fileTypeNotAllowed": "不支持的文件类型",
+      "onlySingleImage": "仅支持单张图片上传,已选取第一张",
       "uploadFailed": "上传失败"
     }
   },