feat(@vben/plugins): add tiptap rich text editor

This commit is contained in:
xingyu4j
2026-03-30 19:36:29 +08:00
parent df88a23102
commit bb78882f72
20 changed files with 1861 additions and 4 deletions

View File

@@ -22,6 +22,10 @@
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
},
"./tiptap": {
"types": "./src/tiptap/index.ts",
"default": "./src/tiptap/index.ts"
},
"./vxe-table": {
"types": "./src/vxe-table/index.ts",
"default": "./src/vxe-table/index.ts"
@@ -32,8 +36,20 @@
}
},
"dependencies": {
"@tiptap/core": "catalog:",
"@tiptap/extension-doc": "catalog:",
"@tiptap/extension-highlight": "catalog:",
"@tiptap/extension-image": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-placeholder": "catalog:",
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-text-style": "catalog:",
"@tiptap/extension-underline": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@tiptap/vue-3": "catalog:",
"@vben-core/design": "workspace:*",
"@vben-core/form-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/hooks": "workspace:*",

View File

@@ -0,0 +1,55 @@
import type { Extensions } from '@tiptap/vue-3';
import type { VbenTiptapExtensionOptions } from './types';
import { $t } from '@vben/locales';
import Document from '@tiptap/extension-doc';
import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Color, TextStyle } from '@tiptap/extension-text-style';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
export function createDefaultTiptapExtensions(
options: VbenTiptapExtensionOptions = {},
): Extensions {
return [
Document,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
TextStyle,
Color.configure({
types: ['textStyle'],
}),
Highlight.configure({
multicolor: true,
}),
Link.configure({
autolink: true,
defaultProtocol: 'https',
enableClickSelection: true,
openOnClick: false,
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
}),
Image.configure({
allowBase64: true,
HTMLAttributes: {
class: 'vben-tiptap__image',
},
}),
Placeholder.configure({
placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
}),
];
}

View File

@@ -0,0 +1,4 @@
export { default as VbenTiptapPreview } from './preview.vue';
export { default as VbenTiptap } from './tiptap.vue';
export * from './types';

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { TipTapPreviewProps } from './types';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import './style.css';
const props = withDefaults(defineProps<TipTapPreviewProps>(), {
content: '',
minHeight: 160,
});
const contentMinHeight = computed(() =>
typeof props.minHeight === 'number'
? `${props.minHeight}px`
: props.minHeight,
);
const previewClass = computed(() =>
cn(
'vben-tiptap-content bg-transparent p-0 leading-7 text-foreground',
props.class,
),
);
</script>
<template>
<!-- eslint-disable vue/no-v-html -->
<div
:class="previewClass"
:style="{ minHeight: contentMinHeight }"
v-html="content"
></div>
</template>

View File

