Quellcode durchsuchen

Merge branch 'fork/jyqwq/feature/富文本支持图片上传'

Jin Mao vor 1 Monat
Ursprung
Commit
86445a38e4

+ 1 - 0
packages/effects/plugins/package.json

@@ -45,6 +45,7 @@
     "@tiptap/extension-text-align": "catalog:",
     "@tiptap/extension-text-style": "catalog:",
     "@tiptap/extension-underline": "catalog:",
+    "@tiptap/pm": "catalog:",
     "@tiptap/starter-kit": "catalog:",
     "@tiptap/vue-3": "catalog:",
     "@vben-core/design": "workspace:*",

+ 404 - 7
packages/effects/plugins/src/tiptap/extensions.ts

@@ -1,9 +1,14 @@
+import type { Editor as CoreEditor } from '@tiptap/core';
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
+import type { EditorView } from '@tiptap/pm/view';
 import type { Extensions } from '@tiptap/vue-3';
 
-import type { VbenTiptapExtensionOptions } from './types';
+import type { ImageUploadOptions, VbenTiptapExtensionOptions } from './types';
 
 import { $t } from '@vben/locales';
 
+import { alert } from '@vben-core/popup-ui';
+
 import Document from '@tiptap/extension-document';
 import Highlight from '@tiptap/extension-highlight';
 import Image from '@tiptap/extension-image';
@@ -12,8 +17,390 @@ import Placeholder from '@tiptap/extension-placeholder';
 import TextAlign from '@tiptap/extension-text-align';
 import { Color, TextStyle } from '@tiptap/extension-text-style';
 import Underline from '@tiptap/extension-underline';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
 import StarterKit from '@tiptap/starter-kit';
 
