import type { Editor } from '@tiptap/vue-3';
import type { CSSProperties, PropType, VNodeChild } from 'vue';
import { computed, defineComponent, onBeforeUnmount, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TableRow from '@tiptap/extension-table-row';
import TextAlign from '@tiptap/extension-text-align';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import { Button, Divider, Input, Modal, Space, Tooltip } from 'antdv-next';
import './KtTiptapHtmlEditor.scss';
const AButton = Button as any;
const ADivider = Divider as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASpace = Space as any;
const ATooltip = Tooltip as any;
const TiptapEditorContent = EditorContent as any;
export type KtTiptapHtmlEditorEmit = {
blur: [];
change: [value: string, previousValue: string];
focus: [];
'update:modelValue': [value: string];
};
export interface KtTiptapHtmlEditorExpose {
getEditor: () => Editor | null;
getHtml: () => string;
setHtml: (value: string) => void;
}
export interface KtTiptapHtmlEditorProps {
disabled?: boolean;
minHeight?: number | string;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}
type ToolbarCommandKey =
| 'align-center'
| 'align-left'
| 'align-right'
| 'blockquote'
| 'bold'
| 'bullet-list'
| 'code-block'
| 'heading-2'
| 'image'
| 'italic'
| 'link'
| 'ordered-list'
| 'redo'
| 'table'
| 'underline'
| 'undo';
type ToolbarCommand = {
icon: string;
isActive?: (editor: Editor) => boolean;
key: ToolbarCommandKey;
run: (editor: Editor) => void;
title: string;
};
/**
* 将数字高度转换为 CSS 长度,字符串保持原样。
*
* @param value 组件 minHeight 入参,支持像素数字或 CSS 长度字符串。
* @returns 可写入 CSS 变量的长度值。
*/
function toCssSize(value?: number | string) {
if (value === undefined || value === null || value === '') return undefined;
return typeof value === 'number' ? `${value}px` : value;
}
/**
* 读取编辑器当前 HTML,编辑器未初始化时回退到缓存值。
*
* @param editor 当前 Tiptap 编辑器实例。
* @param fallback 编辑器不可用时返回的 HTML。
* @returns 当前可提交给父级的 HTML。
*/
function readEditorHtml(editor: Editor | null | undefined, fallback: string) {
return editor?.getHTML() || fallback;
}
/**
* 渲染工具栏图标,统一使用 Iconify 图标来源。
*
* @param icon 图标名称,遵循 Vben/Iconify 的 lucide 命名。
* @returns 图标节点。
*/
const renderIcon = (icon: string) => (
);
export default defineComponent({
name: 'KtTiptapHtmlEditor',
props: {
disabled: {
default: false,
type: Boolean,
},
minHeight: {
default: 360,
type: [Number, String] as PropType,
},
modelValue: {
default: '',
type: String,
},
placeholder: {
default: '请输入正文内容',
type: String,
},
readonly: {
default: false,
type: Boolean,
},
},
emits: {
blur: () => true,
change: (_value: string, _previousValue: string) => true,
focus: () => true,
'update:modelValue': (_value: string) => true,
},
/**
* 初始化 Tiptap HTML 编辑器,桥接 toolbar 命令、v-model 和 expose API。
*
* @param props 组件入参,包含 HTML、禁用态、占位符和最小高度。
* @param context Vue setup context,用于发送编辑器事件并暴露实例方法。
* @param context.emit 组件事件发送器,用于同步 HTML、change、focus 和 blur。
* @param context.expose 组件方法暴露器,用于公开编辑器实例与 HTML 读写方法。
*/
setup(props, { emit, expose }) {
const currentHtml = ref(props.modelValue || '');
const linkModalOpen = ref(false);
const linkValue = ref('');
const imageModalOpen = ref(false);
const imageValue = ref('');
const readonlyState = computed(() => props.disabled || props.readonly);
const editorStyle = computed(() => ({
'--kt-tiptap-min-height': toCssSize(props.minHeight),
}));
/**
* 将编辑器 HTML 变更同步给父级,避免重复值产生循环事件。
*
* @param nextHtml 最新 HTML。
* @param previousHtml 变更前 HTML,用于 change 事件。
*/
function emitHtmlChange(
nextHtml: string,
previousHtml = currentHtml.value,
) {
if (nextHtml === previousHtml) {
currentHtml.value = nextHtml;
return;
}
currentHtml.value = nextHtml;
emit('update:modelValue', nextHtml);
emit('change', nextHtml, previousHtml);
}
const editor = useEditor({
content: currentHtml.value,
editable: !readonlyState.value,
editorProps: {
attributes: {
class: 'kt-tiptap-editor__prosemirror',
},
},
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
link: false,
underline: false,
}),
Link.configure({
autolink: true,
defaultProtocol: 'https',
openOnClick: false,
}),
Image.configure({
allowBase64: true,
inline: false,
}),
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder: props.placeholder,
}),
],
onBlur: () => emit('blur'),
onFocus: () => emit('focus'),
onUpdate: ({ editor }) => {
emitHtmlChange(editor.getHTML());
},
});
/**
* 判断工具栏命令是否应禁用。
*
* @returns true 表示编辑器不可编辑或尚未初始化。
*/
function isToolbarDisabled() {
return readonlyState.value || !editor.value;
}
/**
* 运行需要可编辑实例的命令。
*
* @param command 当前工具栏命令。
*/
function runToolbarCommand(command: ToolbarCommand) {
const currentEditor = editor.value;
if (!currentEditor || readonlyState.value) return;
command.run(currentEditor);
}
/**
* 打开链接编辑弹窗,并预填当前选区链接。
*/
function openLinkModal() {
const currentEditor = editor.value;
if (!currentEditor || readonlyState.value) return;
linkValue.value = currentEditor.getAttributes('link')?.href || '';
linkModalOpen.value = true;
}
/**
* 将链接弹窗中的 URL 应用到当前选区。
*/
function confirmLink() {
const currentEditor = editor.value;
if (!currentEditor) return;
const href = linkValue.value.trim();
if (href) {
currentEditor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href })
.run();
} else {
currentEditor.chain().focus().extendMarkRange('link').unsetLink().run();
}
linkModalOpen.value = false;
}
/**
* 打开图片插入弹窗并清空上一轮输入。
*/
function openImageModal() {
if (!editor.value || readonlyState.value) return;
imageValue.value = '';
imageModalOpen.value = true;
}
/**
* 将图片 URL 插入当前光标位置。
*/
function confirmImage() {
const currentEditor = editor.value;
const src = imageValue.value.trim();
if (!currentEditor || !src) {
imageModalOpen.value = false;
return;
}
currentEditor.chain().focus().setImage({ src }).run();
imageModalOpen.value = false;
}
const toolbarCommands: ToolbarCommand[] = [
{
icon: 'lucide:bold',
isActive: (editor) => editor.isActive('bold'),
key: 'bold',
run: (editor) => editor.chain().focus().toggleBold().run(),
title: '加粗',
},
{
icon: 'lucide:italic',
isActive: (editor) => editor.isActive('italic'),
key: 'italic',
run: (editor) => editor.chain().focus().toggleItalic().run(),
title: '斜体',
},
{
icon: 'lucide:underline',
isActive: (editor) => editor.isActive('underline'),
key: 'underline',
run: (editor) => editor.chain().focus().toggleUnderline().run(),
title: '下划线',
},
{
icon: 'lucide:heading-2',
isActive: (editor) => editor.isActive('heading', { level: 2 }),
key: 'heading-2',
run: (editor) =>
editor.chain().focus().toggleHeading({ level: 2 }).run(),
title: '二级标题',
},
{
icon: 'lucide:list',
isActive: (editor) => editor.isActive('bulletList'),
key: 'bullet-list',
run: (editor) => editor.chain().focus().toggleBulletList().run(),
title: '无序列表',
},
{
icon: 'lucide:list-ordered',
isActive: (editor) => editor.isActive('orderedList'),
key: 'ordered-list',
run: (editor) => editor.chain().focus().toggleOrderedList().run(),
title: '有序列表',
},
{
icon: 'lucide:quote',
isActive: (editor) => editor.isActive('blockquote'),
key: 'blockquote',
run: (editor) => editor.chain().focus().toggleBlockquote().run(),
title: '引用',
},
{
icon: 'lucide:code-2',
isActive: (editor) => editor.isActive('codeBlock'),
key: 'code-block',
run: (editor) => editor.chain().focus().toggleCodeBlock().run(),
title: '代码块',
},
{
icon: 'lucide:align-left',
isActive: (editor) => editor.isActive({ textAlign: 'left' }),
key: 'align-left',
run: (editor) => editor.chain().focus().setTextAlign('left').run(),
title: '左对齐',
},
{
icon: 'lucide:align-center',
isActive: (editor) => editor.isActive({ textAlign: 'center' }),
key: 'align-center',
run: (editor) => editor.chain().focus().setTextAlign('center').run(),
title: '居中',
},
{
icon: 'lucide:align-right',
isActive: (editor) => editor.isActive({ textAlign: 'right' }),
key: 'align-right',
run: (editor) => editor.chain().focus().setTextAlign('right').run(),
title: '右对齐',
},
{
icon: 'lucide:link',
isActive: (editor) => editor.isActive('link'),
key: 'link',
run: openLinkModal,
title: '链接',
},
{
icon: 'lucide:image',
key: 'image',
run: openImageModal,
title: '图片',
},
{
icon: 'lucide:table',
key: 'table',
run: (editor) =>
editor
.chain()
.focus()
.insertTable({ cols: 3, rows: 3, withHeaderRow: true })
.run(),
title: '表格',
},
{
icon: 'lucide:undo-2',
key: 'undo',
run: (editor) => editor.chain().focus().undo().run(),
title: '撤销',
},
{
icon: 'lucide:redo-2',
key: 'redo',
run: (editor) => editor.chain().focus().redo().run(),
title: '重做',
},
];
/**
* 渲染一个工具栏命令按钮。
*
* @param command 当前命令配置。
* @returns 工具栏按钮节点。
*/
const renderCommandButton = (command: ToolbarCommand): VNodeChild => {
const currentEditor = editor.value;
const active = currentEditor ? command.isActive?.(currentEditor) : false;
return (
runToolbarCommand(command)}
shape="circle"
type="text"
>
{renderIcon(command.icon)}
);
};
/**
* 渲染格式工具栏,按文本、段落、插入、历史分组。
*/
const renderToolbar = () => {
const groups = [
toolbarCommands.slice(0, 4),
toolbarCommands.slice(4, 8),
toolbarCommands.slice(8, 11),
toolbarCommands.slice(11, 14),
toolbarCommands.slice(14),
];
return (
);
};
/**
* 渲染链接与图片输入弹窗。
*/
const renderModals = () => (
<>
{
linkModalOpen.value = false;
}}
onOk={confirmLink}
open={linkModalOpen.value}
title="设置链接"
>
{
imageModalOpen.value = false;
}}
onOk={confirmImage}
open={imageModalOpen.value}
title="插入图片"
>
>
);
/**
* 返回当前编辑器实例。
*/
function getEditor() {
return editor.value || null;
}
/**
* 读取当前 HTML。
*/
function getHtml() {
return readEditorHtml(editor.value, currentHtml.value);
}
/**
* 通过 Tiptap 重新设置 HTML,并把规范化结果同步给父级。
*
* @param value 需要写入编辑器的 HTML 字符串。
*/
function setHtml(value: string) {
const currentEditor = editor.value;
const previousHtml = currentHtml.value;
if (!currentEditor) {
emitHtmlChange(value, previousHtml);
return;
}
currentEditor.commands.setContent(value, { emitUpdate: false });
emitHtmlChange(readEditorHtml(currentEditor, value), previousHtml);
}
expose({
getEditor,
getHtml,
setHtml,
} satisfies KtTiptapHtmlEditorExpose);
watch(
() => props.modelValue,
(value = '') => {
const nextHtml = value || '';
if (nextHtml === currentHtml.value) return;
currentHtml.value = nextHtml;
editor.value?.commands.setContent(nextHtml, { emitUpdate: false });
},
);
watch(readonlyState, (value) => {
editor.value?.setEditable(!value);
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
return () => (
{renderToolbar()}
{renderModals()}
);
},
});