From 5907c04e00ea022bdf2b0091cc4ad20fb83dbe7b Mon Sep 17 00:00:00 2001 From: AxiosLeo <13862149+AxiosLeo@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:08:40 +0800 Subject: [PATCH 01/73] fix: update selector for active menu item in useMenuScroll hook (#7829) --- packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts b/packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts index 270a0348d..e18bae755 100644 --- a/packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts +++ b/packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts @@ -20,7 +20,7 @@ export function useMenuScroll( if (!isEnabled) return; const activeElement = document.querySelector( - `aside li[role=menuitem].is-active`, + `aside a[role=menuitem].is-active`, ); if (activeElement) { activeElement.scrollIntoView({ From 3a83fb0c3e0ef233cff6288c5b4f760a70663e08 Mon Sep 17 00:00:00 2001 From: AxiosLeo <13862149+AxiosLeo@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:09:09 +0800 Subject: [PATCH 02/73] fix: ensure trigger function is awaited in useDependencies (#7830) --- packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts index 505bc9eef..8eb656eac 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts +++ b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts @@ -154,7 +154,7 @@ export default function useDependencies( } if (isFunction(trigger)) { - trigger(formValues, formApi, getController()); + await trigger(formValues, formApi, getController()); } }, { deep: true, immediate: true }, From c7fd6ffb0e6f4092cd69189450767fca7fcb4f2f Mon Sep 17 00:00:00 2001 From: boisduval Date: Thu, 23 Apr 2026 17:25:10 +0800 Subject: [PATCH 03/73] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=87=BD=E6=95=B0=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E5=80=BC=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当传入的时间参数为 undefined、null 或空字符串时,直接返回空字符串而不是继续执行格式化逻辑,避免无效的时间值导致程序错误。 --- packages/@core/base/shared/src/utils/date.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/@core/base/shared/src/utils/date.ts b/packages/@core/base/shared/src/utils/date.ts index c68c33850..784b9778e 100644 --- a/packages/@core/base/shared/src/utils/date.ts +++ b/packages/@core/base/shared/src/utils/date.ts @@ -20,6 +20,9 @@ type Format = | (string & {}); export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') { + if (time === undefined || time === null || time === '') { + return ''; + } try { const date = dayjs.isDayjs(time) ? time : dayjs(time); if (!date.isValid()) { From 42317ddf418ee94a452032a0ca97e3edd85eba42 Mon Sep 17 00:00:00 2001 From: Lgf <2246221508@qq.com> Date: Fri, 24 Apr 2026 15:42:07 +0800 Subject: [PATCH 04/73] =?UTF-8?q?feat(watermark):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9A=97=E9=BB=91=E6=A8=A1=E5=BC=8F=E6=B0=B4=E5=8D=B0=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为水印功能添加暗黑模式适配,根据当前主题自动切换水印颜色。在暗黑模式下使用浅色水印,在亮色模式下使用深色水印,提升视觉体验。 --- apps/web-antd/src/layouts/basic.vue | 23 +++++++++++++++++++++-- apps/web-antdv-next/src/layouts/basic.vue | 23 +++++++++++++++++++++-- apps/web-ele/src/layouts/basic.vue | 23 +++++++++++++++++++++-- apps/web-naive/src/layouts/basic.vue | 23 +++++++++++++++++++++-- apps/web-tdesign/src/layouts/basic.vue | 23 +++++++++++++++++++++-- playground/src/layouts/basic.vue | 23 +++++++++++++++++++++-- 6 files changed, 126 insertions(+), 12 deletions(-) diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index 211ee7be3..1bb07ec8f 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -80,6 +80,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -179,10 +180,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, diff --git a/apps/web-antdv-next/src/layouts/basic.vue b/apps/web-antdv-next/src/layouts/basic.vue index 211ee7be3..1bb07ec8f 100644 --- a/apps/web-antdv-next/src/layouts/basic.vue +++ b/apps/web-antdv-next/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -80,6 +80,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -179,10 +180,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, diff --git a/apps/web-ele/src/layouts/basic.vue b/apps/web-ele/src/layouts/basic.vue index 211ee7be3..1bb07ec8f 100644 --- a/apps/web-ele/src/layouts/basic.vue +++ b/apps/web-ele/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -80,6 +80,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -179,10 +180,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, diff --git a/apps/web-naive/src/layouts/basic.vue b/apps/web-naive/src/layouts/basic.vue index 211ee7be3..1bb07ec8f 100644 --- a/apps/web-naive/src/layouts/basic.vue +++ b/apps/web-naive/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -80,6 +80,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -179,10 +180,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, diff --git a/apps/web-tdesign/src/layouts/basic.vue b/apps/web-tdesign/src/layouts/basic.vue index 211ee7be3..1bb07ec8f 100644 --- a/apps/web-tdesign/src/layouts/basic.vue +++ b/apps/web-tdesign/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -80,6 +80,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -179,10 +180,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, diff --git a/playground/src/layouts/basic.vue b/playground/src/layouts/basic.vue index 69fefbe33..62b641768 100644 --- a/playground/src/layouts/basic.vue +++ b/playground/src/layouts/basic.vue @@ -14,7 +14,7 @@ import { Notification, UserDropdown, } from '@vben/layouts'; -import { preferences } from '@vben/preferences'; +import { preferences, usePreferences } from '@vben/preferences'; import { useAccessStore, useTabbarStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; @@ -93,6 +93,7 @@ const userStore = useUserStore(); const authStore = useAuthStore(); const accessStore = useAccessStore(); const { destroyWatermark, updateWatermark } = useWatermark(); +const { isDark } = usePreferences(); const showDot = computed(() => notifications.value.some((item) => !item.isRead), ); @@ -194,10 +195,28 @@ watch( () => ({ enable: preferences.app.watermark, content: preferences.app.watermarkContent, + isDark: isDark.value, }), - async ({ enable, content }) => { + async ({ enable, content, isDark: isDarkValue }) => { if (enable) { + const watermarkColor = isDarkValue + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.12)'; + await updateWatermark({ + advancedStyle: { + colorStops: [ + { + color: watermarkColor, + offset: 0, + }, + { + color: watermarkColor, + offset: 1, + }, + ], + type: 'linear', + }, content: content || `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`, From 4ca2f1c6e831d24d43e24f9503318a4146c570b6 Mon Sep 17 00:00:00 2001 From: "yuan.ji" <961999367@qq.com> Date: Mon, 27 Apr 2026 13:42:36 +0800 Subject: [PATCH 05/73] =?UTF-8?q?feat(@vben/plugins):=20tiptap=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 imageUpload 配置项,支持自定义上传接口 - 支持文件选择、拖拽、粘贴三种上传方式 - 上传中显示 blob 预览图 + loading spinner / 进度条 - 支持 accept 和 maxSize 文件校验 - 支持 onUploadError 自定义错误处理 - 未配置 imageUpload 时保持原有 URL 插入行为不变 - 使用 NodeView 实现实时 DOM 控制的进度展示 --- packages/effects/plugins/package.json | 1 + .../effects/plugins/src/tiptap/extensions.ts | 419 +- packages/effects/plugins/src/tiptap/style.css | 62 + .../effects/plugins/src/tiptap/tiptap.vue | 33 +- .../effects/plugins/src/tiptap/toolbar.ts | 25 +- packages/effects/plugins/src/tiptap/types.ts | 15 + packages/locales/src/langs/en-US/ui.json | 7 + packages/locales/src/langs/zh-CN/ui.json | 7 + .../src/views/examples/tiptap/index.vue | 46 +- pnpm-lock.yaml | 4527 ++++++++--------- pnpm-workspace.yaml | 1 + 11 files changed, 2731 insertions(+), 2412 deletions(-) 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..397bce410 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,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, + 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, +) { + 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'), }), 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..465dc772f 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,7 @@ const editor = useEditor({ }, }); const toolbarGroups = computed(() => { - 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>({}); + +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(); }); @@ -141,8 +166,10 @@ onBeforeUnmount(() => {