+const DEFAULT_ACCEPT = 'image/*';
+
+function validateFile(
+  file: File,
+  options: ImageUploadOptions,
+): string | undefined {
+  if (options.maxSize !== undefined && file.size > options.maxSize) {
+    return $t('ui.tiptap.upload.fileTooLarge');
+  }
+
+  const accept = options.accept ?? DEFAULT_ACCEPT;
+  if (accept && accept !== '*/*' && accept !== 'image/*') {
+    const acceptedTypes = accept.split(',').map((t) => t.trim());
+    const isAccepted = acceptedTypes.some((type) => {
+      if (type.endsWith('/*')) {
+        return file.type.startsWith(type.slice(0, -1));
+      }
+      return file.type === type;
+    });
+    if (!isAccepted) {
+      return $t('ui.tiptap.upload.fileTypeNotAllowed');
+    }
+  }
+
+  return undefined;
+}
+
+function handleUploadError(error: unknown, options: ImageUploadOptions): void {
+  if (options.onUploadError) {
+    options.onUploadError(error);
+  } else {
+    const message = error instanceof Error ? error.message : String(error);
+    alert(message, $t('ui.tiptap.upload.uploadFailed')).catch(() => {});
+  }
+}
+
+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;
+}
+
+function createUploadProcess(
+  editor: CoreEditor,
+  file: File,
+  options: ImageUploadOptions,
+  blobUrlTracker?: Set<string>,
+  pos?: number,
+): UploadContext {
+  const blobUrl = URL.createObjectURL(file);
+  blobUrlTracker?.add(blobUrl);
+  const insertPos = pos ?? editor.state.selection.from;
+
+  // Insert placeholder image with blob URL
+  editor
+    .chain()
+    .insertContentAt(insertPos, {
+      attrs: {
+        'data-upload-progress': 0,
+        'data-uploading': 'true',
+        src: blobUrl,
+      },
+      type: 'image',
+    })
+    .run();
+
+  const nodePos = findPlaceholderPos(editor.state.doc, blobUrl);
+
+  const uploadContext: UploadContext = { blobUrl, pos: nodePos };
+
+  options
+    .upload(file, (percent: number) => {
+      if (editor.isDestroyed) return;
+
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
+      if (currentPos === -1) return;
+
+      const node = editor.state.doc.nodeAt(currentPos);
+      if (!node) return;
+
+      const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
+        ...node.attrs,
+        'data-upload-progress': percent,
+      });
+      editor.view.dispatch(transaction);
+    })
+    .then((url: string) => {
+      if (editor.isDestroyed) {
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
+
+      if (currentPos === -1) {
+        blobUrlTracker?.delete(blobUrl);
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const node = editor.state.doc.nodeAt(currentPos);
+      if (!node) {
+        blobUrlTracker?.delete(blobUrl);
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
+        ...node.attrs,
+        'data-upload-progress': null,
+        'data-uploading': null,
+        src: url,
+      });
+      editor.view.dispatch(transaction);
+      blobUrlTracker?.delete(blobUrl);
+      URL.revokeObjectURL(blobUrl);
+    })
+    .catch((error: unknown) => {
+      if (editor.isDestroyed) {
+        URL.revokeObjectURL(blobUrl);
+        return;
+      }
+
+      const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
+
+      if (currentPos !== -1) {
+        const transaction = editor.state.tr.delete(
+          currentPos,
+          currentPos + (editor.state.doc.nodeAt(currentPos)?.nodeSize ?? 1),
+        );
+        editor.view.dispatch(transaction);
+      }
+
+      URL.revokeObjectURL(blobUrl);
+      blobUrlTracker?.delete(blobUrl);
+      handleUploadError(error, options);
+    });
+
+  return uploadContext;
+}
+
+function createCustomImage(
+  imageUpload: ImageUploadOptions,
+  blobUrlTracker?: Set<string>,
+) {
+  return Image.extend({
+    addAttributes() {
+      return {
+        ...this.parent?.(),
+        'data-upload-progress': {
+          default: null,
+          parseHTML: (element) => element.dataset.uploadProgress,
+          renderHTML: () => {
+            return {};
+          },
+        },
+        'data-uploading': {
+          default: null,
+          parseHTML: (element) => element.dataset.uploading,
+          renderHTML: () => {
+            return {};
+          },
+        },
+      };
+    },
+
+    addNodeView() {
+      return ({ node }) => {
+        const isUploading = node.attrs['data-uploading'] === 'true';
+
+        if (!isUploading) {
+          return null as any;
+        }
+
+        const wrapper = document.createElement('div');
+        wrapper.className = 'vben-tiptap-upload-wrapper';
+
+        const img = document.createElement('img');
+        img.src = node.attrs.src;
+        img.className = 'vben-tiptap__image';
+        wrapper.append(img);
+
+        const spinner = document.createElement('div');
+        spinner.className = 'vben-tiptap-upload-spinner';
+        wrapper.append(spinner);
+
+        const progressBar = document.createElement('div');
+        progressBar.className = 'vben-tiptap-upload-progress';
+        const progressFill = document.createElement('div');
+        progressFill.className = 'vben-tiptap-upload-progress-fill';
+        progressBar.append(progressFill);
+        wrapper.append(progressBar);
+
+        const progress = node.attrs['data-upload-progress'];
+        if (progress !== null && progress !== undefined && progress > 0) {
+          spinner.style.display = 'none';
+          progressBar.style.display = '';
+          progressFill.style.width = `${progress}%`;
+        } else {
+          spinner.style.display = '';
+          progressBar.style.display = 'none';
+        }
+
+        return {
+          dom: wrapper,
+          update(updatedNode: ProseMirrorNode) {
+            if (updatedNode.attrs['data-uploading'] !== 'true') {
+              return false;
+            }
+
+            if (updatedNode.attrs.src !== img.src) {
+              img.src = updatedNode.attrs.src;
+            }
+
+            const newProgress = updatedNode.attrs['data-upload-progress'];
+            if (
+              newProgress !== null &&
+              newProgress !== undefined &&
+              newProgress > 0
+            ) {
+              spinner.style.display = 'none';
+              progressBar.style.display = '';
+              progressFill.style.width = `${newProgress}%`;
+            } else {
+              spinner.style.display = '';
+              progressBar.style.display = 'none';
+            }
+
+            return true;
+          },
+        } as any;
+      };
+    },
+
+    addCommands() {
+      return {
+        ...this.parent?.(),
+        uploadImage:
+          () =>
+          ({ editor: cmdEditor }: { editor: CoreEditor }) => {
+            const input = document.createElement('input');
+            input.type = 'file';
+            input.accept = imageUpload.accept ?? DEFAULT_ACCEPT;
+            input.style.display = 'none';
+
+            input.addEventListener('change', () => {
+              const file = input.files?.[0];
+              if (!file) return;
+
+              const error = validateFile(file, imageUpload);
+              if (error) {
+                handleUploadError(new Error(error), imageUpload);
+                return;
+              }
+
+              createUploadProcess(cmdEditor, file, imageUpload, blobUrlTracker);
+              input.remove();
+            });
+
+            document.body.append(input);
+            input.click();
+            return true;
+          },
+      };
+    },
+
+    addProseMirrorPlugins() {
+      const editor = this.editor;
+
+      return [
+        new Plugin({
+          key: new PluginKey('imageUploadDrop'),
+          props: {
+            handleDrop: (view: EditorView, event: DragEvent) => {
+              if (!event.dataTransfer?.files.length) 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;
+              }
+
+              const coordinates = view.posAtCoords({
+                left: event.clientX,
+                top: event.clientY,
+              });
+
+              const pos = coordinates?.pos ?? view.state.selection.from;
+
+              createUploadProcess(
+                editor,
+                file,
+                imageUpload,
+                blobUrlTracker,
+                pos,
+              );
+              return true;
+            },
+          },
+        }),
+        new Plugin({
+          key: new PluginKey('imageUploadPaste'),
+          props: {
+            handlePaste: (_view: EditorView, event: ClipboardEvent) => {
+              const items = event.clipboardData?.items;
+              if (!items) return false;
+
+              const imageFiles: File[] = [];
+              for (const item of items) {
+                if (item.type.startsWith('image/')) {
+                  const file = item.getAsFile();
+                  if (file) imageFiles.push(file);
+                }
+              }
+
+              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);
+                return true;
+              }
+
+              createUploadProcess(
+                editor,
+                imageFile,
+                imageUpload,
+                blobUrlTracker,
+              );
+              return true;
+            },
+          },
+        }),
+      ];
+    },
+  });
+}
+
 export function createDefaultTiptapExtensions(
   options: VbenTiptapExtensionOptions = {},
 ): Extensions {
@@ -42,12 +429,22 @@ export function createDefaultTiptapExtensions(
       openOnClick: false,
       protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
     }),