@@ -0,0 +1,56 @@
@reference "@vben/tailwind-config/theme";
.vben-tiptap-content > * + * {
@apply mt-3;
}
.vben-tiptap-content h1 {
@apply text-2xl font-bold leading-[1.4];
}
.vben-tiptap-content h2 {
@apply text-xl font-bold leading-[1.45];
}
.vben-tiptap-content h3 {
@apply text-lg font-semibold leading-[1.5];
}
.vben-tiptap-content h4 {
@apply text-base font-semibold leading-[1.55];
}
.vben-tiptap-content ul {
@apply list-disc pl-6;
}
.vben-tiptap-content ol {
@apply list-decimal pl-6;
}
.vben-tiptap-content blockquote {
@apply border-l-4 border-primary pl-4 text-muted-foreground;
}
.vben-tiptap-content a {
@apply text-primary underline decoration-1 underline-offset-[3px];
}
.vben-tiptap-content code {
@apply rounded-[0.45rem] border border-border bg-secondary px-[0.35rem] py-[0.15rem] text-[0.9em] text-primary;
}
.vben-tiptap-content pre {
@apply overflow-x-auto rounded-[0.9rem] border border-border bg-popover p-4 text-popover-foreground;
}
.vben-tiptap-content pre code {
@apply border-none bg-transparent p-0 text-inherit;
}
.vben-tiptap-content img,
.vben-tiptap-content .vben-tiptap__image {
@apply my-4 block h-auto rounded-2xl border border-border;
max-width: min(100%, 640px);
}

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import type {
TipTapProps,
ToolbarAction,
VbenTiptapChangeEvent,
} from './types';
import { computed, onBeforeUnmount, watch } from 'vue';
import { Check, ChevronDown, Eye } from '@vben/icons';
import { $t } from '@vben/locales';
import { useVbenModal } from '@vben-core/popup-ui';
import { VbenIconButton, VbenPopover } from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import { createDefaultTiptapExtensions } from './extensions';
import Preview from './preview.vue';
import { createToolbarGroups } from './toolbar';
import { useTiptapToolbar } from './use-tiptap-toolbar';
import './style.css';
const props = withDefaults(defineProps<TipTapProps>(), {
editable: true,
extensions: undefined,
minHeight: 240,
placeholder: $t('ui.tiptap.placeholder'),
previewable: true,
toolbar: true,
});
const emit = defineEmits<{
change: [payload: VbenTiptapChangeEvent];
}>();
const modelValue = defineModel<string>({ default: '' });
const contentMinHeight = computed(() =>
typeof props.minHeight === 'number'
? `${props.minHeight}px`
: props.minHeight,
);
const tiptapContentClass = cn(
'vben-tiptap-content vben-tiptap__content',
'min-h-(--vben-tiptap-min-height) leading-7 text-foreground outline-none',
);
const editor = useEditor({
content: modelValue.value,
editable: props.editable,
editorProps: {
attributes: {
class: tiptapContentClass,
},
},
extensions:
props.extensions ??
createDefaultTiptapExtensions({
placeholder: props.placeholder,
}),
onUpdate: ({ editor }) => {
const html = editor.getHTML();
if (html !== modelValue.value) {
modelValue.value = html;
}
emit('change', {
html,
json: editor.getJSON(),
text: editor.getText(),
});
},
});
const toolbarGroups = computed<ToolbarAction[][]>(() => {
return createToolbarGroups();
});
const previewContent = computed(
() => editor.value?.getHTML() ?? modelValue.value,
);
const [PreviewModal, previewModalApi] = useVbenModal({
footer: false,
fullscreenButton: false,
});
const {
applyPaletteColor,
canRunAction,
canRunMenuItem,
clearPaletteColor,
getActionIndicatorColor,
getMenuItemClass,
getPaletteCurrentColor,
getPaletteSwatchClass,
getToolbarButtonClass,
isMenuItemActive,
runAction,
runMenuItem,
} = useTiptapToolbar({
editable: () => props.editable,
editor,
});
function openPreviewModal() {
previewModalApi.open();
}
watch(
() => props.editable,
(editable) => {
editor.value?.setEditable(editable);
},
);
watch(
() => modelValue.value,
(nextValue = '') => {
if (!editor.value) {
return;
}
const currentValue = editor.value.getHTML();
if (nextValue === currentValue) {
return;
}
editor.value.commands.setContent(nextValue, {
emitUpdate: false,
});
},
);
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<template>
<div
:style="{ '--vben-tiptap-min-height': contentMinHeight }"
class="vben-tiptap overflow-hidden rounded-xl border border-border bg-card"
>
<div
v-if="toolbar"
class="sticky top-0 z-10 flex flex-wrap items-center gap-2 border-b border-border p-2 backdrop-blur-[14px]"
>
<div
v-for="(group, groupIndex) in toolbarGroups"
:key="groupIndex"
class="flex items-center gap-1"
>
<template v-for="action in group" :key="action.label">
<VbenPopover
v-if="action.menu || action.palette"
:content-props="{ align: 'start', side: 'bottom', sideOffset: 8 }"
content-class="w-auto p-2"
>
<template #trigger>
<VbenIconButton
:aria-label="action.label"
:class="getToolbarButtonClass(action)"
:disabled="!canRunAction(action)"
:tooltip="action.label"
tooltip-side="top"
variant="ghost"
>
<template v-if="action.triggerText">
<span class="text-xs font-semibold tracking-wide">
{{
typeof action.triggerText === 'function'
? action.triggerText(editor)
: action.triggerText
}}
</span>
<ChevronDown class="size-4 opacity-70" />
</template>
<component
v-else-if="action.icon"
:is="action.icon"
class="size-4"
/>
<span
v-if="getActionIndicatorColor(action)"
:style="{ backgroundColor: getActionIndicatorColor(action) }"
class="absolute bottom-1 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full shadow-[0_0_0_1px_hsl(var(--card)/0.7)]"
></span>
</VbenIconButton>
</template>
<div
v-if="action.palette"
class="flex max-w-52 flex-wrap items-center gap-2"
>
<button
v-for="color in action.palette.colors"
:key="color"
:aria-label="`${action.label}-${color}`"
:class="getPaletteSwatchClass(action, color)"
:style="{ backgroundColor: color }"
type="button"
@click="applyPaletteColor(action, color)"
>
<Check
v-if="getPaletteCurrentColor(action) === color"
class="size-4 text-white drop-shadow-sm"
/>
</button>
<button
v-if="action.palette.clear"
class="h-8 w-full rounded-xl border border-border bg-secondary text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
type="button"
@click="clearPaletteColor(action)"
>
{{ $t('ui.tiptap.toolbar.clear') }}
</button>
</div>
<div v-else-if="action.menu" class="flex min-w-32 flex-col gap-1">
<button
v-for="item in action.menu.items"
:key="item.shortLabel"
:class="getMenuItemClass(item)"
:disabled="!canRunMenuItem(item)"
type="button"
@click="runMenuItem(item)"
>
<span class="w-7 text-xs font-semibold tracking-wide">
{{ item.shortLabel }}
</span>
<span class="flex-1">{{ item.label }}</span>
<Check
v-if="isMenuItemActive(item)"
class="size-4 text-primary"
/>
</button>
</div>
</VbenPopover>
<VbenIconButton
v-else
:aria-label="action.label"
:class="getToolbarButtonClass(action)"
:disabled="!canRunAction(action)"
:tooltip="action.label"
tooltip-side="top"
@click="runAction(action)"
>
<component :is="action.icon" class="size-4" />
<span
v-if="getActionIndicatorColor(action)"
:style="{ backgroundColor: getActionIndicatorColor(action) }"
class="absolute bottom-1 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full shadow-[0_0_0_1px_hsl(var(--card)/0.7)]"
></span>
</VbenIconButton>
</template>
<div
v-if="groupIndex < toolbarGroups.length - 1"
class="ml-1 h-5 w-px bg-border"
></div>
</div>
<div v-if="previewable" class="ml-auto flex items-center">
<VbenIconButton
:aria-label="$t('ui.tiptap.toolbar.preview')"
:class="
getToolbarButtonClass({
action: () => {},
label: $t('ui.tiptap.toolbar.preview'),
})
"
:tooltip="$t('ui.tiptap.toolbar.preview')"
tooltip-side="top"
variant="ghost"
@click="openPreviewModal"
>
<Eye class="size-4" />
</VbenIconButton>
</div>
</div>
<EditorContent v-if="editor" :editor="editor" class="p-4" />
<PreviewModal
v-if="previewable"
:title="$t('ui.tiptap.toolbar.preview')"
class="w-4/5"
>
<Preview :content="previewContent" :min-height="320" />
</PreviewModal>
</div>
</template>
<style scoped>
.vben-tiptap
:deep(.vben-tiptap__content p.is-editor-empty:first-child::before) {
float: left;
height: 0;
color: hsl(var(--input-placeholder));
pointer-events: none;
content: attr(data-placeholder);
}
</style>

