diff --git a/packages/effects/plugins/package.json b/packages/effects/plugins/package.json index 9aa6be094..6f4e71f5a 100644 --- a/packages/effects/plugins/package.json +++ b/packages/effects/plugins/package.json @@ -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:*", diff --git a/packages/effects/plugins/src/tiptap/extensions.ts b/packages/effects/plugins/src/tiptap/extensions.ts index 3574c23da..ceea4c804 100644 --- a/packages/effects/plugins/src/tiptap/extensions.ts +++ b/packages/effects/plugins/src/tiptap/extensions.ts @@ -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,390 @@ 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, + 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, +) { + 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 { @@ -42,12 +429,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'), }), diff --git a/packages/effects/plugins/src/tiptap/style.css b/packages/effects/plugins/src/tiptap/style.css index 875556cec..2d8052c1d 100644 --- a/packages/effects/plugins/src/tiptap/style.css +++ b/packages/effects/plugins/src/tiptap/style.css @@ -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); + } +} diff --git a/packages/effects/plugins/src/tiptap/tiptap.vue b/packages/effects/plugins/src/tiptap/tiptap.vue index c5ac005df..6dd99d3d8 100644 --- a/packages/effects/plugins/src/tiptap/tiptap.vue +++ b/packages/effects/plugins/src/tiptap/tiptap.vue @@ -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(), { 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(); 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,10 @@ const editor = useEditor({ }, }); const toolbarGroups = computed(() => { - return createToolbarGroups(); + // 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, @@ -95,6 +103,22 @@ const { editable: () => props.editable, editor, }); + +const menuOpenState = reactive>({}); + +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 +144,10 @@ watch( }, ); onBeforeUnmount(() => { + for (const url of blobUrlTracker) { + URL.revokeObjectURL(url); + } + blobUrlTracker.clear(); editor.value?.destroy(); }); @@ -141,8 +169,10 @@ onBeforeUnmount(() => {