kt-template-admin/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.tsx

589 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
},
});