From adab3662757b411896a7fc4ce0ae03ed0fd13aa3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 1 Jul 2026 16:58:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0Tiptap=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/package.json | 11 + .../richText/KtTiptapHtmlEditor.scss | 134 ++++ .../richText/KtTiptapHtmlEditor.spec.tsx | 381 ++++++++++++ .../richText/KtTiptapHtmlEditor.tsx | 588 ++++++++++++++++++ .../src/components/richText/index.ts | 6 + pnpm-lock.yaml | 448 +++++++++++++ pnpm-workspace.yaml | 11 + 7 files changed, 1579 insertions(+) create mode 100644 apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.scss create mode 100644 apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.spec.tsx create mode 100644 apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor.tsx create mode 100644 apps/web-antdv-next/src/components/richText/index.ts 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 () => ( +
+ + {html.value} + +
+ ); + }, + }); + + 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 ( +
+ + {groups.map((group, index) => ( + + {index > 0 ? : null} + {group.map((command) => renderCommandButton(command))} + + ))} + +
+ ); + }; + + /** + * 渲染链接与图片输入弹窗。 + */ + 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