mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-06 10:01:26 +08:00
feat(@vben/plugins): add tiptap rich text editor
This commit is contained in:
@@ -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:*",
|
||||
|
||||
55
packages/effects/plugins/src/tiptap/extensions.ts
Normal file
55
packages/effects/plugins/src/tiptap/extensions.ts
Normal 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'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
4
packages/effects/plugins/src/tiptap/index.ts
Normal file
4
packages/effects/plugins/src/tiptap/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as VbenTiptapPreview } from './preview.vue';
|
||||
export { default as VbenTiptap } from './tiptap.vue';
|
||||
|
||||
export * from './types';
|
||||
33
packages/effects/plugins/src/tiptap/preview.vue
Normal file
33
packages/effects/plugins/src/tiptap/preview.vue
Normal 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>
|
||||
56
packages/effects/plugins/src/tiptap/style.css
Normal file
56
packages/effects/plugins/src/tiptap/style.css
Normal 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);
|
||||
}
|
||||
285
packages/effects/plugins/src/tiptap/tiptap.vue
Normal file
285
packages/effects/plugins/src/tiptap/tiptap.vue
Normal 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>
|
||||
345
packages/effects/plugins/src/tiptap/toolbar.ts
Normal file
345
packages/effects/plugins/src/tiptap/toolbar.ts
Normal 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'),
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
60
packages/effects/plugins/src/tiptap/types.ts
Normal file
60
packages/effects/plugins/src/tiptap/types.ts
Normal 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;
|
||||
}
|
||||
176
packages/effects/plugins/src/tiptap/use-tiptap-toolbar.ts
Normal file
176
packages/effects/plugins/src/tiptap/use-tiptap-toolbar.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user