mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-12 14:12:08 +08:00
Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
This commit is contained in:
@@ -9,7 +9,7 @@ import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import type { IContextMenuItem } from './interface';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
@@ -35,6 +35,14 @@ const props = defineProps<
|
||||
|
||||
const emits = defineEmits<ContextMenuRootEmits>();
|
||||
|
||||
const NATIVE_CONTEXT_SELECTORS = [
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
'.allow-native-context',
|
||||
].join(', ');
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const {
|
||||
class: _cls,
|
||||
@@ -59,12 +67,34 @@ function handleClick(menu: IContextMenuItem) {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<ContextMenu v-bind="forwarded">
|
||||
<ContextMenuTrigger as-child>
|
||||
<slot></slot>
|
||||
<div ref="triggerRef" class="contents">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
:class="contentClass"
|
||||
|
||||
@@ -147,6 +147,9 @@ const searchInputProps = computed(() => {
|
||||
|
||||
function updateCurrentSelect(v: string) {
|
||||
currentSelect.value = v;
|
||||
if (props.modelValueProp === 'modelValue') {
|
||||
modelValue.value = v;
|
||||
}
|
||||
const eventKey = `onUpdate:${props.modelValueProp}`;
|
||||
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
|
||||
attrs[eventKey](v);
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useTabs() {
|
||||
}
|
||||
|
||||
async function openTabInNewWindow(tab?: RouteLocationNormalized) {
|
||||
await tabbarStore.openTabInNewWindow(tab || route);
|
||||
await tabbarStore.openTabInNewWindow(tab || route, router);
|
||||
}
|
||||
|
||||
async function closeTabByKey(key: string) {
|
||||
|
||||
@@ -23,6 +23,7 @@ const appPreferencesButtonPosition = defineModel<string>(
|
||||
'appPreferencesButtonPosition',
|
||||
);
|
||||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
const widgetTimezone = defineModel<boolean>('widgetTimezone');
|
||||
|
||||
const positionItems = computed((): SelectOption[] => [
|
||||
{
|
||||
@@ -65,6 +66,9 @@ const positionItems = computed((): SelectOption[] => [
|
||||
<SwitchItem v-model="widgetRefresh">
|
||||
{{ $t('preferences.widget.refresh') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetTimezone">
|
||||
{{ $t('preferences.widget.timezone') }}
|
||||
</SwitchItem>
|
||||
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
|
||||
{{ $t('preferences.position.title') }}
|
||||
</SelectItem>
|
||||
|
||||
@@ -180,6 +180,7 @@ const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
|
||||
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
|
||||
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
|
||||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
const widgetTimezone = defineModel<boolean>('widgetTimezone');
|
||||
|
||||
const {
|
||||
customPreferences,
|
||||
@@ -490,6 +491,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
|
||||
v-model:widget-refresh="widgetRefresh"
|
||||
v-model:widget-sidebar-toggle="widgetSidebarToggle"
|
||||
v-model:widget-theme-toggle="widgetThemeToggle"
|
||||
v-model:widget-timezone="widgetTimezone"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.footer.title')">
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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'),
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TipTapProps>(), {
|
||||
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<string>();
|
||||
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<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(
|
||||
() => editor.value?.getHTML() ?? modelValue.value,
|
||||
@@ -95,6 +103,22 @@ const {
|
||||
editable: () => props.editable,
|
||||
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() {
|
||||
previewModalApi.open();
|
||||
}
|
||||
@@ -120,6 +144,10 @@ watch(
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
for (const url of blobUrlTracker) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
blobUrlTracker.clear();
|
||||
editor.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
@@ -141,8 +169,10 @@ onBeforeUnmount(() => {
|
||||
<template v-for="action in group" :key="action.label">
|
||||
<VbenPopover
|
||||
v-if="action.menu || action.palette"
|
||||
:open="action.menu ? getMenuOpen(action) : undefined"
|
||||
:content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
|
||||
content-class="w-auto p-2"
|
||||
@update:open="action.menu ? setMenuOpen(action, $event) : undefined"
|
||||
>
|
||||
<template #trigger>
|
||||
<VbenIconButton
|
||||
@@ -209,7 +239,7 @@ onBeforeUnmount(() => {
|
||||
:class="getMenuItemClass(item)"
|
||||
:disabled="!canRunMenuItem(item)"
|
||||
type="button"
|
||||
@click="runMenuItem(item)"
|
||||
@click="handleMenuItemClick(action, item)"
|
||||
>
|
||||
<span class="w-7 text-xs font-semibold tracking-wide">
|
||||
{{ item.shortLabel }}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
import type { ToolbarAction, ToolbarMenuItem } from './types';
|
||||
import type {
|
||||
ImageUploadOptions,
|
||||
ToolbarAction,
|
||||
ToolbarMenuItem,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
AlignCenter,
|
||||
@@ -155,7 +159,9 @@ async function handleImageAction(editor: Editor) {
|
||||
editor.chain().focus().setImage({ src: nextUrl }).run();
|
||||
}
|
||||
|
||||
export function createToolbarGroups(): ToolbarAction[][] {
|
||||
export function createToolbarGroups(
|
||||
imageUpload?: ImageUploadOptions,
|
||||
): ToolbarAction[][] {
|
||||
const headingMenuItems = createHeadingMenuItems();
|
||||
|
||||
return [
|
||||
@@ -278,6 +284,29 @@ export function createToolbarGroups(): ToolbarAction[][] {
|
||||
action: (editor) => handleImageAction(editor),
|
||||
icon: ImagePlus,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -3,9 +3,32 @@ import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
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 {
|
||||
editable?: boolean;
|
||||
extensions?: Extensions;
|
||||
imageUpload?: ImageUploadOptions;
|
||||
minHeight?: number | string;
|
||||
placeholder?: string;
|
||||
previewable?: boolean;
|
||||
@@ -25,6 +48,9 @@ export interface VbenTiptapChangeEvent {
|
||||
}
|
||||
|
||||
export interface VbenTiptapExtensionOptions {
|
||||
imageUpload?: ImageUploadOptions;
|
||||
/** 内部使用:追踪 blob URL 以便组件销毁时清理 */
|
||||
_blobUrlTracker?: Set<string>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"notification": "Enable Notification",
|
||||
"sidebarToggle": "Enable Sidebar Toggle",
|
||||
"lockScreen": "Enable Lock Screen",
|
||||
"refresh": "Enable Refresh"
|
||||
"refresh": "Enable Refresh",
|
||||
"timezone": "Enable Timezone"
|
||||
},
|
||||
"antd": {
|
||||
"tabLabel": "Antd Extension",
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"link": "Link",
|
||||
"unlink": "Unlink",
|
||||
"image": "Image",
|
||||
"imageUrl": "Image URL",
|
||||
"imageUpload": "Upload Image",
|
||||
"textColor": "Text Color",
|
||||
"highlightColor": "Highlight Color",
|
||||
"alignLeft": "Left",
|
||||
@@ -95,6 +97,12 @@
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"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": {
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"notification": "启用通知",
|
||||
"sidebarToggle": "启用侧边栏切换",
|
||||
"lockScreen": "启用锁屏",
|
||||
"refresh": "启用刷新"
|
||||
"refresh": "启用刷新",
|
||||
"timezone": "启用时区"
|
||||
},
|
||||
"antd": {
|
||||
"tabLabel": "Antd 拓展",
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"link": "链接",
|
||||
"unlink": "移除链接",
|
||||
"image": "图片",
|
||||
"imageUrl": "图片URL",
|
||||
"imageUpload": "从本地上传",
|
||||
"textColor": "文字颜色",
|
||||
"highlightColor": "高亮颜色",
|
||||
"alignLeft": "左对齐",
|
||||
@@ -95,6 +97,12 @@
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"clear": "清除"
|
||||
},
|
||||
"upload": {
|
||||
"fileTooLarge": "文件大小超出限制",
|
||||
"fileTypeNotAllowed": "不支持的文件类型",
|
||||
"onlySingleImage": "仅支持单张图片上传,已选取第一张",
|
||||
"uploadFailed": "上传失败"
|
||||
}
|
||||
},
|
||||
"fallback": {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { markRaw, toRaw } from 'vue';
|
||||
import { preferences } from '@vben-core/preferences';
|
||||
import {
|
||||
createStack,
|
||||
openRouteInNewWindow,
|
||||
openWindow,
|
||||
Stack,
|
||||
startProgress,
|
||||
stopProgress,
|
||||
@@ -371,8 +371,9 @@ export const useTabbarStore = defineStore('core-tabbar', {
|
||||
* @zh_CN 新窗口打开标签页
|
||||
* @param tab
|
||||
*/
|
||||
async openTabInNewWindow(tab: TabDefinition) {
|
||||
openRouteInNewWindow(tab.fullPath || tab.path);
|
||||
async openTabInNewWindow(tab: TabDefinition, router: Router) {
|
||||
const href = router.resolve(tab.fullPath || tab.path).href;
|
||||
openWindow(new URL(href, location.href).href, { target: '_blank' });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,7 @@ catalog:
|
||||
'@tiptap/extension-text-align': ^3.22.3
|
||||
'@tiptap/extension-text-style': ^3.22.3
|
||||
'@tiptap/extension-underline': ^3.22.3
|
||||
'@tiptap/pm': ^3.22.3
|
||||
'@tiptap/starter-kit': ^3.22.3
|
||||
'@tiptap/vue-3': ^3.22.3
|
||||
'@tsdown/css': ^0.21.8
|
||||
@@ -142,7 +143,7 @@ catalog:
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
pinia: ^3.0.4
|
||||
pinia-plugin-persistedstate: ^4.7.1
|
||||
pkg-types: ^2.3.0
|
||||
pkg-types: ^2.3.1
|
||||
playwright: ^1.59.1
|
||||
postcss: ^8.5.9
|
||||
postcss-html: ^1.8.1
|
||||
@@ -153,7 +154,7 @@ catalog:
|
||||
reka-ui: ^2.9.5
|
||||
resolve.exports: ^2.0.3
|
||||
rimraf: ^6.1.3
|
||||
rolldown: ^1.0.0-rc.15
|
||||
rolldown: ^1.0.0-rc.17
|
||||
rollup-plugin-visualizer: ^7.0.1
|
||||
sass: ^1.99.0
|
||||
sass-embedded: ^1.99.0
|
||||
@@ -175,14 +176,14 @@ catalog:
|
||||
tsdown: ^0.21.8
|
||||
turbo: ^2.9.6
|
||||
tw-animate-css: ^1.4.0
|
||||
typescript: ^6.0.2
|
||||
typescript: ^6.0.3
|
||||
unplugin-dts: ^1.0.0-beta.6
|
||||
unplugin-vue: ^7.1.1
|
||||
unplugin-vue-components: ^0.27.3
|
||||
vditor: 3.10.9
|
||||
vee-validate: ^4.15.1
|
||||
version-polling: ^1.3.3
|
||||
vite: ^8.0.8
|
||||
vite: ^8.0.10
|
||||
vite-plugin-compression: ^0.5.1
|
||||
vite-plugin-lazy-import: ^1.0.7
|
||||
vite-plugin-pwa: ^1.2.0
|
||||
|
||||
Reference in New Issue
Block a user