-    Image.configure({
-      allowBase64: true,
-      HTMLAttributes: {
-        class: 'vben-tiptap__image',
-      },
-    }),
+    options.imageUpload
+      ? createCustomImage(
+          options.imageUpload,
+          options._blobUrlTracker,
+        ).configure({
+          allowBase64: true,
+          HTMLAttributes: {
+            class: 'vben-tiptap__image',
+          },
+        })
+      : Image.configure({
+          allowBase64: true,
+          HTMLAttributes: {
+            class: 'vben-tiptap__image',
+          },
+        }),
     Placeholder.configure({
       placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
     }),

+ 62 - 0
packages/effects/plugins/src/tiptap/style.css

@@ -54,3 +54,65 @@
 
   max-width: min(100%, 640px);
 }
+
+/* Image upload states */
+.vben-tiptap-upload-wrapper {
+  position: relative;
+  display: inline-block;
+  max-width: min(100%, 640px);
+  margin: 1rem 0;
+}
+
+.vben-tiptap-upload-wrapper img {
+  display: block;
+  margin: 0;
+  opacity: 0.6;
+}
+
+.vben-tiptap-upload-spinner {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: hsl(var(--card) / 30%);
+  border-radius: 1rem;
+}
+
+.vben-tiptap-upload-spinner::after {
+  display: block;
+  width: 24px;
+  height: 24px;
+  content: '';
+  border: 2px solid hsl(var(--foreground) / 30%);
+  border-top-color: hsl(var(--foreground));
+  border-radius: 50%;
+  animation: vben-tiptap-spin 0.8s linear infinite;
+}
+
+.vben-tiptap-upload-progress {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  height: 4px;
+  overflow: hidden;
+  background-color: hsl(var(--muted));
+  border-radius: 0 0 1rem 1rem;
+}
+
+.vben-tiptap-upload-progress-fill {
+  height: 100%;
+  background-color: hsl(var(--primary));
+  transition: width 0.2s ease;
+}
+
+@keyframes vben-tiptap-spin {
+  from {
+    transform: rotate(0deg);
+  }
+
+  to {
+    transform: rotate(360deg);
+  }
+}

