feat(@vben/plugins): tiptap 支持图片上传功能

- 新增 imageUpload 配置项,支持自定义上传接口
- 支持文件选择、拖拽、粘贴三种上传方式
- 上传中显示 blob 预览图 + loading spinner / 进度条
- 支持 accept 和 maxSize 文件校验
- 支持 onUploadError 自定义错误处理
- 未配置 imageUpload 时保持原有 URL 插入行为不变
- 使用 NodeView 实现实时 DOM 控制的进度展示
This commit is contained in:
yuan.ji
2026-04-27 13:42:36 +08:00
parent 36d7dc23fa
commit 4ca2f1c6e8
11 changed files with 2727 additions and 2408 deletions

View File

@@ -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:*",

View File

@@ -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,398 @@ 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(() => {});
}
}
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();
// 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 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 (currentPos === -1) return;
const node = currentState.doc.nodeAt(currentPos);
if (!node) return;
const transaction = currentState.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 (currentPos === -1) {
blobUrlTracker?.delete(blobUrl);
URL.revokeObjectURL(blobUrl);
return;
}
const node = currentState.doc.nodeAt(currentPos);
if (!node) {
blobUrlTracker?.delete(blobUrl);
URL.revokeObjectURL(blobUrl);
return;
}
const transaction = currentState.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) => {
// 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 (currentPos !== -1) {
const transaction = currentState.tr.delete(
currentPos,
currentPos + (currentState.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: (attributes) => {
if (
attributes['data-upload-progress'] === null ||
attributes['data-upload-progress'] === undefined
)
return {};
return {
'data-upload-progress': attributes['data-upload-progress'],
};
},
},
'data-uploading': {
default: null,
parseHTML: (element) => element.dataset.uploading,
renderHTML: (attributes) => {
if (!attributes['data-uploading']) return {};
return { 'data-uploading': attributes['data-uploading'] };
},
},
};
},
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();
},
};
},
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 file = event.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) return false;
event.preventDefault();
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,
});
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;
let imageFile: File | undefined;
for (const item of items) {
if (item.type.startsWith('image/')) {
imageFile = item.getAsFile() ?? undefined;
break;
}
}
if (!imageFile) return false;
event.preventDefault();
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 +437,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'),
}),

View File

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

View File

@@ -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,7 @@ const editor = useEditor({
},
});
const toolbarGroups = computed<ToolbarAction[][]>(() => {
return createToolbarGroups();
return createToolbarGroups(props.imageUpload);
});
const previewContent = computed(
() => editor.value?.getHTML() ?? modelValue.value,
@@ -95,6 +100,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 +141,10 @@ watch(
},
);
onBeforeUnmount(() => {
for (const url of blobUrlTracker) {
URL.revokeObjectURL(url);
}
blobUrlTracker.clear();
editor.value?.destroy();
});
</script>
@@ -141,8 +166,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 +236,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 }}

View File

@@ -1,6 +1,6 @@
import type { Editor } from '@tiptap/vue-3';
import type { ToolbarAction, ToolbarMenuItem } from './types';
import type { ImageUploadOptions, ToolbarAction, ToolbarMenuItem } from './types';
import {
AlignCenter,
@@ -155,7 +155,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 +280,25 @@ export function createToolbarGroups(): ToolbarAction[][] {
action: (editor) => handleImageAction(editor),
icon: ImagePlus,
label: $t('ui.tiptap.toolbar.image'),
...(imageUpload
? {
action: () => {},
menu: {
items: [
{
action: (editor) => (editor.commands as any).uploadImage(),
label: $t('ui.tiptap.toolbar.imageUpload'),
shortLabel: 'UPL',
},
{
action: (editor) => handleImageAction(editor),
label: $t('ui.tiptap.toolbar.imageUrl'),
shortLabel: 'URL',
},
],
},
}
: {}),
},
],
[

View File

@@ -3,9 +3,21 @@ import type { Editor } from '@tiptap/vue-3';
import type { Component } from 'vue';
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 +37,9 @@ export interface VbenTiptapChangeEvent {
}
export interface VbenTiptapExtensionOptions {
imageUpload?: ImageUploadOptions;
/** 内部使用:追踪 blob URL 以便组件销毁时清理 */
_blobUrlTracker?: Set<string>;
placeholder?: string;
}

View File

@@ -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,11 @@
"undo": "Undo",
"redo": "Redo",
"clear": "Clear"
},
"upload": {
"fileTooLarge": "File size exceeds the limit",
"fileTypeNotAllowed": "File type is not allowed",
"uploadFailed": "Upload Failed"
}
},
"fallback": {

View File

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

View File

@@ -2,9 +2,13 @@
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { VbenTiptap, VbenTiptapPreview } from '@vben/plugins/tiptap';
import {
type ImageUploadOptions,
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 +16,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 +56,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="富文本预览">

4519
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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