diff --git a/apps/web-antdv-next/package.json b/apps/web-antdv-next/package.json
index 00a37a8..19390bd 100644
--- a/apps/web-antdv-next/package.json
+++ b/apps/web-antdv-next/package.json
@@ -29,6 +29,17 @@
"@antdv-next/icons": "catalog:",
"@milkdown/crepe": "catalog:",
"@tanstack/vue-query": "catalog:",
+ "@tiptap/extension-image": "catalog:",
+ "@tiptap/extension-link": "catalog:",
+ "@tiptap/extension-placeholder": "catalog:",
+ "@tiptap/extension-table": "catalog:",
+ "@tiptap/extension-table-cell": "catalog:",
+ "@tiptap/extension-table-header": "catalog:",
+ "@tiptap/extension-table-row": "catalog:",
+ "@tiptap/extension-text-align": "catalog:",
+ "@tiptap/extension-underline": "catalog:",
+ "@tiptap/starter-kit": "catalog:",
+ "@tiptap/vue-3": "catalog:",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben/access": "workspace:*",
diff --git a/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.scss b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.scss
new file mode 100644
index 0000000..12ce7b4
--- /dev/null
+++ b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.scss
@@ -0,0 +1,134 @@
+.kt-tiptap-editor {
+ display: flex;
+ min-height: var(--kt-tiptap-min-height, 360px);
+ width: 100%;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ background: hsl(var(--background));
+
+ &--disabled {
+ opacity: 0.72;
+ }
+
+ &__toolbar {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 10px;
+ border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--background));
+ }
+
+ &__toolbar-button {
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
+ align-items: center;
+ justify-content: center;
+
+ &--active {
+ color: hsl(var(--primary));
+ background: hsl(var(--accent));
+ }
+ }
+
+ &__toolbar-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &__toolbar-icon {
+ width: 16px;
+ height: 16px;
+ }
+
+ &__content {
+ min-height: calc(var(--kt-tiptap-min-height, 360px) - 47px);
+ flex: 1;
+ overflow: auto;
+ }
+
+ &__prosemirror {
+ min-height: calc(var(--kt-tiptap-min-height, 360px) - 47px);
+ padding: 18px 22px;
+ outline: none;
+
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > *:last-child {
+ margin-bottom: 0;
+ }
+
+ p {
+ margin: 0 0 12px;
+ line-height: 1.75;
+ }
+
+ h1,
+ h2,
+ h3 {
+ margin: 18px 0 12px;
+ font-weight: 600;
+ line-height: 1.35;
+ }
+
+ blockquote {
+ margin: 12px 0;
+ padding-left: 14px;
+ border-left: 3px solid hsl(var(--border));
+ color: hsl(var(--muted-foreground));
+ }
+
+ pre {
+ overflow: auto;
+ padding: 14px;
+ border-radius: 6px;
+ background: hsl(var(--muted));
+ font-size: 13px;
+ line-height: 1.6;
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 6px;
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ }
+
+ th,
+ td {
+ min-width: 80px;
+ padding: 8px 10px;
+ border: 1px solid hsl(var(--border));
+ vertical-align: top;
+ }
+
+ th {
+ background: hsl(var(--muted));
+ font-weight: 600;
+ }
+
+ .is-empty::before {
+ float: left;
+ height: 0;
+ color: hsl(var(--muted-foreground));
+ content: attr(data-placeholder);
+ pointer-events: none;
+ }
+ }
+
+ .ProseMirror-selectednode {
+ outline: 2px solid hsl(var(--primary));
+ }
+}
diff --git a/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx
new file mode 100644
index 0000000..3018cb8
--- /dev/null
+++ b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx
@@ -0,0 +1,381 @@
+/* @vitest-environment happy-dom */
+/* eslint-disable vue/one-component-per-file */
+
+import type { ComponentPublicInstance, Ref } from 'vue';
+
+import { mount } from '@vue/test-utils';
+import { defineComponent, h, nextTick, ref } from 'vue';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import KtTiptapHtmlEditor from './KtTiptapHtmlEditor';
+
+type FakeEditorOptions = {
+ content?: string;
+ editable?: boolean;
+ onBlur?: () => void;
+ onFocus?: () => void;
+ onUpdate?: (payload: { editor: FakeEditor }) => void;
+};
+
+type TiptapExpose = ComponentPublicInstance & {
+ getHtml: () => string;
+ setHtml: (value: string) => void;
+};
+
+/**
+ * 模拟 Tiptap Editor 的最小行为,支持组件测试 HTML 同步和禁用态。
+ */
+class FakeEditor {
+ public html = '';
+
+ public commands = {
+ focus: () => true,
+ setContent: (value: string) => {
+ this.html = value;
+ return true;
+ },
+ };
+
+ public destroyed = false;
+
+ public editable = true;
+
+ public options: FakeEditorOptions;
+
+ /**
+ * 记录传入配置并初始化测试用 HTML。
+ *
+ * @param options 编辑器初始化配置,测试只关心内容、可编辑态和更新回调。
+ */
+ constructor(options: FakeEditorOptions) {
+ this.options = options;
+ this.html = options.content || '';
+ this.editable = options.editable !== false;
+ }
+
+ public chain = () => ({
+ focus: () => ({
+ run: () => true,
+ setImage: ({ src }: { src: string }) => ({
+ run: () => {
+ this.html = `
`;
+ this.options.onUpdate?.({ editor: this });
+ return true;
+ },
+ }),
+ setLink: ({ href }: { href: string }) => ({
+ run: () => {
+ this.html = `
${href}
`;
+ this.options.onUpdate?.({ editor: this });
+ return true;
+ },
+ }),
+ toggleBold: () => ({
+ run: () => {
+ this.html = '粗体
';
+ this.options.onUpdate?.({ editor: this });
+ return true;
+ },
+ }),
+ }),
+ });
+
+ /**
+ * 标记编辑器已销毁,便于组件卸载路径保持可调用。
+ */
+ public destroy() {
+ this.destroyed = true;
+ }
+
+ /**
+ * 返回当前 HTML,模拟 Tiptap 的 getHTML。
+ */
+ public getHTML() {
+ return this.html;
+ }
+
+ /**
+ * 查询命令激活态,测试中默认未激活。
+ */
+ public isActive() {
+ return false;
+ }
+
+ /**
+ * 同步只读状态给测试替身。
+ *
+ * @param value false 表示 readonly/disabled。
+ */
+ public setEditable(value: boolean) {
+ this.editable = value;
+ }
+}
+
+const editorRefs: Array[> = [];
+
+vi.mock('@tiptap/vue-3', () => ({
+ EditorContent: defineComponent({
+ name: 'MockEditorContent',
+ props: {
+ editor: {
+ default: null,
+ type: Object,
+ },
+ },
+ /**
+ * 渲染当前 FakeEditor HTML,使测试能观察组件传入内容。
+ *
+ * @param props EditorContent 入参,包含当前编辑器实例。
+ */
+ setup(props) {
+ return () =>
+ h('div', {
+ class: 'kt-tiptap-editor__prosemirror ProseMirror',
+ innerHTML: (props.editor as FakeEditor | null)?.getHTML() || '',
+ });
+ },
+ }),
+ useEditor: (options: FakeEditorOptions) => {
+ const editor = ref(new FakeEditor(options));
+ editorRefs.push(editor);
+ return editor;
+ },
+}));
+
+vi.mock('antdv-next', () => ({
+ Button: defineComponent({
+ name: 'MockButton',
+ props: {
+ disabled: Boolean,
+ type: {
+ default: undefined,
+ type: String,
+ },
+ },
+ emits: ['click'],
+ /**
+ * 用原生 button 替代 antdv-next Button,保留 attrs/disabled/click。
+ *
+ * @param props 按钮入参,测试只读取 disabled。
+ * @param context Vue setup context,透传 attrs 和 slots。
+ * @param context.attrs 透传到原生 button 的属性。
+ * @param context.emit click 事件发送器。
+ * @param context.slots 按钮默认插槽。
+ */
+ setup(props, { attrs, emit, slots }) {
+ return () =>
+ h(
+ 'button',
+ {
+ ...attrs,
+ disabled: props.disabled,
+ onClick: () => emit('click'),
+ type: 'button',
+ },
+ slots.default?.(),
+ );
+ },
+ }),
+ Divider: defineComponent({
+ name: 'MockDivider',
+ /**
+ * 渲染工具栏分隔符占位。
+ */
+ setup() {
+ return () => h('span', { class: 'mock-divider' });
+ },
+ }),
+ Input: defineComponent({
+ name: 'MockInput',
+ props: {
+ value: {
+ default: '',
+ type: String,
+ },
+ },
+ emits: ['update:value'],
+ /**
+ * 用 input 替代 antdv-next Input,保留 value 双向更新。
+ *
+ * @param props 输入框入参,测试只使用 value。
+ * @param context Vue setup context,发送 update:value。
+ * @param context.emit update:value 事件发送器。
+ */
+ setup(props, { emit }) {
+ return () =>
+ h('input', {
+ value: props.value,
+ onInput: (event: Event) =>
+ emit('update:value', (event.target as HTMLInputElement).value),
+ });
+ },
+ }),
+ Modal: defineComponent({
+ name: 'MockModal',
+ props: {
+ open: Boolean,
+ title: {
+ default: '',
+ type: String,
+ },
+ },
+ emits: ['cancel', 'ok', 'update:open'],
+ /**
+ * 模拟 Modal,只在 open=true 时渲染内容。
+ *
+ * @param props 弹窗入参,测试仅关心 open。
+ * @param context Vue setup context。
+ * @param context.slots 弹窗默认插槽。
+ */
+ setup(props, { slots }) {
+ return () =>
+ props.open ? h('div', { role: 'dialog' }, slots.default?.()) : null;
+ },
+ }),
+ Space: defineComponent({
+ name: 'MockSpace',
+ /**
+ * 以 div 包装 Space 内容。
+ *
+ * @param _props 未使用的 Space props。
+ * @param context Vue setup context,读取默认 slot。
+ * @param context.slots Space 默认插槽。
+ */
+ setup(_props, { slots }) {
+ return () => h('div', slots.default?.());
+ },
+ }),
+ Tooltip: defineComponent({
+ name: 'MockTooltip',
+ props: {
+ title: {
+ default: '',
+ type: String,
+ },
+ },
+ /**
+ * 直接渲染 Tooltip 默认 slot,避免测试依赖浮层实现。
+ *
+ * @param _props 未使用的 Tooltip props。
+ * @param context Vue setup context,读取默认 slot。
+ * @param context.slots Tooltip 默认插槽。
+ */
+ setup(_props, { slots }) {
+ return () => h('span', slots.default?.());
+ },
+ }),
+}));
+
+vi.mock('@vben/icons', () => ({
+ IconifyIcon: defineComponent({
+ name: 'MockIconifyIcon',
+ props: {
+ icon: {
+ default: '',
+ type: String,
+ },
+ },
+ /**
+ * 渲染可识别的图标占位。
+ *
+ * @param props 图标入参,测试不读取但保留 data 属性。
+ */
+ setup(props) {
+ return () => h('i', { 'data-icon': props.icon });
+ },
+ }),
+}));
+
+describe('ktTiptapHtmlEditor', () => {
+ beforeEach(() => {
+ editorRefs.length = 0;
+ });
+
+ it('renders incoming html content', () => {
+ const wrapper = mount(KtTiptapHtmlEditor, {
+ props: {
+ modelValue: ']旧正文
',
+ },
+ });
+
+ expect(wrapper.find('.kt-tiptap-editor__prosemirror').html()).toContain(
+ '旧正文
',
+ );
+ });
+
+ it('emits normalized html from setHtml', async () => {
+ const wrapper = mount(KtTiptapHtmlEditor, {
+ props: {
+ modelValue: '旧正文
',
+ },
+ });
+
+ (wrapper.vm as unknown as TiptapExpose).setHtml(
+ '新标题
新正文
',
+ );
+ await nextTick();
+
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
+ '新标题
新正文
',
+ ]);
+ expect(wrapper.emitted('change')?.at(-1)).toEqual([
+ '新标题
新正文
',
+ '旧正文
',
+ ]);
+ expect((wrapper.vm as unknown as TiptapExpose).getHtml()).toBe(
+ '新标题
新正文
',
+ );
+ });
+
+ it('marks disabled editor and disables bold command', () => {
+ const wrapper = mount(KtTiptapHtmlEditor, {
+ props: {
+ disabled: true,
+ modelValue: '旧正文
',
+ },
+ });
+
+ expect(wrapper.classes()).toContain('kt-tiptap-editor--disabled');
+ expect(
+ wrapper.find('[data-kt-tiptap-command="bold"]').attributes('disabled'),
+ ).toBeDefined();
+ });
+
+ it('syncs setHtml changes through a v-model parent', async () => {
+ const Parent = defineComponent({
+ name: 'TiptapParentHarness',
+ /**
+ * 建立父子 v-model 绑定,验证编辑器 emit 会回写父级状态。
+ */
+ setup() {
+ const html = ref('旧正文
');
+ const editorRef = ref(null);
+
+ return () => (
+
+
+
+
+
+ );
+ },
+ });
+
+ const wrapper = mount(Parent);
+ await wrapper.find('[data-testid="parent-update"]').trigger('click');
+ await nextTick();
+
+ expect(wrapper.find('[data-testid="parent-html"]').text()).toBe(
+ '新标题
新正文
',
+ );
+ });
+});
diff --git a/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.tsx b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.tsx
new file mode 100644
index 0000000..877e969
--- /dev/null
+++ b/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.tsx
@@ -0,0 +1,588 @@
+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);
+ 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);
+ },
+ );
+
+ watch(readonlyState, (value) => {
+ editor.value?.setEditable(!value);
+ });
+
+ onBeforeUnmount(() => {
+ editor.value?.destroy();
+ });
+
+ return () => (
+
+ {renderToolbar()}
+
+
+
+ {renderModals()}
+
+ );
+ },
+});
diff --git a/apps/web-antdv-next/src/components/richText/index.ts b/apps/web-antdv-next/src/components/richText/index.ts
new file mode 100644
index 0000000..e68ea4c
--- /dev/null
+++ b/apps/web-antdv-next/src/components/richText/index.ts
@@ -0,0 +1,6 @@
+export { default as KtTiptapHtmlEditor } from './KtTiptapHtmlEditor';
+export type {
+ KtTiptapHtmlEditorEmit,
+ KtTiptapHtmlEditorExpose,
+ KtTiptapHtmlEditorProps,
+} from './KtTiptapHtmlEditor';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 40062db..26c618e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,6 +57,39 @@ catalogs:
'@tanstack/vue-store':
specifier: ^0.8.0
version: 0.8.0
+ '@tiptap/extension-image':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-link':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-placeholder':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-table':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-table-cell':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-table-header':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-table-row':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-text-align':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/extension-underline':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/starter-kit':
+ specifier: ^3.27.1
+ version: 3.27.1
+ '@tiptap/vue-3':
+ specifier: ^3.27.1
+ version: 3.27.1
'@types/archiver':
specifier: ^6.0.4
version: 6.0.4
@@ -536,6 +569,39 @@ importers:
'@tanstack/vue-query':
specifier: 'catalog:'
version: 5.92.9(vue@3.5.27(typescript@5.9.3))
+ '@tiptap/extension-image':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-link':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-placeholder':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-table':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-table-cell':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-table-header':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-table-row':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-text-align':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-underline':
+ specifier: 'catalog:'
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/starter-kit':
+ specifier: 'catalog:'
+ version: 3.27.1
+ '@tiptap/vue-3':
+ specifier: 'catalog:'
+ version: 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.27(typescript@5.9.3))
'@vben-core/menu-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/menu-ui
@@ -3588,6 +3654,189 @@ packages:
peerDependencies:
vue: ^3.5.27
+ '@tiptap/core@3.27.1':
+ resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
+ peerDependencies:
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-blockquote@3.27.1':
+ resolution: {integrity: sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-bold@3.27.1':
+ resolution: {integrity: sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-bubble-menu@3.27.1':
+ resolution: {integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-bullet-list@3.27.1':
+ resolution: {integrity: sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==}
+ peerDependencies:
+ '@tiptap/extension-list': 3.27.1
+
+ '@tiptap/extension-code-block@3.27.1':
+ resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-code@3.27.1':
+ resolution: {integrity: sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-document@3.27.1':
+ resolution: {integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-dropcursor@3.27.1':
+ resolution: {integrity: sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==}
+ peerDependencies:
+ '@tiptap/extensions': 3.27.1
+
+ '@tiptap/extension-floating-menu@3.27.1':
+ resolution: {integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==}
+ peerDependencies:
+ '@floating-ui/dom': ^1.0.0
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-gapcursor@3.27.1':
+ resolution: {integrity: sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==}
+ peerDependencies:
+ '@tiptap/extensions': 3.27.1
+
+ '@tiptap/extension-hard-break@3.27.1':
+ resolution: {integrity: sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-heading@3.27.1':
+ resolution: {integrity: sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-horizontal-rule@3.27.1':
+ resolution: {integrity: sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-image@3.27.1':
+ resolution: {integrity: sha512-+JTahgQT+NxiGjduaB3qJVyhU/wh4m3pVkht1Earioku2bm/apj5Lb8rSowa/NJYP3B+oQgV/V4YLw5dtDgBoA==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-italic@3.27.1':
+ resolution: {integrity: sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-link@3.27.1':
+ resolution: {integrity: sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-list-item@3.27.1':
+ resolution: {integrity: sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==}
+ peerDependencies:
+ '@tiptap/extension-list': 3.27.1
+
+ '@tiptap/extension-list-keymap@3.27.1':
+ resolution: {integrity: sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==}
+ peerDependencies:
+ '@tiptap/extension-list': 3.27.1
+
+ '@tiptap/extension-list@3.27.1':
+ resolution: {integrity: sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-ordered-list@3.27.1':
+ resolution: {integrity: sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==}
+ peerDependencies:
+ '@tiptap/extension-list': 3.27.1
+
+ '@tiptap/extension-paragraph@3.27.1':
+ resolution: {integrity: sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-placeholder@3.27.1':
+ resolution: {integrity: sha512-lhcNDcczQ75yJOSywCHb58Hmtg1aPL/2TdYmeLPxVrP048D7rRs133sfONcgyyw0AvhnfmPOkHLTv3QtKSowhw==}
+ peerDependencies:
+ '@tiptap/extensions': 3.27.1
+
+ '@tiptap/extension-strike@3.27.1':
+ resolution: {integrity: sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-table-cell@3.27.1':
+ resolution: {integrity: sha512-aopweoHugP+ZR5WTgb7mccXXWmjoNv7PBjtQDl8rvS4IWsJE1w23B+rkj7VuoncO8WK5bU3SS9O3l5aObql68w==}
+ peerDependencies:
+ '@tiptap/extension-table': 3.27.1
+
+ '@tiptap/extension-table-header@3.27.1':
+ resolution: {integrity: sha512-+J9EyKP6RMInfMJbIoNemqWzHxZe9XZv/7OjpsvtVZO1cxMTd5W1xIg0Ntr7YMdG9eKBC8Vwx5jjFbRm2tbM3A==}
+ peerDependencies:
+ '@tiptap/extension-table': 3.27.1
+
+ '@tiptap/extension-table-row@3.27.1':
+ resolution: {integrity: sha512-peEcbNRrJJ9Qc/WNKQNFbkwBrJvBvq22Dp5WNqEygn/ZJez1qD9ctd10+D6f3b9GtSyx12sfDgn8yBufWa+Iug==}
+ peerDependencies:
+ '@tiptap/extension-table': 3.27.1
+
+ '@tiptap/extension-table@3.27.1':
+ resolution: {integrity: sha512-tNB8kjxo0+XPremnWkd6NUikpeJ+JQrss7HntL8GgIxX4t0gkdnLn9yUWb0JzcaYP7Y8iTZZHdkPs0P154DG8Q==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-text-align@3.27.1':
+ resolution: {integrity: sha512-EXawuJBO55wd8WcTbHTMoPhv0CGQxza4yCCPB5Hqz4ZPQwahIr3ej+8yp/kimIl0xokabwZ0/Fu8STQ4AkZv5g==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-text@3.27.1':
+ resolution: {integrity: sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extension-underline@3.27.1':
+ resolution: {integrity: sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+
+ '@tiptap/extensions@3.27.1':
+ resolution: {integrity: sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/pm@3.27.1':
+ resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
+
+ '@tiptap/starter-kit@3.27.1':
+ resolution: {integrity: sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==}
+
+ '@tiptap/vue-3@3.27.1':
+ resolution: {integrity: sha512-o5GB6hfUnyf9sCB306rHWmaIYRL+02ROX657EkuY8tEWKHMTuMjHWl2AqHMP47wz0W9DaMOJLvcPpYdAEKq3Mw==}
+ peerDependencies:
+ '@floating-ui/dom': ^1.0.0
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+ vue: ^3.5.27
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -6436,6 +6685,9 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ linkifyjs@4.3.3:
+ resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
+
listhen@1.9.0:
resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==}
hasBin: true
@@ -11983,6 +12235,200 @@ snapshots:
'@tanstack/virtual-core': 3.13.18
vue: 3.5.27(typescript@5.9.3)
+ '@tiptap/core@3.27.1(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-blockquote@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-bold@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.5
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+ optional: true
+
+ '@tiptap/extension-bullet-list@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-code@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-dropcursor@3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.5
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+ optional: true
+
+ '@tiptap/extension-gapcursor@3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-hard-break@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-heading@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-horizontal-rule@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-image@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-italic@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+ linkifyjs: 4.3.3
+
+ '@tiptap/extension-list-item@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-list-keymap@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-paragraph@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-placeholder@3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-strike@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-table-cell@3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-table': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-table-header@3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-table': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-table-row@3.27.1(@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/extension-table': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-table@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/extension-text-align@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-text@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extension-underline@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+
+ '@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/pm@3.27.1':
+ dependencies:
+ prosemirror-changeset: 2.4.1
+ prosemirror-commands: 1.7.1
+ prosemirror-dropcursor: 1.8.2
+ prosemirror-gapcursor: 1.4.1
+ prosemirror-history: 1.5.0
+ prosemirror-inputrules: 1.5.1
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.7
+ prosemirror-schema-list: 1.5.1
+ prosemirror-state: 1.4.4
+ prosemirror-tables: 1.8.5
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+
+ '@tiptap/starter-kit@3.27.1':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/extension-blockquote': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-bold': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-bullet-list': 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-code': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-document': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-dropcursor': 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-gapcursor': 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-hard-break': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-heading': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-horizontal-rule': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-italic': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-list-item': 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-list-keymap': 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-ordered-list': 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
+ '@tiptap/extension-paragraph': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-strike': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-text': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extension-underline': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
+ '@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+
+ '@tiptap/vue-3@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.27(typescript@5.9.3))':
+ dependencies:
+ '@floating-ui/dom': 1.7.5
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+ vue: 3.5.27(typescript@5.9.3)
+ optionalDependencies:
+ '@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+ '@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -15172,6 +15618,8 @@ snapshots:
lines-and-columns@1.2.4: {}
+ linkifyjs@4.3.3: {}
+
listhen@1.9.0:
dependencies:
'@parcel/watcher': 2.5.6
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 2cd463d..bf28b23 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -39,6 +39,17 @@ catalog:
'@tailwindcss/typography': ^0.5.19
'@tanstack/vue-query': ^5.92.9
'@tanstack/vue-store': ^0.8.0
+ '@tiptap/extension-image': ^3.27.1
+ '@tiptap/extension-link': ^3.27.1
+ '@tiptap/extension-placeholder': ^3.27.1
+ '@tiptap/extension-table': ^3.27.1
+ '@tiptap/extension-table-cell': ^3.27.1
+ '@tiptap/extension-table-header': ^3.27.1
+ '@tiptap/extension-table-row': ^3.27.1
+ '@tiptap/extension-text-align': ^3.27.1
+ '@tiptap/extension-underline': ^3.27.1
+ '@tiptap/starter-kit': ^3.27.1
+ '@tiptap/vue-3': ^3.27.1
'@types/archiver': ^6.0.4
'@types/eslint': ^9.6.1
'@types/html-minifier-terser': ^7.0.2