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

View File

@@ -74,7 +74,10 @@ const editor = useEditor({
}, },
}); });
const toolbarGroups = computed<ToolbarAction[][]>(() => { 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( const previewContent = computed(
() => editor.value?.getHTML() ?? modelValue.value, () => editor.value?.getHTML() ?? modelValue.value,

View File

@@ -290,7 +290,11 @@ export function createToolbarGroups(
menu: { menu: {
items: [ 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'), label: $t('ui.tiptap.toolbar.imageUpload'),
shortLabel: 'UPL', shortLabel: 'UPL',
}, },

View File

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

View File

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

View File

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