589 lines
16 KiB
TypeScript
589 lines
16 KiB
TypeScript
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) => (
|
||
<IconifyIcon class="kt-tiptap-editor__toolbar-icon" icon={icon} />
|
||
);
|
||
|
||
export default defineComponent({
|
||
name: 'KtTiptapHtmlEditor',
|
||
props: {
|
||
disabled: {
|
||
default: false,
|
||
type: Boolean,
|
||
},
|
||
minHeight: {
|
||
default: 360,
|
||
type: [Number, String] as PropType<number | string>,
|
||
},
|
||
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<CSSProperties>(() => ({
|
||
'--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 (
|
||
<ATooltip key={command.key} title={command.title}>
|
||
<AButton
|
||
aria-label={command.title}
|
||
class={[
|
||
'kt-tiptap-editor__toolbar-button',
|
||
active ? 'kt-tiptap-editor__toolbar-button--active' : '',
|
||
]}
|
||
data-kt-tiptap-command={command.key}
|
||
disabled={isToolbarDisabled()}
|
||
onClick={() => runToolbarCommand(command)}
|
||
shape="circle"
|
||
type="text"
|
||
>
|
||
{renderIcon(command.icon)}
|
||
</AButton>
|
||
</ATooltip>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 渲染格式工具栏,按文本、段落、插入、历史分组。
|
||
*/
|
||
const renderToolbar = () => {
|
||
const groups = [
|
||
toolbarCommands.slice(0, 4),
|
||
toolbarCommands.slice(4, 8),
|
||
toolbarCommands.slice(8, 11),
|
||
toolbarCommands.slice(11, 14),
|
||
toolbarCommands.slice(14),
|
||
];
|
||
|
||
return (
|
||
<div class="kt-tiptap-editor__toolbar">
|
||
<ASpace size={4} wrap>
|
||
{groups.map((group, index) => (
|
||
<span class="kt-tiptap-editor__toolbar-group" key={index}>
|
||
{index > 0 ? <ADivider type="vertical" /> : null}
|
||
{group.map((command) => renderCommandButton(command))}
|
||
</span>
|
||
))}
|
||
</ASpace>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 渲染链接与图片输入弹窗。
|
||
*/
|
||
const renderModals = () => (
|
||
<>
|
||
<AModal
|
||
destroyOnHidden
|
||
okText="应用"
|
||
onCancel={() => {
|
||
linkModalOpen.value = false;
|
||
}}
|
||
onOk={confirmLink}
|
||
open={linkModalOpen.value}
|
||
title="设置链接"
|
||
>
|
||
<AInput
|
||
allowClear
|
||
placeholder="https://example.com"
|
||
v-model:value={linkValue.value}
|
||
/>
|
||
</AModal>
|
||
<AModal
|
||
destroyOnHidden
|
||
okText="插入"
|
||
onCancel={() => {
|
||
imageModalOpen.value = false;
|
||
}}
|
||
onOk={confirmImage}
|
||
open={imageModalOpen.value}
|
||
title="插入图片"
|
||
>
|
||
<AInput
|
||
allowClear
|
||
placeholder="https://example.com/image.png"
|
||
v-model:value={imageValue.value}
|
||
/>
|
||
</AModal>
|
||
</>
|
||
);
|
||
|
||
/**
|
||
* 返回当前编辑器实例。
|
||
*/
|
||
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 () => (
|
||
<div
|
||
class={[
|
||
'kt-tiptap-editor',
|
||
readonlyState.value ? 'kt-tiptap-editor--disabled' : '',
|
||
]}
|
||
style={editorStyle.value}
|
||
>
|
||
{renderToolbar()}
|
||
<div class="kt-tiptap-editor__content">
|
||
<TiptapEditorContent editor={editor.value} />
|
||
</div>
|
||
{renderModals()}
|
||
</div>
|
||
);
|
||
},
|
||
});
|