Compare commits
4 Commits
083d5a4720
...
b813af5af7
| Author | SHA1 | Date | |
|---|---|---|---|
| b813af5af7 | |||
| 1672b9f446 | |||
| 45f87d06d3 | |||
| adab366275 |
@ -50,6 +50,7 @@ pnpm run build:antdv-next
|
|||||||
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
||||||
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
||||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||||
|
- 博客管理 / 文章表单支持 Markdown、富文本 HTML、源码 HTML 三种编辑模式:Markdown 继续使用 Milkdown 并保存 `contentFormat=markdown`;富文本 HTML 使用 Tiptap 并保存 `contentFormat=html`;源码 HTML 用于保留 WordPress/Argon 运行时 DOM,同样保存 `contentFormat=html`。
|
||||||
|
|
||||||
## 部署说明
|
## 部署说明
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,17 @@
|
|||||||
"@antdv-next/icons": "catalog:",
|
"@antdv-next/icons": "catalog:",
|
||||||
"@milkdown/crepe": "catalog:",
|
"@milkdown/crepe": "catalog:",
|
||||||
"@tanstack/vue-query": "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/menu-ui": "workspace:*",
|
||||||
"@vben-core/shadcn-ui": "workspace:*",
|
"@vben-core/shadcn-ui": "workspace:*",
|
||||||
"@vben/access": "workspace:*",
|
"@vben/access": "workspace:*",
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,386 @@
|
|||||||
|
/* @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, options?: { emitUpdate?: boolean }) => {
|
||||||
|
this.html = value;
|
||||||
|
if (options?.emitUpdate !== false) {
|
||||||
|
this.options.onUpdate?.({ editor: this });
|
||||||
|
}
|
||||||
|
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 = `<img src="${src}">`;
|
||||||
|
this.options.onUpdate?.({ editor: this });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setLink: ({ href }: { href: string }) => ({
|
||||||
|
run: () => {
|
||||||
|
this.html = `<p><a href="${href}">${href}</a></p>`;
|
||||||
|
this.options.onUpdate?.({ editor: this });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toggleBold: () => ({
|
||||||
|
run: () => {
|
||||||
|
this.html = '<p><strong>粗体</strong></p>';
|
||||||
|
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<Ref<FakeEditor | null>> = [];
|
||||||
|
|
||||||
|
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: '<p>旧正文</p>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('.kt-tiptap-editor__prosemirror').html()).toContain(
|
||||||
|
'<p>旧正文</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits normalized html from setHtml', async () => {
|
||||||
|
const wrapper = mount(KtTiptapHtmlEditor, {
|
||||||
|
props: {
|
||||||
|
modelValue: '<p>旧正文</p>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(wrapper.vm as unknown as TiptapExpose).setHtml(
|
||||||
|
'<h2>新标题</h2><p>新正文</p>',
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('change')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||||
|
'<h2>新标题</h2><p>新正文</p>',
|
||||||
|
]);
|
||||||
|
expect(wrapper.emitted('change')?.at(-1)).toEqual([
|
||||||
|
'<h2>新标题</h2><p>新正文</p>',
|
||||||
|
'<p>旧正文</p>',
|
||||||
|
]);
|
||||||
|
expect((wrapper.vm as unknown as TiptapExpose).getHtml()).toBe(
|
||||||
|
'<h2>新标题</h2><p>新正文</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks disabled editor and disables bold command', () => {
|
||||||
|
const wrapper = mount(KtTiptapHtmlEditor, {
|
||||||
|
props: {
|
||||||
|
disabled: true,
|
||||||
|
modelValue: '<p>旧正文</p>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<p>旧正文</p>');
|
||||||
|
const editorRef = ref<null | TiptapExpose>(null);
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<section>
|
||||||
|
<KtTiptapHtmlEditor ref={editorRef} v-model={html.value} />
|
||||||
|
<output data-testid="parent-html">{html.value}</output>
|
||||||
|
<button
|
||||||
|
data-testid="parent-update"
|
||||||
|
onClick={() =>
|
||||||
|
editorRef.value?.setHtml('<h2>新标题</h2><p>新正文</p>')
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
更新
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(Parent);
|
||||||
|
await wrapper.find('[data-testid="parent-update"]').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="parent-html"]').text()).toBe(
|
||||||
|
'<h2>新标题</h2><p>新正文</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
6
apps/web-antdv-next/src/components/richText/index.ts
Normal file
6
apps/web-antdv-next/src/components/richText/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { default as KtTiptapHtmlEditor } from './KtTiptapHtmlEditor';
|
||||||
|
export type {
|
||||||
|
KtTiptapHtmlEditorEmit,
|
||||||
|
KtTiptapHtmlEditorExpose,
|
||||||
|
KtTiptapHtmlEditorProps,
|
||||||
|
} from './KtTiptapHtmlEditor';
|
||||||
49
apps/web-antdv-next/src/views/blog/article/list.scss
Normal file
49
apps/web-antdv-next/src/views/blog/article/list.scss
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
.blog-article-modal {
|
||||||
|
width: min(1080px, calc(100vw - 48px)) !important;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-article-form {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content,
|
||||||
|
&__html-rich,
|
||||||
|
&__html-rich .kt-tiptap-editor,
|
||||||
|
&__content .kt-milkdown-editor,
|
||||||
|
&__content .ant-input,
|
||||||
|
&__html-source {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__html-rich .kt-tiptap-editor,
|
||||||
|
&__content .kt-milkdown-editor,
|
||||||
|
&__html-source {
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__html-rich .kt-tiptap-editor {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__html-source {
|
||||||
|
resize: vertical;
|
||||||
|
font-family:
|
||||||
|
"SFMono-Regular",
|
||||||
|
Consolas,
|
||||||
|
"Liberation Mono",
|
||||||
|
monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/web-antdv-next/src/views/blog/article/list.spec.ts
Normal file
30
apps/web-antdv-next/src/views/blog/article/list.spec.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/* @vitest-environment node */
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('list.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
describe('blog article list preview entry', () => {
|
||||||
|
it('keeps the preview row action wired to the hidden iframe route', () => {
|
||||||
|
expect(source).toContain("key: 'preview'");
|
||||||
|
expect(source).toContain("permissionCodes: ['Blog:Article:Preview']");
|
||||||
|
expect(source).toContain("name: 'BlogArticlePreview'");
|
||||||
|
expect(source).toContain('articleId: row.id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires Tiptap editor mode switching without dropping current content', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
"import { KtTiptapHtmlEditor } from '#/components/richText';",
|
||||||
|
);
|
||||||
|
expect(source).toContain('createBlogArticleEditorModeSchema');
|
||||||
|
expect(source).toContain(
|
||||||
|
'void setArticleEditorMode(mode, { preserveContent: true });',
|
||||||
|
);
|
||||||
|
expect(source).toContain(
|
||||||
|
'contentFormat: getContentFormatForEditorMode(mode)',
|
||||||
|
);
|
||||||
|
expect(source).toContain('editorMode: mode');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import type { TableColumnType } from 'antdv-next';
|
import type { TableColumnType } from 'antdv-next';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlogArticleEditorMode,
|
||||||
|
BlogArticleFormValues,
|
||||||
|
} from '../modules/article-form';
|
||||||
|
|
||||||
import type { WordpressBlogApi } from '#/api/blog';
|
import type { WordpressBlogApi } from '#/api/blog';
|
||||||
import type {
|
import type {
|
||||||
KtTableApi,
|
KtTableApi,
|
||||||
@ -9,6 +14,7 @@ import type {
|
|||||||
} from '#/components/ktTable';
|
} from '#/components/ktTable';
|
||||||
|
|
||||||
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
|
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
import { Plus, SvgDownloadIcon } from '@vben/icons';
|
import { Plus, SvgDownloadIcon } from '@vben/icons';
|
||||||
@ -27,9 +33,24 @@ import {
|
|||||||
} from '#/api/blog';
|
} from '#/api/blog';
|
||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
import { KtMilkdownEditor } from '#/components/markdown';
|
import { KtMilkdownEditor } from '#/components/markdown';
|
||||||
|
import { KtTiptapHtmlEditor } from '#/components/richText';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BLOG_ARTICLE_FORM_CLASS,
|
||||||
|
BLOG_ARTICLE_MODAL_CLASS,
|
||||||
|
BLOG_ARTICLE_MODAL_CONTENT_CLASS,
|
||||||
|
buildBlogArticleSubmitPayload,
|
||||||
|
createBlogArticleContentSchema,
|
||||||
|
createBlogArticleEditorModeSchema,
|
||||||
|
getBlogArticleCreateFormDefaults,
|
||||||
|
getBlogArticleEditFormValues,
|
||||||
|
getContentFormatForEditorMode,
|
||||||
|
getRenderedText,
|
||||||
|
} from '../modules/article-form';
|
||||||
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
|
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
|
||||||
|
|
||||||
|
import './list.scss';
|
||||||
|
|
||||||
type TermOption = {
|
type TermOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@ -54,7 +75,9 @@ const articleStatusOptions = [
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'BlogArticleList',
|
name: 'BlogArticleList',
|
||||||
setup() {
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
const editingId = ref<string>();
|
const editingId = ref<string>();
|
||||||
|
const contentEditMode = ref<BlogArticleEditorMode>('markdown');
|
||||||
const categoryOptions = ref<TermOption[]>([]);
|
const categoryOptions = ref<TermOption[]>([]);
|
||||||
const tagOptions = ref<TermOption[]>([]);
|
const tagOptions = ref<TermOption[]>([]);
|
||||||
|
|
||||||
@ -118,14 +141,13 @@ export default defineComponent({
|
|||||||
fieldName: 'excerpt',
|
fieldName: 'excerpt',
|
||||||
label: '摘要',
|
label: '摘要',
|
||||||
},
|
},
|
||||||
|
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
|
||||||
{
|
{
|
||||||
component: KtMilkdownEditor,
|
...createBlogArticleContentSchema(
|
||||||
componentProps: {
|
'markdown',
|
||||||
minHeight: 460,
|
KtMilkdownEditor,
|
||||||
placeholder: '请输入 Markdown 正文',
|
KtTiptapHtmlEditor,
|
||||||
},
|
),
|
||||||
fieldName: 'content',
|
|
||||||
label: '内容',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'Switch',
|
component: 'Switch',
|
||||||
@ -141,7 +163,8 @@ export default defineComponent({
|
|||||||
editingId.value ? '编辑文章' : '新建文章',
|
editingId.value ? '编辑文章' : '新建文章',
|
||||||
);
|
);
|
||||||
const [ArticleModal, articleModalApi] = useVbenModal({
|
const [ArticleModal, articleModalApi] = useVbenModal({
|
||||||
class: 'w-[760px]',
|
class: BLOG_ARTICLE_MODAL_CLASS,
|
||||||
|
contentClass: BLOG_ARTICLE_MODAL_CONTENT_CLASS,
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
await submitArticle();
|
await submitArticle();
|
||||||
@ -149,9 +172,9 @@ export default defineComponent({
|
|||||||
onOpenChange(isOpen: boolean) {
|
onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const { values } = articleModalApi.getData<{
|
const { values } = articleModalApi.getData<{
|
||||||
values?: WordpressBlogApi.ArticleBody;
|
values?: BlogArticleFormValues;
|
||||||
}>();
|
}>();
|
||||||
void resetArticleForm(values || getArticleFormDefaults());
|
void resetArticleForm(values || getBlogArticleCreateFormDefaults());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
|
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
|
||||||
@ -205,6 +228,12 @@ export default defineComponent({
|
|||||||
const rowActions: Array<
|
const rowActions: Array<
|
||||||
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
|
||||||
> = [
|
> = [
|
||||||
|
{
|
||||||
|
key: 'preview',
|
||||||
|
label: '预览',
|
||||||
|
onClick: openPreview,
|
||||||
|
permissionCodes: ['Blog:Article:Preview'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: '编辑',
|
label: '编辑',
|
||||||
@ -284,29 +313,6 @@ export default defineComponent({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRenderedText(value?: string | WordpressBlogApi.RenderedField) {
|
|
||||||
if (!value) return '';
|
|
||||||
if (typeof value === 'string') return stripHtml(value);
|
|
||||||
return stripHtml(value.raw || value.rendered || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditableContent(
|
|
||||||
value?: string | WordpressBlogApi.RenderedField,
|
|
||||||
markdown?: string,
|
|
||||||
) {
|
|
||||||
if (markdown) return markdown;
|
|
||||||
if (!value) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return value.raw || value.rendered || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll(/<[^>]+>/g, '')
|
|
||||||
.replaceAll(' ', ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusOption(status?: string) {
|
function getStatusOption(status?: string) {
|
||||||
return (
|
return (
|
||||||
articleStatusOptions.find((item) => item.value === status) ||
|
articleStatusOptions.find((item) => item.value === status) ||
|
||||||
@ -385,28 +391,66 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArticleFormDefaults(
|
/**
|
||||||
searchValues: ArticleSearchValues = {},
|
* Resets modal form state before applying create or edit values.
|
||||||
): WordpressBlogApi.ArticleBody {
|
* @param values Article values selected for the current modal session.
|
||||||
return {
|
*/
|
||||||
categories: [...(searchValues.categories || [])],
|
async function resetArticleForm(values: BlogArticleFormValues) {
|
||||||
content: '',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
excerpt: '',
|
|
||||||
slug: '',
|
|
||||||
status: 'draft',
|
|
||||||
sticky: false,
|
|
||||||
tags: [...(searchValues.tags || [])],
|
|
||||||
title: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetArticleForm(values: WordpressBlogApi.ArticleBody) {
|
|
||||||
await articleFormApi.resetForm();
|
await articleFormApi.resetForm();
|
||||||
await articleFormApi.setValues(values);
|
await articleFormApi.setValues(values);
|
||||||
await articleFormApi.resetValidate();
|
await articleFormApi.resetValidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles editor mode changes from the form control without clearing the current content draft.
|
||||||
|
* @param mode Editor mode selected by the user.
|
||||||
|
*/
|
||||||
|
function handleArticleEditorModeChange(mode: BlogArticleEditorMode) {
|
||||||
|
void setArticleEditorMode(mode, { preserveContent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the content field between Markdown, rich HTML, and raw WordPress HTML editing.
|
||||||
|
* @param mode Editor mode selected for the current modal session.
|
||||||
|
* @param options Whether the current content draft should be reapplied after schema replacement.
|
||||||
|
* @param options.preserveContent Reapplies the existing content value after the editor component changes.
|
||||||
|
*/
|
||||||
|
async function setArticleEditorMode(
|
||||||
|
mode: BlogArticleEditorMode,
|
||||||
|
options: { preserveContent?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const currentValues = options.preserveContent
|
||||||
|
? await articleFormApi.getValues<BlogArticleFormValues>()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
contentEditMode.value = mode;
|
||||||
|
await articleFormApi.updateSchema([
|
||||||
|
createBlogArticleEditorModeSchema(handleArticleEditorModeChange),
|
||||||
|
createBlogArticleContentSchema(
|
||||||
|
mode,
|
||||||
|
KtMilkdownEditor,
|
||||||
|
KtTiptapHtmlEditor,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextValues: Partial<BlogArticleFormValues> = {
|
||||||
|
contentFormat: getContentFormatForEditorMode(mode),
|
||||||
|
editorMode: mode,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
options.preserveContent &&
|
||||||
|
currentValues &&
|
||||||
|
'content' in currentValues
|
||||||
|
) {
|
||||||
|
nextValues.content = currentValues.content;
|
||||||
|
}
|
||||||
|
await articleFormApi.setValues(nextValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the article modal in Markdown authoring mode with current table filters as defaults.
|
||||||
|
* @param context Optional table context supplied by a toolbar button.
|
||||||
|
*/
|
||||||
async function openCreate(
|
async function openCreate(
|
||||||
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
|
||||||
) {
|
) {
|
||||||
@ -415,37 +459,42 @@ export default defineComponent({
|
|||||||
: await tableApi.getSearchValues();
|
: await tableApi.getSearchValues();
|
||||||
|
|
||||||
editingId.value = undefined;
|
editingId.value = undefined;
|
||||||
articleModalApi
|
const values = getBlogArticleCreateFormDefaults(searchValues);
|
||||||
.setData({ values: getArticleFormDefaults(searchValues) })
|
await setArticleEditorMode(values.editorMode || 'markdown');
|
||||||
.open();
|
articleModalApi.setData({ values }).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: WordpressBlogApi.Article) {
|
/**
|
||||||
|
* Opens the article modal using raw HTML mode for imported WordPress/Argon content.
|
||||||
|
* @param row Article row selected from the table.
|
||||||
|
*/
|
||||||
|
async function openEdit(row: WordpressBlogApi.Article) {
|
||||||
editingId.value = `${row.id}`;
|
editingId.value = `${row.id}`;
|
||||||
articleModalApi
|
const values = getBlogArticleEditFormValues(row);
|
||||||
.setData({
|
await setArticleEditorMode(values.editorMode || 'markdown');
|
||||||
values: {
|
articleModalApi.setData({ values }).open();
|
||||||
categories: row.categories || [],
|
|
||||||
content: getEditableContent(row.content, row.contentMarkdown),
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
excerpt: getRenderedText(row.excerpt),
|
|
||||||
id: row.id,
|
|
||||||
slug: row.slug || '',
|
|
||||||
status: row.status || 'draft',
|
|
||||||
sticky: !!row.sticky,
|
|
||||||
tags: row.tags || [],
|
|
||||||
title: getRenderedText(row.title),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.open();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param row 文章列表当前行,用其数据库 ID 打开专用 Blog Web 预览页。
|
||||||
|
*/
|
||||||
|
function openPreview(row: WordpressBlogApi.Article) {
|
||||||
|
void router.push({
|
||||||
|
name: 'BlogArticlePreview',
|
||||||
|
params: {
|
||||||
|
articleId: row.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and submits the article form with the active content format preserved.
|
||||||
|
*/
|
||||||
async function submitArticle() {
|
async function submitArticle() {
|
||||||
const { valid } = await articleFormApi.validate();
|
const { valid } = await articleFormApi.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const values =
|
const values = await articleFormApi.getValues<BlogArticleFormValues>();
|
||||||
await articleFormApi.getValues<WordpressBlogApi.ArticleBody>();
|
|
||||||
const title = values.title?.trim();
|
const title = values.title?.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
message.warning('请填写文章标题');
|
message.warning('请填写文章标题');
|
||||||
@ -455,9 +504,11 @@ export default defineComponent({
|
|||||||
articleModalApi.lock();
|
articleModalApi.lock();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...buildBlogArticleSubmitPayload(
|
||||||
contentFormat: 'markdown' as const,
|
values,
|
||||||
id: editingId.value,
|
editingId.value,
|
||||||
|
contentEditMode.value,
|
||||||
|
),
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
await (editingId.value
|
await (editingId.value
|
||||||
@ -559,7 +610,7 @@ export default defineComponent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleModal title={modalTitle.value}>
|
<ArticleModal title={modalTitle.value}>
|
||||||
<ArticleForm class="mx-2" />
|
<ArticleForm class={BLOG_ARTICLE_FORM_CLASS} />
|
||||||
</ArticleModal>
|
</ArticleModal>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
175
apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts
Normal file
175
apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/* @vitest-environment happy-dom */
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BLOG_ARTICLE_CONTENT_FIELD_CLASS,
|
||||||
|
BLOG_ARTICLE_FORM_CLASS,
|
||||||
|
BLOG_ARTICLE_HTML_RICH_CLASS,
|
||||||
|
BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
|
||||||
|
buildBlogArticleSubmitPayload,
|
||||||
|
createBlogArticleContentSchema,
|
||||||
|
getBlogArticleCreateFormDefaults,
|
||||||
|
getBlogArticleEditFormValues,
|
||||||
|
getContentFormatForEditorMode,
|
||||||
|
} from './article-form';
|
||||||
|
|
||||||
|
describe('blog article form helpers', () => {
|
||||||
|
it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => {
|
||||||
|
const values = getBlogArticleEditFormValues({
|
||||||
|
categories: ['技术'],
|
||||||
|
contentHtml:
|
||||||
|
'<pre class="wp-block-code hljs-codeblock"><code class="hljs sql"><table class="hljs-ln"><tbody><tr><td class="hljs-ln-line hljs-ln-code">select 1;</td></tr></tbody></table></code><div class="hljs-control"></div></pre>',
|
||||||
|
contentMarkdown: '```sql\nselect 1;\n```',
|
||||||
|
excerpt: '摘要',
|
||||||
|
id: '50',
|
||||||
|
slug: 'wordpress-post',
|
||||||
|
status: 'publish',
|
||||||
|
tags: ['WordPress'],
|
||||||
|
title: 'WordPress 文章',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.editorMode).toBe('html-source');
|
||||||
|
expect(values.contentFormat).toBe('html');
|
||||||
|
expect(values.content).toContain('hljs-codeblock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps existing markdown articles in markdown mode when rendered html is present', () => {
|
||||||
|
const values = getBlogArticleEditFormValues({
|
||||||
|
categories: [],
|
||||||
|
contentHtml: '<h1>标题</h1><p>正文</p>',
|
||||||
|
contentMarkdown: '# 标题\n\n正文',
|
||||||
|
excerpt: '',
|
||||||
|
id: '52',
|
||||||
|
slug: 'markdown-post',
|
||||||
|
status: 'publish',
|
||||||
|
tags: [],
|
||||||
|
title: 'Markdown 文章',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.editorMode).toBe('markdown');
|
||||||
|
expect(values.contentFormat).toBe('markdown');
|
||||||
|
expect(values.content).toBe('# 标题\n\n正文');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps markdown fenced code articles in markdown mode after Argon base rendering', () => {
|
||||||
|
const values = getBlogArticleEditFormValues({
|
||||||
|
categories: [],
|
||||||
|
contentHtml:
|
||||||
|
'<pre class="wp-block-code hljs-codeblock"><code class="hljs typescript">const a = 1;</code></pre>',
|
||||||
|
contentMarkdown: '```ts\nconst a = 1;\n```',
|
||||||
|
excerpt: '',
|
||||||
|
id: '53',
|
||||||
|
slug: 'markdown-code-post',
|
||||||
|
status: 'publish',
|
||||||
|
tags: [],
|
||||||
|
title: 'Markdown 代码文章',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.editorMode).toBe('markdown');
|
||||||
|
expect(values.contentFormat).toBe('markdown');
|
||||||
|
expect(values.content).toBe('```ts\nconst a = 1;\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits plain HTML articles with the rich HTML editor by default', () => {
|
||||||
|
const values = getBlogArticleEditFormValues({
|
||||||
|
categories: [],
|
||||||
|
contentHtml: '<h2>标题</h2><p>正文</p>',
|
||||||
|
excerpt: '',
|
||||||
|
id: '51',
|
||||||
|
slug: 'plain-html-post',
|
||||||
|
status: 'publish',
|
||||||
|
tags: [],
|
||||||
|
title: 'Plain HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.editorMode).toBe('html-rich');
|
||||||
|
expect(values.contentFormat).toBe('html');
|
||||||
|
expect(values.content).toBe('<h2>标题</h2><p>正文</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps new local articles in markdown editor mode', () => {
|
||||||
|
const values = getBlogArticleCreateFormDefaults();
|
||||||
|
|
||||||
|
expect(values.editorMode).toBe('markdown');
|
||||||
|
expect(values.contentFormat).toBe('markdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps markdown as the default editing mode for new local articles', () => {
|
||||||
|
const payload = buildBlogArticleSubmitPayload(
|
||||||
|
{
|
||||||
|
content: '# 标题',
|
||||||
|
contentFormat: 'markdown',
|
||||||
|
editorMode: 'markdown',
|
||||||
|
status: 'draft',
|
||||||
|
title: '新文章',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
'markdown',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
content: '# 标题',
|
||||||
|
contentFormat: 'markdown',
|
||||||
|
title: '新文章',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps html format in update payloads instead of forcing markdown', () => {
|
||||||
|
const payload = buildBlogArticleSubmitPayload(
|
||||||
|
{
|
||||||
|
content: '<pre class="wp-block-code hljs-codeblock"></pre>',
|
||||||
|
contentFormat: 'markdown',
|
||||||
|
editorMode: 'html-source',
|
||||||
|
status: 'publish',
|
||||||
|
title: 'WordPress 文章',
|
||||||
|
},
|
||||||
|
'50',
|
||||||
|
'html-source',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
contentFormat: 'html',
|
||||||
|
id: '50',
|
||||||
|
});
|
||||||
|
expect(payload).not.toHaveProperty('editorMode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps both html editor modes to the persisted html content format', () => {
|
||||||
|
expect(getContentFormatForEditorMode('html-rich')).toBe('html');
|
||||||
|
expect(getContentFormatForEditorMode('html-source')).toBe('html');
|
||||||
|
expect(getContentFormatForEditorMode('markdown')).toBe('markdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the rich HTML component and class for html-rich mode', () => {
|
||||||
|
const schema = createBlogArticleContentSchema(
|
||||||
|
'html-rich',
|
||||||
|
'MarkdownEditor',
|
||||||
|
'RichHtmlEditor',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(BLOG_ARTICLE_FORM_CLASS).toBe('blog-article-form');
|
||||||
|
expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
|
||||||
|
expect(schema.component).toBe('RichHtmlEditor');
|
||||||
|
expect(schema.componentProps).toMatchObject({
|
||||||
|
class: BLOG_ARTICLE_HTML_RICH_CLASS,
|
||||||
|
minHeight: 560,
|
||||||
|
placeholder: '请输入 HTML 富文本正文',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a full-width content form item and a tall raw html textarea', () => {
|
||||||
|
const schema = createBlogArticleContentSchema(
|
||||||
|
'html-source',
|
||||||
|
'MarkdownEditor',
|
||||||
|
'RichHtmlEditor',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
|
||||||
|
expect(schema.component).toBe('Textarea');
|
||||||
|
expect(schema.componentProps).toMatchObject({
|
||||||
|
autoSize: { maxRows: 30, minRows: 18 },
|
||||||
|
class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
280
apps/web-antdv-next/src/views/blog/modules/article-form.ts
Normal file
280
apps/web-antdv-next/src/views/blog/modules/article-form.ts
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { WordpressBlogApi } from '#/api/blog';
|
||||||
|
|
||||||
|
export type BlogArticleEditorMode = 'html-rich' | 'html-source' | 'markdown';
|
||||||
|
export type BlogArticleContentFormat = 'html' | 'markdown';
|
||||||
|
export type BlogArticleFormValues = WordpressBlogApi.ArticleBody & {
|
||||||
|
editorMode?: BlogArticleEditorMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLOG_ARTICLE_FORM_CLASS = 'blog-article-form';
|
||||||
|
export const BLOG_ARTICLE_MODAL_CLASS = 'blog-article-modal';
|
||||||
|
export const BLOG_ARTICLE_MODAL_CONTENT_CLASS = 'blog-article-modal__content';
|
||||||
|
export const BLOG_ARTICLE_CONTENT_FIELD_CLASS = 'blog-article-form__content';
|
||||||
|
export const BLOG_ARTICLE_HTML_RICH_CLASS = 'blog-article-form__html-rich';
|
||||||
|
export const BLOG_ARTICLE_HTML_TEXTAREA_CLASS =
|
||||||
|
'blog-article-form__html-source';
|
||||||
|
export const BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT = 560;
|
||||||
|
|
||||||
|
const ARGON_BASE_CODEBLOCK_PATTERN = /\b(?:wp-block-code|hljs-codeblock)\b/;
|
||||||
|
const SOURCE_ONLY_HTML_PATTERN =
|
||||||
|
/\b(?:hljs-ln|hljs-control|fancybox-wrapper|lazyload|collapse-block|wp-block-(?!code\b)[\w-]+)/;
|
||||||
|
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the content field schema for Markdown, rich HTML, or raw WordPress HTML preservation.
|
||||||
|
* @param mode Current editor mode; source mode uses a raw textarea to avoid runtime DOM rewrites.
|
||||||
|
* @param markdownEditor Markdown editor component used for normal local article authoring.
|
||||||
|
* @param richHtmlEditor Tiptap HTML editor component used for plain HTML authoring.
|
||||||
|
* @returns Vben form schema for the article content field.
|
||||||
|
*/
|
||||||
|
export function createBlogArticleContentSchema(
|
||||||
|
mode: BlogArticleEditorMode,
|
||||||
|
markdownEditor: unknown,
|
||||||
|
richHtmlEditor?: unknown,
|
||||||
|
): VbenFormSchema {
|
||||||
|
const common = {
|
||||||
|
fieldName: 'content',
|
||||||
|
formItemClass: BLOG_ARTICLE_CONTENT_FIELD_CLASS,
|
||||||
|
label: '内容',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'html-source') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
autoSize: { maxRows: 30, minRows: 18 },
|
||||||
|
class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
|
||||||
|
placeholder: '保留 WordPress / Argon HTML 原文,保存时仅做安全清洗',
|
||||||
|
},
|
||||||
|
controlClass: 'w-full',
|
||||||
|
wrapperClass: 'items-stretch',
|
||||||
|
} as VbenFormSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'html-rich') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
component: richHtmlEditor as VbenFormSchema['component'],
|
||||||
|
componentProps: {
|
||||||
|
class: BLOG_ARTICLE_HTML_RICH_CLASS,
|
||||||
|
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
|
||||||
|
placeholder: '请输入 HTML 富文本正文',
|
||||||
|
},
|
||||||
|
controlClass: 'w-full',
|
||||||
|
wrapperClass: 'items-stretch',
|
||||||
|
} as VbenFormSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
component: markdownEditor as VbenFormSchema['component'],
|
||||||
|
componentProps: {
|
||||||
|
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
|
||||||
|
placeholder: '请输入 Markdown 正文',
|
||||||
|
},
|
||||||
|
controlClass: 'w-full',
|
||||||
|
wrapperClass: 'items-stretch',
|
||||||
|
} as VbenFormSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the editor mode selector schema and forwards mode changes to the article list page.
|
||||||
|
* @param onChange Callback that receives the next editor mode selected in the form.
|
||||||
|
* @returns Vben form schema for selecting the article editor mode.
|
||||||
|
*/
|
||||||
|
export function createBlogArticleEditorModeSchema(
|
||||||
|
onChange?: (mode: BlogArticleEditorMode) => void,
|
||||||
|
): VbenFormSchema {
|
||||||
|
return {
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
buttonStyle: 'solid',
|
||||||
|
onChange: (event: { target?: { value?: BlogArticleEditorMode } }) => {
|
||||||
|
const mode = event?.target?.value;
|
||||||
|
if (mode) onChange?.(mode);
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ label: 'Markdown', value: 'markdown' },
|
||||||
|
{ label: '富文本 HTML', value: 'html-rich' },
|
||||||
|
{ label: '源码 HTML', value: 'html-source' },
|
||||||
|
],
|
||||||
|
optionType: 'button',
|
||||||
|
},
|
||||||
|
fieldName: 'editorMode',
|
||||||
|
label: '编辑模式',
|
||||||
|
} as VbenFormSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the UI editor mode back to the API's persisted content format contract.
|
||||||
|
* @param mode Current editor mode from the article form.
|
||||||
|
* @returns API content format saved with the article.
|
||||||
|
*/
|
||||||
|
export function getContentFormatForEditorMode(
|
||||||
|
mode: BlogArticleEditorMode,
|
||||||
|
): BlogArticleContentFormat {
|
||||||
|
return mode === 'markdown' ? 'markdown' : 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses the editor mode for an existing article without forcing Markdown source or imported Argon HTML into the wrong editor.
|
||||||
|
* @param article Article row returned by the local blog API.
|
||||||
|
* @returns Markdown for source-backed articles, source HTML for strong runtime DOM, rich HTML for plain tags.
|
||||||
|
*/
|
||||||
|
export function getBlogArticleEditorMode(
|
||||||
|
article?: Partial<WordpressBlogApi.Article>,
|
||||||
|
): BlogArticleEditorMode {
|
||||||
|
const html = getRenderedValue(article?.contentHtml || article?.content);
|
||||||
|
if (!html) return 'markdown';
|
||||||
|
if (SOURCE_ONLY_HTML_PATTERN.test(html)) return 'html-source';
|
||||||
|
if (hasMarkdownSource(article)) return 'markdown';
|
||||||
|
if (ARGON_BASE_CODEBLOCK_PATTERN.test(html)) return 'html-source';
|
||||||
|
return HTML_TAG_PATTERN.test(html) ? 'html-rich' : 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses the persisted content format for an existing article.
|
||||||
|
* @param article Article row returned by the local blog API.
|
||||||
|
* @returns API content format derived from the detected editor mode.
|
||||||
|
*/
|
||||||
|
export function getBlogArticleContentFormat(
|
||||||
|
article?: Partial<WordpressBlogApi.Article>,
|
||||||
|
): BlogArticleContentFormat {
|
||||||
|
return getContentFormatForEditorMode(getBlogArticleEditorMode(article));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds form defaults for creating a new article from current table filters.
|
||||||
|
* @param searchValues Active table search filters used to prefill category and tag fields.
|
||||||
|
* @param searchValues.categories Active category filters copied into the create form.
|
||||||
|
* @param searchValues.tags Active tag filters copied into the create form.
|
||||||
|
* @returns Article form defaults in Markdown mode.
|
||||||
|
*/
|
||||||
|
export function getBlogArticleCreateFormDefaults(
|
||||||
|
searchValues: {
|
||||||
|
categories?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
} = {},
|
||||||
|
): BlogArticleFormValues {
|
||||||
|
return {
|
||||||
|
categories: [...(searchValues.categories || [])],
|
||||||
|
content: '',
|
||||||
|
contentFormat: 'markdown',
|
||||||
|
editorMode: 'markdown',
|
||||||
|
excerpt: '',
|
||||||
|
slug: '',
|
||||||
|
status: 'draft',
|
||||||
|
sticky: false,
|
||||||
|
tags: [...(searchValues.tags || [])],
|
||||||
|
title: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds edit form values, preserving WordPress/Argon HTML when the article depends on runtime classes.
|
||||||
|
* @param row Article row selected from the article table.
|
||||||
|
* @returns Form values and content format for the edit modal.
|
||||||
|
*/
|
||||||
|
export function getBlogArticleEditFormValues(
|
||||||
|
row: WordpressBlogApi.Article,
|
||||||
|
): BlogArticleFormValues {
|
||||||
|
const editorMode = getBlogArticleEditorMode(row);
|
||||||
|
const contentFormat = getContentFormatForEditorMode(editorMode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: row.categories || [],
|
||||||
|
content:
|
||||||
|
contentFormat === 'html'
|
||||||
|
? getRenderedValue(row.contentHtml || row.content)
|
||||||
|
: getEditableMarkdown(row.content, row.contentMarkdown),
|
||||||
|
contentFormat,
|
||||||
|
editorMode,
|
||||||
|
excerpt: getRenderedText(row.excerpt),
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug || '',
|
||||||
|
status: row.status || 'draft',
|
||||||
|
sticky: !!row.sticky,
|
||||||
|
tags: row.tags || [],
|
||||||
|
title: getRenderedText(row.title),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the API payload while preserving the current content format selected for the modal.
|
||||||
|
* @param values Current form values from Vben Form.
|
||||||
|
* @param editingId Current article id, or undefined for create.
|
||||||
|
* @param editorMode Current editor mode.
|
||||||
|
* @returns Payload accepted by the blog article save/update API.
|
||||||
|
*/
|
||||||
|
export function buildBlogArticleSubmitPayload(
|
||||||
|
values: BlogArticleFormValues,
|
||||||
|
editingId: string | undefined,
|
||||||
|
editorMode: BlogArticleEditorMode,
|
||||||
|
): WordpressBlogApi.ArticleBody {
|
||||||
|
const { editorMode: _editorMode, ...payloadValues } = values;
|
||||||
|
return {
|
||||||
|
...payloadValues,
|
||||||
|
contentFormat: getContentFormatForEditorMode(editorMode),
|
||||||
|
id: editingId,
|
||||||
|
title: values.title?.trim() || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts WordPress rendered fields or strings into readable plain text.
|
||||||
|
* @param value Rendered field or string value.
|
||||||
|
* @returns HTML-free text.
|
||||||
|
*/
|
||||||
|
export function getRenderedText(
|
||||||
|
value?: string | WordpressBlogApi.RenderedField,
|
||||||
|
) {
|
||||||
|
return stripHtml(getRenderedValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a rendered field without stripping HTML.
|
||||||
|
* @param value Rendered field or string value.
|
||||||
|
* @returns Raw/rendered string.
|
||||||
|
*/
|
||||||
|
function getRenderedValue(value?: string | WordpressBlogApi.RenderedField) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return value.raw || value.rendered || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects whether the article still owns an editable Markdown source snapshot.
|
||||||
|
* @param article Article row returned by the local blog API.
|
||||||
|
* @returns true when Admin should prefer Milkdown unless strong runtime-only HTML is present.
|
||||||
|
*/
|
||||||
|
function hasMarkdownSource(article?: Partial<WordpressBlogApi.Article>) {
|
||||||
|
return !!article?.contentMarkdown?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses editable Markdown from an API rendered field and its explicit Markdown source.
|
||||||
|
* @param value Rendered field or string value.
|
||||||
|
* @param markdown Explicit Markdown source saved on local articles.
|
||||||
|
* @returns Markdown text for the Milkdown editor.
|
||||||
|
*/
|
||||||
|
function getEditableMarkdown(
|
||||||
|
value?: string | WordpressBlogApi.RenderedField,
|
||||||
|
markdown?: string,
|
||||||
|
) {
|
||||||
|
if (markdown) return markdown;
|
||||||
|
return getRenderedValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips simple HTML tags from Admin labels and excerpts.
|
||||||
|
* @param value HTML or text value.
|
||||||
|
* @returns Trimmed text.
|
||||||
|
*/
|
||||||
|
function stripHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll(/<[^>]+>/g, '')
|
||||||
|
.replaceAll(' ', ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
448
pnpm-lock.yaml
448
pnpm-lock.yaml
@ -57,6 +57,39 @@ catalogs:
|
|||||||
'@tanstack/vue-store':
|
'@tanstack/vue-store':
|
||||||
specifier: ^0.8.0
|
specifier: ^0.8.0
|
||||||
version: 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':
|
'@types/archiver':
|
||||||
specifier: ^6.0.4
|
specifier: ^6.0.4
|
||||||
version: 6.0.4
|
version: 6.0.4
|
||||||
@ -536,6 +569,39 @@ importers:
|
|||||||
'@tanstack/vue-query':
|
'@tanstack/vue-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.92.9(vue@3.5.27(typescript@5.9.3))
|
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':
|
'@vben-core/menu-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/@core/ui-kit/menu-ui
|
version: link:../../packages/@core/ui-kit/menu-ui
|
||||||
@ -3588,6 +3654,189 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.27
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@ -6436,6 +6685,9 @@ packages:
|
|||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
|
linkifyjs@4.3.3:
|
||||||
|
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||||
|
|
||||||
listhen@1.9.0:
|
listhen@1.9.0:
|
||||||
resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==}
|
resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -11983,6 +12235,200 @@ snapshots:
|
|||||||
'@tanstack/virtual-core': 3.13.18
|
'@tanstack/virtual-core': 3.13.18
|
||||||
vue: 3.5.27(typescript@5.9.3)
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@ -15172,6 +15618,8 @@ snapshots:
|
|||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
|
linkifyjs@4.3.3: {}
|
||||||
|
|
||||||
listhen@1.9.0:
|
listhen@1.9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@parcel/watcher': 2.5.6
|
'@parcel/watcher': 2.5.6
|
||||||
|
|||||||
@ -39,6 +39,17 @@ catalog:
|
|||||||
'@tailwindcss/typography': ^0.5.19
|
'@tailwindcss/typography': ^0.5.19
|
||||||
'@tanstack/vue-query': ^5.92.9
|
'@tanstack/vue-query': ^5.92.9
|
||||||
'@tanstack/vue-store': ^0.8.0
|
'@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/archiver': ^6.0.4
|
||||||
'@types/eslint': ^9.6.1
|
'@types/eslint': ^9.6.1
|
||||||
'@types/html-minifier-terser': ^7.0.2
|
'@types/html-minifier-terser': ^7.0.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user