View File

@@ -0,0 +1,345 @@
import type { Editor } from '@tiptap/vue-3';
import type { ToolbarAction, ToolbarMenuItem } from './types';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Highlighter,
ImagePlus,
Italic,
Link2,
List,
ListOrdered,
MessageSquareCode,
Paintbrush,
Redo2,
RemoveFormatting,
SquareCode,
Strikethrough,
TextQuote,
Underline,
Undo2,
Unlink2,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { COLOR_PRESETS } from '@vben/preferences';
import { prompt } from '@vben-core/popup-ui';
const headingLevels = [1, 2, 3, 4] as const;
const editorColorPresets = [
'hsl(var(--foreground))',
'hsl(var(--warning))',
'hsl(var(--success))',
'hsl(var(--destructive))',
...COLOR_PRESETS.map((item) => item.color),
];
const editorHighlightPresets = [
withAlpha('hsl(var(--warning))', 0.45),
withAlpha('hsl(var(--success))', 0.35),
withAlpha('hsl(var(--primary))', 0.3),
withAlpha('hsl(var(--destructive))', 0.3),
...COLOR_PRESETS.map((item) => withAlpha(item.color, 0.4)),
];
function createHeadingMenuItems(): ToolbarMenuItem[] {
return [
{
action: (editor) => editor.chain().focus().setParagraph().run(),
can: (editor) => editor.can().chain().focus().setParagraph().run(),
isActive: (editor) => editor.isActive('paragraph'),
label: $t('ui.tiptap.toolbar.paragraph'),
shortLabel: 'P',
},
...headingLevels.map((level) => ({
action: (editor: Editor) =>
editor.chain().focus().toggleHeading({ level }).run(),
can: (editor: Editor) =>
editor.can().chain().focus().toggleHeading({ level }).run(),
isActive: (editor: Editor) => editor.isActive('heading', { level }),
label: $t(`ui.tiptap.toolbar.heading${level}`),
shortLabel: `H${level}`,
})),
];
}
function getHeadingTriggerText(editor?: Editor) {
if (editor?.isActive('paragraph')) {
return 'P';
}
const level = headingLevels.find((headingLevel) =>
editor?.isActive('heading', { level: headingLevel }),
);
return level ? `H${level}` : 'H';
}
function normalizeLinkUrl(url: string) {
if (/^(https?:|mailto:|tel:)/i.test(url)) {
return url;
}
return `https://${url}`;
}
function withAlpha(color: string, alpha: number) {
const normalizedAlpha = Math.min(Math.max(alpha, 0), 1);
const hslMatch = color.match(/^hsl\((.+)\)$/);
if (!hslMatch) {
return color;
}
return `hsl(${hslMatch[1]} / ${normalizedAlpha})`;
}
async function handleLinkAction(editor: Editor) {
const currentHref = editor.getAttributes('link').href as string | undefined;
let url: string | undefined;
try {
url = await prompt<string>({
componentProps: {
placeholder: 'https://example.com',
},
content: $t('ui.tiptap.prompts.link'),
defaultValue: currentHref ?? '',
});
} catch {
return;
}
const nextUrl = (url ?? '').trim();
if (!nextUrl) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({
href: normalizeLinkUrl(nextUrl),
})
.run();
}
async function handleImageAction(editor: Editor) {
let url: string | undefined;
try {
url = await prompt<string>({
componentProps: {
placeholder: 'https://example.com/image.png',
},
content: $t('ui.tiptap.prompts.image'),
defaultValue: '',
});
} catch {
return;
}
const nextUrl = (url ?? '').trim();
if (!nextUrl) {
return;
}
editor.chain().focus().setImage({ src: nextUrl }).run();
}
export function createToolbarGroups(): ToolbarAction[][] {
const headingMenuItems = createHeadingMenuItems();
return [
[
{
action: (editor) => editor.chain().focus().undo().run(),
can: (editor) => editor.can().chain().focus().undo().run(),
icon: Undo2,
label: $t('ui.tiptap.toolbar.undo'),
},
{
action: (editor) => editor.chain().focus().redo().run(),
can: (editor) => editor.can().chain().focus().redo().run(),
icon: Redo2,
label: $t('ui.tiptap.toolbar.redo'),
},
{
action: (editor) =>
editor.chain().focus().clearNodes().unsetAllMarks().run(),
icon: RemoveFormatting,
label: $t('ui.tiptap.toolbar.clear'),
},
],
[
{
action: (editor) => editor.chain().focus().toggleBold().run(),
active: { name: 'bold' },
can: (editor) => editor.can().chain().focus().toggleBold().run(),
icon: Bold,
label: $t('ui.tiptap.toolbar.bold'),
},
{
action: (editor) => editor.chain().focus().toggleItalic().run(),
active: { name: 'italic' },
can: (editor) => editor.can().chain().focus().toggleItalic().run(),
icon: Italic,
label: $t('ui.tiptap.toolbar.italic'),
},
{
action: (editor) => editor.chain().focus().toggleUnderline().run(),
active: { name: 'underline' },
can: (editor) => editor.can().chain().focus().toggleUnderline().run(),
icon: Underline,
label: $t('ui.tiptap.toolbar.underline'),
},
{
action: (editor) => editor.chain().focus().toggleStrike().run(),
active: { name: 'strike' },
can: (editor) => editor.can().chain().focus().toggleStrike().run(),
icon: Strikethrough,
label: $t('ui.tiptap.toolbar.strike'),
},
{
action: (editor) => editor.chain().focus().toggleCode().run(),
active: { name: 'code' },
can: (editor) => editor.can().chain().focus().toggleCode().run(),
icon: SquareCode,
label: $t('ui.tiptap.toolbar.code'),
},
],
[
{
action: () => {},
can: (editor) =>
headingMenuItems.some((item) => (item.can ? item.can(editor) : true)),
isActive: (editor) =>
headingMenuItems.some((item) => item.isActive?.(editor)),
label: $t('ui.tiptap.toolbar.heading'),
menu: {
items: headingMenuItems,
},
triggerText: (editor) => getHeadingTriggerText(editor),
},
{
action: (editor) => editor.chain().focus().toggleBulletList().run(),
active: { name: 'bulletList' },
can: (editor) => editor.can().chain().focus().toggleBulletList().run(),
icon: List,
label: $t('ui.tiptap.toolbar.bulletList'),
},
{
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
active: { name: 'orderedList' },
can: (editor) => editor.can().chain().focus().toggleOrderedList().run(),
icon: ListOrdered,
label: $t('ui.tiptap.toolbar.orderedList'),
},
{
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
active: { name: 'blockquote' },
can: (editor) => editor.can().chain().focus().toggleBlockquote().run(),
icon: TextQuote,
label: $t('ui.tiptap.toolbar.blockquote'),
},
{
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
active: { name: 'codeBlock' },
can: (editor) => editor.can().chain().focus().toggleCodeBlock().run(),
icon: MessageSquareCode,
label: $t('ui.tiptap.toolbar.codeBlock'),
},
],
[
{
action: (editor) => handleLinkAction(editor),
active: { name: 'link' },
can: (editor) =>
editor.can().chain().focus().extendMarkRange('link').run(),
icon: Link2,
label: $t('ui.tiptap.toolbar.link'),
},
{
action: (editor) => editor.chain().focus().unsetLink().run(),
can: (editor) => editor.can().chain().focus().unsetLink().run(),
icon: Unlink2,
isActive: (editor) => editor.isActive('link'),
label: $t('ui.tiptap.toolbar.unlink'),
},
{
action: (editor) => handleImageAction(editor),
icon: ImagePlus,
label: $t('ui.tiptap.toolbar.image'),
},
],
[
{
action: () => {},
icon: Paintbrush,
indicatorColor: (editor) =>
editor.getAttributes('textStyle').color as string | undefined,
isActive: (editor) => Boolean(editor.getAttributes('textStyle').color),
label: $t('ui.tiptap.toolbar.textColor'),
palette: {
apply: (editor, color) =>
editor.chain().focus().setColor(color).run(),
clear: (editor) => editor.chain().focus().unsetColor().run(),
colors: editorColorPresets,
currentColor: (editor) =>
editor.getAttributes('textStyle').color as string | undefined,
},
},
{
action: () => {},
icon: Highlighter,
indicatorColor: (editor) =>
(editor.getAttributes('highlight').color as string | undefined) ??
'#fef08a',
isActive: (editor) => editor.isActive('highlight'),
label: $t('ui.tiptap.toolbar.highlightColor'),
palette: {
apply: (editor, color) =>
editor.chain().focus().setHighlight({ color }).run(),
clear: (editor) => editor.chain().focus().unsetHighlight().run(),
colors: editorHighlightPresets,
currentColor: (editor) =>
editor.getAttributes('highlight').color as string | undefined,
},
},
],
[
{
action: (editor) => editor.chain().focus().setTextAlign('left').run(),
can: (editor) =>
editor.can().chain().focus().setTextAlign('left').run(),
icon: AlignLeft,
isActive: (editor) => editor.isActive({ textAlign: 'left' }),
label: $t('ui.tiptap.toolbar.alignLeft'),
},
{
action: (editor) => editor.chain().focus().setTextAlign('center').run(),
can: (editor) =>
editor.can().chain().focus().setTextAlign('center').run(),
icon: AlignCenter,
isActive: (editor) => editor.isActive({ textAlign: 'center' }),
label: $t('ui.tiptap.toolbar.alignCenter'),
},
{
action: (editor) => editor.chain().focus().setTextAlign('right').run(),
can: (editor) =>
editor.can().chain().focus().setTextAlign('right').run(),
icon: AlignRight,
isActive: (editor) => editor.isActive({ textAlign: 'right' }),
label: $t('ui.tiptap.toolbar.alignRight'),
},
],
];
}