+ 33 - 3
packages/effects/plugins/src/tiptap/tiptap.vue

@@ -2,10 +2,11 @@
 import type {
   TipTapProps,
   ToolbarAction,
+  ToolbarMenuItem,
   VbenTiptapChangeEvent,
 } from './types';
 
-import { computed, onBeforeUnmount, watch } from 'vue';
+import { computed, onBeforeUnmount, reactive, watch } from 'vue';
 
 import { Check, ChevronDown, Eye } from '@vben/icons';
 import { $t } from '@vben/locales';
@@ -25,6 +26,7 @@ import './style.css';
 const props = withDefaults(defineProps<TipTapProps>(), {
   editable: true,
   extensions: undefined,
+  imageUpload: undefined,
   minHeight: 240,
   placeholder: $t('ui.tiptap.placeholder'),
   previewable: true,
@@ -43,6 +45,7 @@ const tiptapContentClass = cn(
   'vben-tiptap-content vben-tiptap__content',
   'text-foreground min-h-(--vben-tiptap-min-height) leading-7 outline-none',
 );
+const blobUrlTracker = new Set<string>();
 const editor = useEditor({
   content: modelValue.value,
   editable: props.editable,
@@ -54,6 +57,8 @@ const editor = useEditor({
   extensions:
     props.extensions ??
     createDefaultTiptapExtensions({
+      _blobUrlTracker: blobUrlTracker,
+      imageUpload: props.imageUpload,
       placeholder: props.placeholder,
     }),
   onUpdate: ({ editor }) => {
@@ -69,7 +74,10 @@ const editor = useEditor({
   },
 });
 const toolbarGroups = computed<ToolbarAction[][]>(() => {
-  return createToolbarGroups();
+  // 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,
@@ -95,6 +103,22 @@ const {
   editable: () => props.editable,
   editor,
 });
+
+const menuOpenState = reactive<Record<string, boolean>>({});
+
+function getMenuOpen(action: ToolbarAction): boolean {
+  return menuOpenState[action.label] ?? false;
+}
+
+function setMenuOpen(action: ToolbarAction, open: boolean) {
+  menuOpenState[action.label] = open;
+}
+
+function handleMenuItemClick(action: ToolbarAction, item: ToolbarMenuItem) {
+  runMenuItem(item);
+  setMenuOpen(action, false);
+}
+
 function openPreviewModal() {
   previewModalApi.open();
 }
@@ -120,6 +144,10 @@ watch(
   },
 );
 onBeforeUnmount(() => {
+  for (const url of blobUrlTracker) {
+    URL.revokeObjectURL(url);
+  }
+  blobUrlTracker.clear();
   editor.value?.destroy();
 });
 </script>
@@ -141,8 +169,10 @@ onBeforeUnmount(() => {
         <template v-for="action in group" :key="action.label">
           <VbenPopover
             v-if="action.menu || action.palette"
+            :open="action.menu ? getMenuOpen(action) : undefined"
             :content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
             content-class="w-auto p-2"
+            @update:open="action.menu ? setMenuOpen(action, $event) : undefined"
           >
             <template #trigger>
               <VbenIconButton
@@ -209,7 +239,7 @@ onBeforeUnmount(() => {
                 :class="getMenuItemClass(item)"
                 :disabled="!canRunMenuItem(item)"
                 type="button"
-                @click="runMenuItem(item)"
+                @click="handleMenuItemClick(action, item)"
               >
                 <span class="w-7 text-xs font-semibold tracking-wide">
                   {{ item.shortLabel }}

+ 31 - 2
packages/effects/plugins/src/tiptap/toolbar.ts

@@ -1,6 +1,10 @@
 import type { Editor } from '@tiptap/vue-3';
 
-import type { ToolbarAction, ToolbarMenuItem } from './types';
+import type {
+  ImageUploadOptions,
+  ToolbarAction,
+  ToolbarMenuItem,
+} from './types';
 
 import {
   AlignCenter,
@@ -155,7 +159,9 @@ async function handleImageAction(editor: Editor) {
   editor.chain().focus().setImage({ src: nextUrl }).run();
 }
 
-export function createToolbarGroups(): ToolbarAction[][] {
+export function createToolbarGroups(
+  imageUpload?: ImageUploadOptions,
+): ToolbarAction[][] {
   const headingMenuItems = createHeadingMenuItems();
 
   return [
@@ -278,6 +284,29 @@ export function createToolbarGroups(): ToolbarAction[][] {
         action: (editor) => handleImageAction(editor),
         icon: ImagePlus,
         label: $t('ui.tiptap.toolbar.image'),
+        ...(imageUpload
+          ? {
+              action: () => {},
+              menu: {
+                items: [
+                  {
+                    action: (editor) => {
+                      if (typeof editor.commands.uploadImage === 'function') {
+                        editor.commands.uploadImage();
+                      }
+                    },
+                    label: $t('ui.tiptap.toolbar.imageUpload'),
+                    shortLabel: 'UPL',
+                  },
+                  {
+                    action: (editor) => handleImageAction(editor),
+                    label: $t('ui.tiptap.toolbar.imageUrl'),
+                    shortLabel: 'URL',
+                  },
+                ],
+              },
+            }
+          : {}),
       },
     ],
     [

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

@@ -3,9 +3,32 @@ 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;
+  /** 最大文件大小(字节),默认 5MB */
+  maxSize?: number;
+  /** 上传失败回调,未提供时使用 alert 弹窗提示 */
+  onUploadError?: (error: unknown) => void;
+  /** 上传函数,返回图片 URL,可选 onProgress 回调报告上传进度 */
+  upload: (
+    file: File,
+    onProgress?: (percent: number) => void,
+  ) => Promise<string>;
+}
+
 export interface TipTapProps {
   editable?: boolean;
   extensions?: Extensions;
+  imageUpload?: ImageUploadOptions;
   minHeight?: number | string;
   placeholder?: string;
   previewable?: boolean;
@@ -25,6 +48,9 @@ export interface VbenTiptapChangeEvent {
 }
 
 export interface VbenTiptapExtensionOptions {
+  imageUpload?: ImageUploadOptions;
+  /** 内部使用:追踪 blob URL 以便组件销毁时清理 */
+  _blobUrlTracker?: Set<string>;
   placeholder?: string;
 }
 

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

@@ -86,6 +86,8 @@
       "link": "Link",
       "unlink": "Unlink",
       "image": "Image",
+      "imageUrl": "Image URL",
+      "imageUpload": "Upload Image",
       "textColor": "Text Color",
       "highlightColor": "Highlight Color",
       "alignLeft": "Left",
@@ -95,6 +97,12 @@
       "undo": "Undo",
       "redo": "Redo",
       "clear": "Clear"
+    },
+    "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"
     }
   },
   "fallback": {

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

@@ -86,6 +86,8 @@
       "link": "链接",
       "unlink": "移除链接",
       "image": "图片",
+      "imageUrl": "图片URL",
+      "imageUpload": "从本地上传",
       "textColor": "文字颜色",
       "highlightColor": "高亮颜色",
       "alignLeft": "左对齐",
@@ -95,6 +97,12 @@
       "undo": "撤销",
       "redo": "重做",
       "clear": "清除"
+    },
+    "upload": {
+      "fileTooLarge": "文件大小超出限制",
+      "fileTypeNotAllowed": "不支持的文件类型",
+      "onlySingleImage": "仅支持单张图片上传,已选取第一张",
+      "uploadFailed": "上传失败"
     }
   },
   "fallback": {

+ 40 - 2
playground/src/views/examples/tiptap/index.vue

@@ -1,10 +1,12 @@
 <script lang="ts" setup>
+import type { ImageUploadOptions } from '@vben/plugins/tiptap';
+
 import { computed, ref } from 'vue';
 
 import { Page } from '@vben/common-ui';
 import { VbenTiptap, VbenTiptapPreview } from '@vben/plugins/tiptap';
 
-import { Card } from 'ant-design-vue';
+import { Card, Switch } from 'ant-design-vue';
 const content = ref(`
   <h1>Vben Tiptap</h1>
   <p>这个编辑器已经被封装在 <code>packages/effects/plugins/src/tiptap</code> 中。</p>
@@ -12,6 +14,35 @@ const content = ref(`
   <blockquote>默认内置 StarterKit、Underline、TextAlign、Placeholder。</blockquote>
 `);
 const previewContent = computed(() => content.value);
+
+const enableUpload = ref(true);
+
+// Mock upload: 模拟上传延迟,支持进度回调
+const imageUpload: ImageUploadOptions = {
+  accept: 'image/*',
+  maxSize: 5 * 1024 * 1024, // 5MB
+  upload: (file, onProgress) =>
+    new Promise((resolve) => {
+      let progress = 0;
+      const interval = setInterval(() => {
+        progress += Math.random() * 30;
+        if (progress >= 100) {
+          progress = 100;
+          clearInterval(interval);
+        }
+        onProgress?.(Math.round(progress));
+        if (progress >= 100) {
+          // 上传完成后返回 mock URL
+          resolve(
+            `https://picsum.photos/seed/${Date.now()}/640/${Math.round((640 * ((file.size % 3) + 2)) / 4)}`,
+          );
+        }
+      }, 300);
+    }),
+  onUploadError: (error) => {
+    console.error('Image upload failed:', error);
+  },
+};
 </script>
 
 <template>
@@ -23,7 +54,14 @@ const previewContent = computed(() => content.value);
     </template>
 
     <Card class="mb-5" title="编辑器">
-      <VbenTiptap v-model="content" />
+      <div class="mb-3 flex items-center gap-3">
+        <span class="text-sm">启用图片上传:</span>
+        <Switch v-model:checked="enableUpload" />
+      </div>
+      <VbenTiptap
+        v-model="content"
+        :image-upload="enableUpload ? imageUpload : undefined"
+      />
     </Card>
 
     <Card class="mb-5" title="富文本预览">

Datei-Diff unterdrückt, da er zu groß ist
+ 171 - 168
pnpm-lock.yaml


+ 1 - 0
pnpm-workspace.yaml

@@ -56,6 +56,7 @@ catalog:
   '@tiptap/extension-text-align': ^3.22.3
   '@tiptap/extension-text-style': ^3.22.3
   '@tiptap/extension-underline': ^3.22.3
+  '@tiptap/pm': ^3.22.3
   '@tiptap/starter-kit': ^3.22.3
   '@tiptap/vue-3': ^3.22.3
   '@tsdown/css': ^0.21.8

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.