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

- 提取 findPlaceholderPos 辅助函数,消除重复的 descendants 查找
- 添加 editor.isDestroyed 守卫,防止操作已销毁编辑器
- renderHTML 不输出上传状态属性,防止 blob URL 泄露到序列化 HTML
- uploadImage 命令返回 boolean,添加 Commands 类型增强,移除 as any
- 拖拽/粘贴多图时仅处理第一张并提示仅支持单图上传
- 自定义 extensions 时不传 imageUpload 给工具栏,toolbar action 加运行时守卫
This commit is contained in:
yuan.ji
2026-04-27 14:33:30 +08:00
parent c6fd599784
commit 244c0a5884
6 changed files with 88 additions and 79 deletions

View File

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

View File

@@ -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,

View File

@@ -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',
},

View File

@@ -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;

View File

@@ -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"
}
},

View File

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