xiangning 86a38bdfc8
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
init: RAG 前端项目(基于 Vben Admin web-antd)
- 基于 Vben Admin 5.7.0,仅保留 web-antd 版本
- 删除多余 UI 版本(antdv-next/ele/naive/tdesign)
- 删除 backend-mock(已对接真实 .NET 后端)
- 删除 playground 和 docs
- 添加 Jenkinsfile CI/CD
2026-05-11 02:30:30 +08:00

453 lines
13 KiB
TypeScript

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 { 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';
import Link from '@tiptap/extension-link';
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(() => {});
}
}
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;
}
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();
const nodePos = findPlaceholderPos(editor.state.doc, blobUrl);
const uploadContext: UploadContext = { blobUrl, pos: nodePos };
options
.upload(file, (percent: number) => {
if (editor.isDestroyed) return;
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
if (currentPos === -1) return;
const node = editor.state.doc.nodeAt(currentPos);
if (!node) return;
const transaction = editor.state.tr.setNodeMarkup(currentPos, undefined, {
...node.attrs,
'data-upload-progress': percent,
});
editor.view.dispatch(transaction);
})
.then((url: string) => {
if (editor.isDestroyed) {
URL.revokeObjectURL(blobUrl);
return;
}
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
if (currentPos === -1) {
blobUrlTracker?.delete(blobUrl);
URL.revokeObjectURL(blobUrl);
return;
}
const node = editor.state.doc.nodeAt(currentPos);
if (!node) {
blobUrlTracker?.delete(blobUrl);
URL.revokeObjectURL(blobUrl);
return;
}
const transaction = editor.state.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) => {
if (editor.isDestroyed) {
URL.revokeObjectURL(blobUrl);
return;
}
const currentPos = findPlaceholderPos(editor.state.doc, blobUrl);
if (currentPos !== -1) {
const transaction = editor.state.tr.delete(
currentPos,
currentPos + (editor.state.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: () => {
return {};
},
},
'data-uploading': {
default: null,
parseHTML: (element) => element.dataset.uploading,
renderHTML: () => {
return {};
},
},
};
},
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();
return true;
},
};
},
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 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;
}
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;
const imageFiles: File[] = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
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);
return true;
}
createUploadProcess(
editor,
imageFile,
imageUpload,
blobUrlTracker,
);
return true;
},
},
}),
];
},
});
}
export function createDefaultTiptapExtensions(
options: VbenTiptapExtensionOptions = {},
): Extensions {
return [
Document,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
TextStyle,
Color.configure({
types: ['textStyle'],
}),
Highlight.configure({
multicolor: true,
}),
Link.configure({
autolink: true,
defaultProtocol: 'https',
enableClickSelection: true,
openOnClick: false,
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
}),
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'),
}),
];
}