View File

@@ -0,0 +1,60 @@
import type { Extensions, JSONContent } from '@tiptap/core';
import type { Editor } from '@tiptap/vue-3';
import type { Component } from 'vue';
export interface TipTapProps {
editable?: boolean;
extensions?: Extensions;
minHeight?: number | string;
placeholder?: string;
previewable?: boolean;
toolbar?: boolean;
}
export interface TipTapPreviewProps {
class?: any;
content?: string;
minHeight?: number | string;
}
export interface VbenTiptapChangeEvent {
html: string;
json: JSONContent;
text: string;
}
export interface VbenTiptapExtensionOptions {
placeholder?: string;
}
export interface ToolbarAction {
action: (editor: Editor) => void;
active?: {
attrs?: Record<string, unknown>;
name: string;
};
can?: (editor: Editor) => boolean;
icon?: Component;
indicatorColor?: (editor: Editor) => string | undefined;
isActive?: (editor: Editor) => boolean;
label: string;
menu?: {
items: ToolbarMenuItem[];
};
palette?: {
apply: (editor: Editor, color: string) => void;
clear?: (editor: Editor) => void;
colors: string[];
currentColor?: (editor: Editor) => string | undefined;
};
triggerText?: ((editor?: Editor) => string) | string;
}
export interface ToolbarMenuItem {
action: (editor: Editor) => void;
can?: (editor: Editor) => boolean;
isActive?: (editor: Editor) => boolean;
label: string;
shortLabel: string;
}

