This commit is contained in:
dap
2026-05-09 16:23:45 +08:00
17 changed files with 628 additions and 24 deletions

View File

@@ -9,7 +9,7 @@ import type { ClassType } from '@vben-core/typings';
import type { IContextMenuItem } from './interface'; import type { IContextMenuItem } from './interface';
import { computed } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useForwardPropsEmits } from 'reka-ui'; import { useForwardPropsEmits } from 'reka-ui';
@@ -35,6 +35,14 @@ const props = defineProps<
const emits = defineEmits<ContextMenuRootEmits>(); const emits = defineEmits<ContextMenuRootEmits>();
const NATIVE_CONTEXT_SELECTORS = [
'input',
'textarea',
'select',
'[contenteditable]:not([contenteditable="false"])',
'.allow-native-context',
].join(', ');
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { const {
class: _cls, class: _cls,
@@ -59,12 +67,34 @@ function handleClick(menu: IContextMenuItem) {
} }
menu?.handler?.(props.handlerData); menu?.handler?.(props.handlerData);
} }
const triggerRef = ref<HTMLElement | null>(null);
function onContextMenuCapture(e: MouseEvent) {
if ((e.target as HTMLElement).closest(NATIVE_CONTEXT_SELECTORS)) {
e.stopPropagation();
}
}
onMounted(() => {
triggerRef.value?.addEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
onUnmounted(() => {
triggerRef.value?.removeEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
</script> </script>
<template> <template>
<ContextMenu v-bind="forwarded"> <ContextMenu v-bind="forwarded">
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<slot></slot> <div ref="triggerRef" class="contents">
<slot></slot>
</div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent <ContextMenuContent
:class="contentClass" :class="contentClass"

View File

@@ -147,6 +147,9 @@ const searchInputProps = computed(() => {
function updateCurrentSelect(v: string) { function updateCurrentSelect(v: string) {
currentSelect.value = v; currentSelect.value = v;
if (props.modelValueProp === 'modelValue') {
modelValue.value = v;
}
const eventKey = `onUpdate:${props.modelValueProp}`; const eventKey = `onUpdate:${props.modelValueProp}`;
if (attrs[eventKey] && isFunction(attrs[eventKey])) { if (attrs[eventKey] && isFunction(attrs[eventKey])) {
attrs[eventKey](v); attrs[eventKey](v);

View File

@@ -46,7 +46,7 @@ export function useTabs() {
} }
async function openTabInNewWindow(tab?: RouteLocationNormalized) { async function openTabInNewWindow(tab?: RouteLocationNormalized) {
await tabbarStore.openTabInNewWindow(tab || route); await tabbarStore.openTabInNewWindow(tab || route, router);
} }
async function closeTabByKey(key: string) { async function closeTabByKey(key: string) {

View File

@@ -23,6 +23,7 @@ const appPreferencesButtonPosition = defineModel<string>(
'appPreferencesButtonPosition', 'appPreferencesButtonPosition',
); );
const widgetRefresh = defineModel<boolean>('widgetRefresh'); const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const positionItems = computed((): SelectOption[] => [ const positionItems = computed((): SelectOption[] => [
{ {
@@ -65,6 +66,9 @@ const positionItems = computed((): SelectOption[] => [
<SwitchItem v-model="widgetRefresh"> <SwitchItem v-model="widgetRefresh">
{{ $t('preferences.widget.refresh') }} {{ $t('preferences.widget.refresh') }}
</SwitchItem> </SwitchItem>
<SwitchItem v-model="widgetTimezone">
{{ $t('preferences.widget.timezone') }}
</SwitchItem>
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems"> <SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
{{ $t('preferences.position.title') }} {{ $t('preferences.position.title') }}
</SelectItem> </SelectItem>

View File

@@ -180,6 +180,7 @@ const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle'); const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen'); const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const widgetRefresh = defineModel<boolean>('widgetRefresh'); const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const { const {
customPreferences, customPreferences,
@@ -490,6 +491,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:widget-refresh="widgetRefresh" v-model:widget-refresh="widgetRefresh"
v-model:widget-sidebar-toggle="widgetSidebarToggle" v-model:widget-sidebar-toggle="widgetSidebarToggle"
v-model:widget-theme-toggle="widgetThemeToggle" v-model:widget-theme-toggle="widgetThemeToggle"
v-model:widget-timezone="widgetTimezone"
/> />
</Block> </Block>
<Block :title="$t('preferences.footer.title')"> <Block :title="$t('preferences.footer.title')">

View File

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

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 { 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,390 @@ 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(() => {});
}
}
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( export function createDefaultTiptapExtensions(
options: VbenTiptapExtensionOptions = {}, options: VbenTiptapExtensionOptions = {},
): Extensions { ): Extensions {
@@ -42,12 +429,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'),
}), }),

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
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 +159,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 +284,29 @@ 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) => {
if (typeof editor.commands.uploadImage === 'function') {
editor.commands.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,32 @@ import type { Editor } from '@tiptap/vue-3';
import type { Component } from 'vue'; import type { Component } from 'vue';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
uploadImage: () => ReturnType;
};
}
}
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 +48,9 @@ export interface VbenTiptapChangeEvent {
} }
export interface VbenTiptapExtensionOptions { export interface VbenTiptapExtensionOptions {
imageUpload?: ImageUploadOptions;
/** 内部使用:追踪 blob URL 以便组件销毁时清理 */
_blobUrlTracker?: Set<string>;
placeholder?: string; placeholder?: string;
} }

View File

@@ -197,7 +197,8 @@
"notification": "Enable Notification", "notification": "Enable Notification",
"sidebarToggle": "Enable Sidebar Toggle", "sidebarToggle": "Enable Sidebar Toggle",
"lockScreen": "Enable Lock Screen", "lockScreen": "Enable Lock Screen",
"refresh": "Enable Refresh" "refresh": "Enable Refresh",
"timezone": "Enable Timezone"
}, },
"antd": { "antd": {
"tabLabel": "Antd Extension", "tabLabel": "Antd Extension",

View File

@@ -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,12 @@
"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",
"onlySingleImage": "Only single image upload is supported, the first one is selected",
"uploadFailed": "Upload Failed"
} }
}, },
"fallback": { "fallback": {

View File

@@ -197,7 +197,8 @@
"notification": "启用通知", "notification": "启用通知",
"sidebarToggle": "启用侧边栏切换", "sidebarToggle": "启用侧边栏切换",
"lockScreen": "启用锁屏", "lockScreen": "启用锁屏",
"refresh": "启用刷新" "refresh": "启用刷新",
"timezone": "启用时区"
}, },
"antd": { "antd": {
"tabLabel": "Antd 拓展", "tabLabel": "Antd 拓展",

View File

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

View File

@@ -14,7 +14,7 @@ import { markRaw, toRaw } from 'vue';
import { preferences } from '@vben-core/preferences'; import { preferences } from '@vben-core/preferences';
import { import {
createStack, createStack,
openRouteInNewWindow, openWindow,
Stack, Stack,
startProgress, startProgress,
stopProgress, stopProgress,
@@ -371,8 +371,9 @@ export const useTabbarStore = defineStore('core-tabbar', {
* @zh_CN 新窗口打开标签页 * @zh_CN 新窗口打开标签页
* @param tab * @param tab
*/ */
async openTabInNewWindow(tab: TabDefinition) { async openTabInNewWindow(tab: TabDefinition, router: Router) {
openRouteInNewWindow(tab.fullPath || tab.path); const href = router.resolve(tab.fullPath || tab.path).href;
openWindow(new URL(href, location.href).href, { target: '_blank' });
}, },
/** /**

View File

@@ -62,6 +62,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
@@ -142,7 +143,7 @@ catalog:
oxlint-tsgolint: ^0.20.0 oxlint-tsgolint: ^0.20.0
pinia: ^3.0.4 pinia: ^3.0.4
pinia-plugin-persistedstate: ^4.7.1 pinia-plugin-persistedstate: ^4.7.1
pkg-types: ^2.3.0 pkg-types: ^2.3.1
playwright: ^1.59.1 playwright: ^1.59.1
postcss: ^8.5.9 postcss: ^8.5.9
postcss-html: ^1.8.1 postcss-html: ^1.8.1
@@ -153,7 +154,7 @@ catalog:
reka-ui: ^2.9.5 reka-ui: ^2.9.5
resolve.exports: ^2.0.3 resolve.exports: ^2.0.3
rimraf: ^6.1.3 rimraf: ^6.1.3
rolldown: ^1.0.0-rc.15 rolldown: ^1.0.0-rc.17
rollup-plugin-visualizer: ^7.0.1 rollup-plugin-visualizer: ^7.0.1
sass: ^1.99.0 sass: ^1.99.0
sass-embedded: ^1.99.0 sass-embedded: ^1.99.0
@@ -175,14 +176,14 @@ catalog:
tsdown: ^0.21.8 tsdown: ^0.21.8
turbo: ^2.9.6 turbo: ^2.9.6
tw-animate-css: ^1.4.0 tw-animate-css: ^1.4.0
typescript: ^6.0.2 typescript: ^6.0.3
unplugin-dts: ^1.0.0-beta.6 unplugin-dts: ^1.0.0-beta.6
unplugin-vue: ^7.1.1 unplugin-vue: ^7.1.1
unplugin-vue-components: ^0.27.3 unplugin-vue-components: ^0.27.3
vditor: 3.10.9 vditor: 3.10.9
vee-validate: ^4.15.1 vee-validate: ^4.15.1
version-polling: ^1.3.3 version-polling: ^1.3.3
vite: ^8.0.8 vite: ^8.0.10
vite-plugin-compression: ^0.5.1 vite-plugin-compression: ^0.5.1
vite-plugin-lazy-import: ^1.0.7 vite-plugin-lazy-import: ^1.0.7
vite-plugin-pwa: ^1.2.0 vite-plugin-pwa: ^1.2.0