mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-13 06:42:10 +08:00
feat(@vben/plugins): tiptap 支持图片上传功能
- 新增 imageUpload 配置项,支持自定义上传接口 - 支持文件选择、拖拽、粘贴三种上传方式 - 上传中显示 blob 预览图 + loading spinner / 进度条 - 支持 accept 和 maxSize 文件校验 - 支持 onUploadError 自定义错误处理 - 未配置 imageUpload 时保持原有 URL 插入行为不变 - 使用 NodeView 实现实时 DOM 控制的进度展示
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
"@tiptap/extension-text-align": "catalog:",
|
"@tiptap/extension-text-align": "catalog:",
|
||||||
"@tiptap/extension-text-style": "catalog:",
|
"@tiptap/extension-text-style": "catalog:",
|
||||||
"@tiptap/extension-underline": "catalog:",
|
"@tiptap/extension-underline": "catalog:",
|
||||||
|
"@tiptap/pm": "catalog:",
|
||||||
"@tiptap/starter-kit": "catalog:",
|
"@tiptap/starter-kit": "catalog:",
|
||||||
"@tiptap/vue-3": "catalog:",
|
"@tiptap/vue-3": "catalog:",
|
||||||
"@vben-core/design": "workspace:*",
|
"@vben-core/design": "workspace:*",
|
||||||
|
|||||||
@@ -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 { Extensions } from '@tiptap/vue-3';
|
||||||
|
|
||||||
import type { VbenTiptapExtensionOptions } from './types';
|
import type { ImageUploadOptions, VbenTiptapExtensionOptions } from './types';
|
||||||
|
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { alert } from '@vben-core/popup-ui';
|
||||||
|
|
||||||
import Document from '@tiptap/extension-document';
|
import Document from '@tiptap/extension-document';
|
||||||
import Highlight from '@tiptap/extension-highlight';
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
@@ -12,8 +17,398 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|||||||
import TextAlign from '@tiptap/extension-text-align';
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
import { Color, TextStyle } from '@tiptap/extension-text-style';
|
import { Color, TextStyle } from '@tiptap/extension-text-style';
|
||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
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(
|
export function createDefaultTiptapExtensions(
|
||||||
options: VbenTiptapExtensionOptions = {},
|
options: VbenTiptapExtensionOptions = {},
|
||||||
): Extensions {
|
): Extensions {
|
||||||
@@ -42,12 +437,22 @@ export function createDefaultTiptapExtensions(
|
|||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
|
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
|
||||||
}),
|
}),
|
||||||
Image.configure({
|
options.imageUpload
|
||||||
allowBase64: true,
|
? createCustomImage(
|
||||||
HTMLAttributes: {
|
options.imageUpload,
|
||||||
class: 'vben-tiptap__image',
|
options._blobUrlTracker,
|
||||||
},
|
).configure({
|
||||||
}),
|
allowBase64: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'vben-tiptap__image',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Image.configure({
|
||||||
|
allowBase64: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'vben-tiptap__image',
|
||||||
|
},
|
||||||
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
|
placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -54,3 +54,65 @@
|
|||||||
|
|
||||||
max-width: min(100%, 640px);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import type {
|
import type {
|
||||||
TipTapProps,
|
TipTapProps,
|
||||||
ToolbarAction,
|
ToolbarAction,
|
||||||
|
ToolbarMenuItem,
|
||||||
VbenTiptapChangeEvent,
|
VbenTiptapChangeEvent,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, watch } from 'vue';
|
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||||
|
|
||||||
import { Check, ChevronDown, Eye } from '@vben/icons';
|
import { Check, ChevronDown, Eye } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
@@ -25,6 +26,7 @@ import './style.css';
|
|||||||
const props = withDefaults(defineProps<TipTapProps>(), {
|
const props = withDefaults(defineProps<TipTapProps>(), {
|
||||||
editable: true,
|
editable: true,
|
||||||
extensions: undefined,
|
extensions: undefined,
|
||||||
|
imageUpload: undefined,
|
||||||
minHeight: 240,
|
minHeight: 240,
|
||||||
placeholder: $t('ui.tiptap.placeholder'),
|
placeholder: $t('ui.tiptap.placeholder'),
|
||||||
previewable: true,
|
previewable: true,
|
||||||
@@ -43,6 +45,7 @@ const tiptapContentClass = cn(
|
|||||||
'vben-tiptap-content vben-tiptap__content',
|
'vben-tiptap-content vben-tiptap__content',
|
||||||
'text-foreground min-h-(--vben-tiptap-min-height) leading-7 outline-none',
|
'text-foreground min-h-(--vben-tiptap-min-height) leading-7 outline-none',
|
||||||
);
|
);
|
||||||
|
const blobUrlTracker = new Set<string>();
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
content: modelValue.value,
|
content: modelValue.value,
|
||||||
editable: props.editable,
|
editable: props.editable,
|
||||||
@@ -54,6 +57,8 @@ const editor = useEditor({
|
|||||||
extensions:
|
extensions:
|
||||||
props.extensions ??
|
props.extensions ??
|
||||||
createDefaultTiptapExtensions({
|
createDefaultTiptapExtensions({
|
||||||
|
_blobUrlTracker: blobUrlTracker,
|
||||||
|
imageUpload: props.imageUpload,
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
}),
|
}),
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
@@ -69,7 +74,7 @@ const editor = useEditor({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const toolbarGroups = computed<ToolbarAction[][]>(() => {
|
const toolbarGroups = computed<ToolbarAction[][]>(() => {
|
||||||
return createToolbarGroups();
|
return createToolbarGroups(props.imageUpload);
|
||||||
});
|
});
|
||||||
const previewContent = computed(
|
const previewContent = computed(
|
||||||
() => editor.value?.getHTML() ?? modelValue.value,
|
() => editor.value?.getHTML() ?? modelValue.value,
|
||||||
@@ -95,6 +100,22 @@ const {
|
|||||||
editable: () => props.editable,
|
editable: () => props.editable,
|
||||||
editor,
|
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() {
|
function openPreviewModal() {
|
||||||
previewModalApi.open();
|
previewModalApi.open();
|
||||||
}
|
}
|
||||||
@@ -120,6 +141,10 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
for (const url of blobUrlTracker) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
blobUrlTracker.clear();
|
||||||
editor.value?.destroy();
|
editor.value?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -141,8 +166,10 @@ onBeforeUnmount(() => {
|
|||||||
<template v-for="action in group" :key="action.label">
|
<template v-for="action in group" :key="action.label">
|
||||||
<VbenPopover
|
<VbenPopover
|
||||||
v-if="action.menu || action.palette"
|
v-if="action.menu || action.palette"
|
||||||
|
:open="action.menu ? getMenuOpen(action) : undefined"
|
||||||
:content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
|
:content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
|
||||||
content-class="w-auto p-2"
|
content-class="w-auto p-2"
|
||||||
|
@update:open="action.menu ? setMenuOpen(action, $event) : undefined"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<VbenIconButton
|
<VbenIconButton
|
||||||
@@ -209,7 +236,7 @@ onBeforeUnmount(() => {
|
|||||||
:class="getMenuItemClass(item)"
|
:class="getMenuItemClass(item)"
|
||||||
:disabled="!canRunMenuItem(item)"
|
:disabled="!canRunMenuItem(item)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="runMenuItem(item)"
|
@click="handleMenuItemClick(action, item)"
|
||||||
>
|
>
|
||||||
<span class="w-7 text-xs font-semibold tracking-wide">
|
<span class="w-7 text-xs font-semibold tracking-wide">
|
||||||
{{ item.shortLabel }}
|
{{ item.shortLabel }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Editor } from '@tiptap/vue-3';
|
import type { Editor } from '@tiptap/vue-3';
|
||||||
|
|
||||||
import type { ToolbarAction, ToolbarMenuItem } from './types';
|
import type { ImageUploadOptions, ToolbarAction, ToolbarMenuItem } from './types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
@@ -155,7 +155,9 @@ async function handleImageAction(editor: Editor) {
|
|||||||
editor.chain().focus().setImage({ src: nextUrl }).run();
|
editor.chain().focus().setImage({ src: nextUrl }).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolbarGroups(): ToolbarAction[][] {
|
export function createToolbarGroups(
|
||||||
|
imageUpload?: ImageUploadOptions,
|
||||||
|
): ToolbarAction[][] {
|
||||||
const headingMenuItems = createHeadingMenuItems();
|
const headingMenuItems = createHeadingMenuItems();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -278,6 +280,25 @@ export function createToolbarGroups(): ToolbarAction[][] {
|
|||||||
action: (editor) => handleImageAction(editor),
|
action: (editor) => handleImageAction(editor),
|
||||||
icon: ImagePlus,
|
icon: ImagePlus,
|
||||||
label: $t('ui.tiptap.toolbar.image'),
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ import type { Editor } from '@tiptap/vue-3';
|
|||||||
|
|
||||||
import type { Component } from 'vue';
|
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 {
|
export interface TipTapProps {
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
extensions?: Extensions;
|
extensions?: Extensions;
|
||||||
|
imageUpload?: ImageUploadOptions;
|
||||||
minHeight?: number | string;
|
minHeight?: number | string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
previewable?: boolean;
|
previewable?: boolean;
|
||||||
@@ -25,6 +37,9 @@ export interface VbenTiptapChangeEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VbenTiptapExtensionOptions {
|
export interface VbenTiptapExtensionOptions {
|
||||||
|
imageUpload?: ImageUploadOptions;
|
||||||
|
/** 内部使用:追踪 blob URL 以便组件销毁时清理 */
|
||||||
|
_blobUrlTracker?: Set<string>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@
|
|||||||
"link": "Link",
|
"link": "Link",
|
||||||
"unlink": "Unlink",
|
"unlink": "Unlink",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
|
"imageUrl": "Image URL",
|
||||||
|
"imageUpload": "Upload Image",
|
||||||
"textColor": "Text Color",
|
"textColor": "Text Color",
|
||||||
"highlightColor": "Highlight Color",
|
"highlightColor": "Highlight Color",
|
||||||
"alignLeft": "Left",
|
"alignLeft": "Left",
|
||||||
@@ -95,6 +97,11 @@
|
|||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"clear": "Clear"
|
"clear": "Clear"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"fileTooLarge": "File size exceeds the limit",
|
||||||
|
"fileTypeNotAllowed": "File type is not allowed",
|
||||||
|
"uploadFailed": "Upload Failed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@
|
|||||||
"link": "链接",
|
"link": "链接",
|
||||||
"unlink": "移除链接",
|
"unlink": "移除链接",
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
|
"imageUrl": "图片URL",
|
||||||
|
"imageUpload": "从本地上传",
|
||||||
"textColor": "文字颜色",
|
"textColor": "文字颜色",
|
||||||
"highlightColor": "高亮颜色",
|
"highlightColor": "高亮颜色",
|
||||||
"alignLeft": "左对齐",
|
"alignLeft": "左对齐",
|
||||||
@@ -95,6 +97,11 @@
|
|||||||
"undo": "撤销",
|
"undo": "撤销",
|
||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"clear": "清除"
|
"clear": "清除"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"fileTooLarge": "文件大小超出限制",
|
||||||
|
"fileTypeNotAllowed": "不支持的文件类型",
|
||||||
|
"uploadFailed": "上传失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
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(`
|
const content = ref(`
|
||||||
<h1>Vben Tiptap</h1>
|
<h1>Vben Tiptap</h1>
|
||||||
<p>这个编辑器已经被封装在 <code>packages/effects/plugins/src/tiptap</code> 中。</p>
|
<p>这个编辑器已经被封装在 <code>packages/effects/plugins/src/tiptap</code> 中。</p>
|
||||||
@@ -12,6 +16,35 @@ const content = ref(`
|
|||||||
<blockquote>默认内置 StarterKit、Underline、TextAlign、Placeholder。</blockquote>
|
<blockquote>默认内置 StarterKit、Underline、TextAlign、Placeholder。</blockquote>
|
||||||
`);
|
`);
|
||||||
const previewContent = computed(() => content.value);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,7 +56,14 @@ const previewContent = computed(() => content.value);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Card class="mb-5" title="编辑器">
|
<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>
|
||||||
|
|
||||||
<Card class="mb-5" title="富文本预览">
|
<Card class="mb-5" title="富文本预览">
|
||||||
|
|||||||
4519
pnpm-lock.yaml
generated
4519
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ catalog:
|
|||||||
'@tiptap/extension-text-align': ^3.22.3
|
'@tiptap/extension-text-align': ^3.22.3
|
||||||
'@tiptap/extension-text-style': ^3.22.3
|
'@tiptap/extension-text-style': ^3.22.3
|
||||||
'@tiptap/extension-underline': ^3.22.3
|
'@tiptap/extension-underline': ^3.22.3
|
||||||
|
'@tiptap/pm': ^3.22.3
|
||||||
'@tiptap/starter-kit': ^3.22.3
|
'@tiptap/starter-kit': ^3.22.3
|
||||||
'@tiptap/vue-3': ^3.22.3
|
'@tiptap/vue-3': ^3.22.3
|
||||||
'@tsdown/css': ^0.21.8
|
'@tsdown/css': ^0.21.8
|
||||||
|
|||||||
Reference in New Issue
Block a user