View File

@@ -0,0 +1,176 @@
import type { Editor } from '@tiptap/vue-3';
import type { ShallowRef } from 'vue';
import type { ToolbarAction, ToolbarMenuItem } from './types';
import { cn } from '@vben-core/shared/utils';
interface UseTiptapToolbarOptions {
editable: () => boolean;
editor: Readonly<ShallowRef<Editor | undefined>>;
}
export function useTiptapToolbar(options: UseTiptapToolbarOptions) {
const getEditor = () => options.editor.value;
function getActionIndicatorColor(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor || !action.indicatorColor) {
return undefined;
}
return action.indicatorColor(currentEditor);
}
function getPaletteCurrentColor(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor || !action.palette?.currentColor) {
return undefined;
}
return action.palette.currentColor(currentEditor);
}
function canRunAction(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor || !options.editable()) {
return false;
}
return action.can ? action.can(currentEditor) : true;
}
function canRunMenuItem(item: ToolbarMenuItem) {
const currentEditor = getEditor();
if (!currentEditor || !options.editable()) {
return false;
}
return item.can ? item.can(currentEditor) : true;
}
function isActionActive(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor) {
return false;
}
if (action.isActive) {
return action.isActive(currentEditor);
}
if (!action.active) {
return false;
}
return currentEditor.isActive(action.active.name, action.active.attrs);
}
function isMenuItemActive(item: ToolbarMenuItem, currentEditor?: Editor) {
const targetEditor = currentEditor ?? getEditor();
if (!targetEditor || !item.isActive) {
return false;
}
return item.isActive(targetEditor);
}
function runAction(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor || !options.editable()) {
return;
}
if (action.menu || action.palette) {
return;
}
action.action(currentEditor);
}
function runMenuItem(item: ToolbarMenuItem) {
const currentEditor = getEditor();
if (!currentEditor || !options.editable()) {
return;
}
item.action(currentEditor);
}
function applyPaletteColor(action: ToolbarAction, color: string) {
const currentEditor = getEditor();
if (!currentEditor || !action.palette) {
return;
}
action.palette.apply(currentEditor, color);
}
function clearPaletteColor(action: ToolbarAction) {
const currentEditor = getEditor();
if (!currentEditor || !action.palette?.clear) {
return;
}
action.palette.clear(currentEditor);
}
function getToolbarButtonClass(action: ToolbarAction) {
return cn(
'relative rounded-[10px] border border-transparent bg-transparent text-muted-foreground shadow-none',
'transition-[transform,color,background-color,border-color,box-shadow] duration-200 ease-out',
'enabled:hover:-translate-y-px enabled:hover:border-border disabled:opacity-45',
'enabled:hover:bg-accent enabled:hover:text-foreground',
isActionActive(action) &&
'border-primary/30 bg-accent text-primary shadow-primary',
);
}
function getPaletteSwatchClass(action: ToolbarAction, color: string) {
return cn(
'inline-flex size-8 items-center justify-center rounded-full border border-border',
'shadow-accent',
'transition-[transform,box-shadow,border-color] duration-200 ease-out',
'hover:-translate-y-px hover:scale-[1.04]',
getPaletteCurrentColor(action) === color &&
'border-primary shadow-primary',
);
}
function getMenuItemClass(item: ToolbarMenuItem) {
return cn(
'flex items-center gap-2 rounded-lg p-2 text-left text-sm transition-colors',
'disabled:cursor-not-allowed disabled:opacity-45',
isMenuItemActive(item)
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
);
}
return {
applyPaletteColor,
canRunAction,
canRunMenuItem,
clearPaletteColor,
getActionIndicatorColor,
getMenuItemClass,
getPaletteCurrentColor,
getPaletteSwatchClass,
getToolbarButtonClass,
isActionActive,
isMenuItemActive,
runAction,
runMenuItem,
};
}