From 244c0a5884b7613bb2c3b29dccfac8e99d517ac5 Mon Sep 17 00:00:00 2001 From: "yuan.ji" <961999367@qq.com> Date: Mon, 27 Apr 2026 14:33:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(@vben/plugins):=20=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20tiptap=20=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取 findPlaceholderPos 辅助函数,消除重复的 descendants 查找 - 添加 editor.isDestroyed 守卫,防止操作已销毁编辑器 - renderHTML 不输出上传状态属性,防止 blob URL 泄露到序列化 HTML - uploadImage 命令返回 boolean,添加 Commands 类型增强,移除 as any - 拖拽/粘贴多图时仅处理第一张并提示仅支持单图上传 - 自定义 extensions 时不传 imageUpload 给工具栏,toolbar action 加运行时守卫 --- .../effects/plugins/src/tiptap/extensions.ts | 146 +++++++++--------- .../effects/plugins/src/tiptap/tiptap.vue | 5 +- .../effects/plugins/src/tiptap/toolbar.ts | 6 +- packages/effects/plugins/src/tiptap/types.ts | 8 + packages/locales/src/langs/en-US/ui.json | 1 + packages/locales/src/langs/zh-CN/ui.json | 1 + 6 files changed, 88 insertions(+), 79 deletions(-) diff --git a/packages/effects/plugins/src/tiptap/extensions.ts b/packages/effects/plugins/src/tiptap/extensions.ts index 397bce410..ceea4c804 100644 --- a/packages/effects/plugins/src/tiptap/extensions.ts +++ b/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); diff --git a/packages/effects/plugins/src/tiptap/tiptap.vue b/packages/effects/plugins/src/tiptap/tiptap.vue index 465dc772f..6dd99d3d8 100644 --- a/packages/effects/plugins/src/tiptap/tiptap.vue +++ b/packages/effects/plugins/src/tiptap/tiptap.vue @@ -74,7 +74,10 @@ const editor = useEditor({ }, }); const toolbarGroups = computed(() => { - 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, diff --git a/packages/effects/plugins/src/tiptap/toolbar.ts b/packages/effects/plugins/src/tiptap/toolbar.ts index c4aa2f439..2bd0eb770 100644 --- a/packages/effects/plugins/src/tiptap/toolbar.ts +++ b/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', }, diff --git a/packages/effects/plugins/src/tiptap/types.ts b/packages/effects/plugins/src/tiptap/types.ts index 3546ab2e4..ea84d981d 100644 --- a/packages/effects/plugins/src/tiptap/types.ts +++ b/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 { + imageUpload: { + uploadImage: () => ReturnType; + }; + } +} + export interface ImageUploadOptions { /** 允许的文件类型,默认 'image/*' */ accept?: string; diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index 216870621..25ef7a78d 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/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" } }, diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index a36a55707..cf718b941 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -101,6 +101,7 @@ "upload": { "fileTooLarge": "文件大小超出限制", "fileTypeNotAllowed": "不支持的文件类型", + "onlySingleImage": "仅支持单张图片上传,已选取第一张", "uploadFailed": "上传失败" } },