Files
2026-10-12 17:32:50 +08:00

34 KiB
Raw Permalink Blame History

title, description
title description
RichText 富文本编辑器 基于 Tiptap 的富文本编辑,支持字体、字号、对齐、文字与背景色、上下标、列表样式、图片视频上传、表格、预览打印、整理格式与源代码

RichText 富文本编辑器

RichText 是基于 Tiptap 封装的富文本编辑器,v-model 输出干净的 HTML 字符串,可直接作为邮件正文、公告正文等场景使用。

何时使用

  • 录入需要富文本格式的正文(公告、邮件、通知、帮助文档);
  • 需要字体 / 字号 / 对齐 / 文字颜色 / 背景颜色 / 上标 / 下标等排版能力时;
  • 需要在正文中嵌入图片 / 视频、插入表格时。

纯文本或短文本请用 Input(type="textarea"),不要用富文本承载简单字段。

基础用法

v-model 双向绑定 HTML 字符串,工具条默认置顶,内置字体 / 字号 / 对齐 / 颜色 / 上下标 / 表格 / 撤销重做。

工具条

工具条按「一天点几次」而不是「功能重不重要」排布,并分成 7 组(组间靠间距区分,没有分割线):

  • 常驻(顺序:历史 → 段落格式 → 行内格式 → 对齐 → 颜色 → 列表与插入 → 更多):撤销、重做 | 段落格式(正文 / 标题 1-6)| 加粗、斜体、下划线 | 对齐(左 / 中 / 右)| 字体颜色、背景颜色 | 无序列表、有序列表、图片、链接、插入表格 | ⋯;
  • 收进 ⋯ 更多:字体 / 字号(二级菜单)、格式 组(删除线 / 上标 / 下标 / 引用 / 整理格式)、插入 组(视频)、查看与导出 组(预览 / 打印 / 源代码)。菜单项带分组标题与快捷键提示(对着 Tiptap 的真实键位写的,如删除线 Ctrl+Shift+S)。

无序列表 / 有序列表的样式不在「更多」里 —— 它们挂在两个列表按钮自己的下拉上(点按钮展开样式面板),见「列表」一节。

图标 18px、按钮 32px(等于 --g3-control-height)、内边距 8px / 10px。图标前景用 text-1(88% 黑)而不是 ghost 按钮默认的 text-2(65%),描边保持 lucide 原生粗细 —— 实测「发虚」的主因是浅灰前景而不是线宽,所以只加深颜色、不加重描边。

分组与折行

分组用 role="group" + aria-label 表达语义,视觉上不画分割线(对齐 TinyMCE 的 tox-toolbar__group):

<div role="group" aria-label="行内格式" class="g3-rich-text__group">…</div>

分组容器用了 display: contents —— 组的盒子不参与布局,组内按钮被提升为工具条的直接 flex 项,因此折行以按钮为单位,每行都会填满、不会出现「整组被推到下一行、右边空一大片」。代价是间距不能再用组的 padding / gap 表达,改由按钮的 margin-inline-end 承担:组内 2px、组间 12px(6 倍对比构成分组感)。

用 flex-wrap: wrap + min-width: 0 给组做不到按按钮折行:组对工具条来说始终是一个不可分割的 flex 项,flex-wrap 只影响组内部如何排列。

键盘操作

工具条是 role="toolbar" 的单个 Tab 停靠点(ARIA 作者实践):Tab 进入工具条后,用 ← / → 在按钮间移动(两端循环),Home / End 跳到首 / 尾,再 Tab 离开落到正文。RTL 下左右键按屏幕方向解释。这样避免「20 多个按钮各占一个 Tab 位、键盘用户要按二十几次才能穿过工具条」。

内部用 roving tabindex 实现:焦点所在按钮 tabindex=0,其余 -1;按钮的禁用态(撤销 / 重做、图片上的对齐)变化后会重新归一化,不会出现「全部 -1 导致 Tab 进不来」。

对齐与颜色

工具条提供三组排版能力,均作用于选区所在的块(正文段落 / 标题,表格单元格里的内容同样是段落):

  • 对齐(左 / 居中 / 右):写 text-align 到块元素上;点当前档位即取消对齐,不需要单独的「取消」按钮;
  • 字体颜色(A 图标 + 当前色条):写 color;
  • 背景颜色(荧光笔图标 + 当前色条):写 background-color。

两个取色按钮都是「图标 + 当前色条」的形式,色条显示选区当前的颜色(未设色时是空位)。点开后是预设色板面板:常用色一屏排满(24px 色块),无颜色 清除该属性,右侧彩虹色块打开系统取色器选任意颜色。选完色面板自动收起。

换一套色板

预设色板走 CSS 变量(--g3-rich-text-swatch-text-1..10 / --g3-rich-text-swatch-bg-1..10),宿主可在任意祖先上覆盖,不需要改组件代码、不需要发版:

/* 换成品牌色板 */
.g3-rich-text__toolbar {
  --g3-rich-text-swatch-text-5: #ff4d4f;
}

变量化只解决「可覆盖」,默认色值不变(仍是 Ant Design 的 6 号色 / 1 号色)。暗色主题下不会自动换一套色值 —— 正文颜色是文档内容的一部分,同一份 HTML 在不同主题下显示成不同颜色会让「所见即所得」失效;需要暗色调色板的宿主自行覆盖这些变量。

色板显示走变量,但写进文档的始终是解析后的具体颜色(rgb(...)),不会把 var(--g3-…) 存进 HTML:邮件正文、复制出去的内容、别人家的页面都没有这个变量,存变量会渲染成透明或黑色。

图片 / 视频上传

图片和视频通过 upload-image / upload-video 两个 prop 注入上传函数。上传函数接收 File,返回 { fileUrl, ... } 元数据;上传完成后组件会触发 upload-inline 事件,把内联资源元数据上报给外层(对齐邮件「inline 附件」模型)。

// 上传函数约定
type Upload = (file: File) => Promise<{
  fileUrl: string      // 正文 img / video 的 src
  fileId?: string      // 后端文件 id
  cid?: string         // 内联资源 cid(邮件场景)
  fileName?: string
  fileSize?: number
}>

链接

工具条「链接」按钮弹出一个输入面板(不是 window.prompt),两个字段:链接地址 与 显示文字。填好后回车或点「确定」应用。两个输入框都不设 placeholder —— 字段名已经说明要填什么,示例文案属于噪音。

操作区(一行,「取消 / 确定」始终靠右成组):

位置 按钮 说明
左 移除链接 仅光标已在链接上时出现;危险语义与主操作分开,不让它和「确定」挨着
右 取消 Ghost 低强调,关闭面板且不写入文档
右 确定 Primary 主操作;只要地址非空就可用(见下)

按钮用默认尺寸(32px,等于 --g3-control-height)而不是 size="small"(24px):这是面板里唯一的决定动作,24px 在弹层里偏小,且无法与 G3Input 的 32px 等高、同一行里不齐平。用 justify-content: space-between 而非各组均分,是为了让**「确定」的位置在编辑态 / 非编辑态下始终不变**(编辑态左侧多出「移除链接」,靠右的主操作不该被挤动)。

「显示文字」不是必填(只填地址就能提交)。链接文字按「填的显示文字 → 选中文字 → 地址本身」依次兜底,因此最后一种情况下链接文字就是 URL:

场景 「显示文字」的行为
有选中文字 预填选中的文字;改成别的内容即替换选中文字;留空则沿用原文字
空选区(光标停在某处) 作为链接文字插入(等价于「先打字再选中」);留空则用地址本身当文字
光标已在链接上 预填该链接的文字;改它就替换链接文字,留空则只改地址

其他规则:

  • 地址不带协议时自动补 https://:example.com → https://example.com;已是完整 URL、# 锚点、/ 站内路径、mailto: 的一律不动;
  • 光标已在链接上:面板预填当前地址与链接文字,并多出一个「移除链接」按钮;
  • 为防 XSS,只有 http / https / mailto / tel / # / / 开头的地址会被写入。

右键菜单

在正文的链接上右键,会在鼠标位置弹出操作菜单(在非链接处右键仍走浏览器原生菜单):

菜单项 行为
编辑链接 打开链接面板并预填该链接的地址与文字
打开链接 新标签页打开(带 noopener,noreferrer)
删除链接 只去掉链接、保留文字(想连文字一起删,用选中 + Delete 更直接)

菜单作用于右键落点那条链接,而不是当前光标所在处 —— 右键不一定移动光标,直接按选区操作会删错链接。

列表

工具条上的「无序列表 / 有序列表」是分裂按钮(对齐 TinyMCE 的 bullist / numlist):点图标 = 一键建 / 撤列表,点右侧箭头 = 打开样式面板:

面板 档位
无序列表 默认、实心圆点、空心圆圈、实心方块
有序列表 默认、小写字母、小写罗马数字、小写希腊字母、大写罗马数字、大写字母

样式面板里每一项画成缩略图(三条内容线 + 左侧真实的项目符号 / 编号,与 TinyMCE 图标同一画法),下方标注档位名,一眼扫过就能分辨,不需要记住 disc / circle / square 这些英文枚举。当前档位打勾并高亮。

点某一档即把光标所在的列表设成该样式;光标不在列表里时会先建列表再设样式 ——「想插一个方块列表」一次选择即可,不必先点无序列表再挑样式(内部是两个事务:建列表 + 设样式,按两次 Ctrl+Z 依次退回)。

样式存成属性,不存成类名

样式写进文档的是 HTML 属性(<ul type="square"> / <ol type="I">),而不是 class="g3-list-square"。属性是自描述的:导出的 HTML 到邮件客户端、别人家的页面都保持原样;用类名的话离开本组件的样式表就退化成默认圆点,而富文本正文的第一要求是「导出即正确」。

小写希腊字母是唯一例外:<ol type="lower-greek"> 不是合法的 HTML 属性值(<ol type> 只认 1 / a / A / i / I),浏览器会当作未知值忽略、静默退回数字编号。这一档改用 style="list-style-type: lower-greek" 表达。组件的 OrderedListWithGreek 扩展补齐了官方 Tiptap 在这条路上的两个缺口(读回时不认 lower-greek、写出时会生成非法的 type 属性),因此这一档与其它档位一样能完整往返。

自定义档位

bullet-list-types / ordered-list-types 可传自己的选项(结构与 { label, value, glyph } 一致),用于裁剪档位或本地化文案:

<G3RichText v-model="html" :bullet-list-types="[{ label: '默认', value: '', glyph: '•' }]" />

不传时用内置的 DEFAULT_BULLET_LIST_TYPES / DEFAULT_ORDERED_LIST_TYPES(已从包入口导出,便于在其基础上裁剪)。

预览 / 打印

「更多 → 查看与导出」里有两项:

  • 预览:在模态框里按正文的真实排版只读渲染当前 HTML(标题层级、列表、表格、颜色、对齐都与编辑区一致),弹窗里还有一个「打印」按钮;
  • 打印:只打印正文,不带宿主页面的导航、按钮与工具条。

两者都用模态框 / 隐藏 iframe,而不是 window.open:嵌入式 WebView 与 iframe 里新窗口常被拦截,点了没反应;模态框复用库内 G3Modal,观感与全库一致,E2E 也能直接断言。

预览为什么不是「再开一个编辑器」

预览用 v-html 直接渲染,而不是把内容塞进第二个 Tiptap 实例:后者会把 ProseMirror 的编辑态装饰(空文档占位符、选中节点描边、列宽拖拽手柄)一起带进来,那正是「预览」不想要的。预览区复刻了一份与编辑器内一致的排版规则,保证「编辑时看到的」「预览看到的」「打印出来的」三者一致。

预览打开时会取一份内容快照:预览的语义是「看一眼当前成果」,快照也保证了「从预览里打印」与「屏幕上看到的」是同一份内容。

打印的实现

用隐藏 iframe 装载正文 HTML 后调 iframe.contentWindow.print(),而不是直接 window.print() 打印整个页面(那会把宿主页面的界面一起打出来)。iframe 用「零尺寸 + 移出视口」隐藏,不用 display: none(部分浏览器会跳过渲染、打印出空白页)也不用 visibility: hidden(会被打印管线继承,同样打出白纸)。打印样式内置一份精简的文档排版(@page 页边距、标题层级、表格边框、图片不溢出),表格在纸上用 table-layout: auto(纸上没有「拖列宽」,按内容分配才不会把列压成一条缝)。

整理格式

「更多 → 格式 → 整理格式」清理整篇文档的结构垃圾 —— 它不同于工具条上的橡皮擦(那个只作用于选区的行内记号):

清理项 说明
空段落 粘贴后常见的 <p></p> 连排:视觉上是一块空白,但用户按退格删不掉
空白文本 <p> </p> 里那三个空格 —— 让「看起来是空的」段落躲过空段落判断
空行内记号 <strong> </strong> 这类挂了个寂寞的 mark(Word 粘贴重灾区)

判据走文档模型(「这个段落节点的 content.size 是否为 0」)而不是对 HTML 跑正则:正则会误伤 <p><span></span></p> 这类非空段落,也会漏掉带属性的 <p style="…"></p>。只删真正的空 —— 段落里只要还有一个可见字符就原样保留,只含图片 / 视频 / 表格 / 分割线的段落也不受影响(它们的内容不为空)。

清理在一个事务里完成:撤销栈里只留一步,按一次 Ctrl+Z 即可整体退回。没有任何可清理项时不会 dispatch(撤销栈不被无谓地污染),并通过 cleanup 事件把统计(删了几个空段落、几个空白文本、几个空记号)报给宿主。

源代码

「更多 → 查看与导出 → 源代码」打开一个模态框,直接编辑正文的 HTML,点保存写回编辑器(对齐 TinyMCE 的 code 插件)。

  • 保存是显式的:只有「保存」才写回,改到一半可以直接「取消」关掉;「重置」把草稿恢复成打开时的内容;
  • 写回是一次可撤销的操作:Ctrl+Z 能整体退回改源码之前;
  • 会产生 source-save 事件(参数是写回后的 HTML),宿主可以据此做提示或埋点;
  • 不认识的标签会被过滤:写回走的是 Tiptap 的 setContent,schema 之外的内容会被清洗掉 —— 界面里对此有明确说明。组件只拦「彻底解析失败」的情况(DOMParser 抛错时不允许保存),不规范但能解析的写法交给 schema 处理,不假装能完整往返。

也可以不开工具条菜单、由宿主自己唤起(ref 调用):

<G3RichText ref="editor" v-model="html" />
<script setup>
const editor = ref()
editor.value?.openSourceCode()
</script>

只读 / 禁用

readonly 可聚焦可复制但不可编辑;disabled 整体灰化不可交互。

表格

工具条「插入表格」按钮弹出网格选择器(对齐 TinyMCE / Word):移入网格即高亮出 行 × 列 的矩形区域,下方实时显示尺寸(如 3 × 4),点击即插入;支持单击与拖拽划选两种用法。网格上限 8 行 × 10 列,可选范围由网格本身限定,因此不存在「填了非法值再夹回」的问题。面板里还有「包含表头行」勾选(默认勾选)。

光标进入表格后,表格下方会浮出就近操作栏:

  • 行:上方插入行、下方插入行、删除行;
  • 列:左侧插入列、右侧插入列、删除列;
  • 单元格:合并单元格、拆分单元格(两个独立按钮,不可用时各自置灰);
  • 删除:整表删除(与增删行列用分隔线隔开,防误点)。

单元格内文字的对齐、颜色在顶部工具条里设置(单元格内容也是普通段落),操作栏不再重复提供。

列宽拖拽:悬停列分隔线出现 col-resize 光标,左右拖动调整列宽。拖拽采用 table-layout: fixed,只动被拖列、其余列纹丝不动(Excel 语义);列宽以 colwidth 属性持久化进 HTML,最小列宽 10px。Tab / Shift+Tab 在单元格间跳转,末格 Tab 自动追加行。

为什么插入多列表格不会出滚动条

插入与拖拽对「总宽超出容器」的处理刻意相反:

时机 行为 原因
插入表格 列宽等比压缩到容器宽以内 刚生成的表由浏览器按内容均分,列一多必然超出;用户要的是「铺满」而不是「横滚」
拖拽列宽结束 保留超宽,超出部分在表格容器内横向滚动 用户是故意把列拖宽的,压缩等于立刻撤销他的操作(Excel 语义:表格可变宽)

压缩时目标宽度取 容器宽 - 1px —— 这是 border-collapse: collapse 的舍入修正:折叠边框下浏览器会在表格外沿各补半像素,声明宽度 1060px 的表实际渲染为 1061px,而只要 scrollWidth > clientWidth(哪怕 1px)就会出现滚动条。

获取纯文本

通过 ref 调用 getText() 拿到纯文本(邮件 text/plain 备选),getHTML() 拿 HTML,clear() 清空。

API

Props

名称 类型 默认值 说明
modelValue string '' 双向绑定的 HTML 字符串
placeholder string — 占位文案
disabled boolean false 禁用态
readonly boolean false 只读态:可聚焦、可复制,但不可编辑
toolbar 'top' | 'bottom' | false 'top' 工具条位置;false 隐藏工具条
minHeight number | string 200 编辑区最小高度
maxHeight number | string — 编辑区最大高度(超出滚动)
uploadImage (file: File) => Promise<RichTextUploadResult> — 图片上传函数,返回完整元数据
uploadVideo (file: File) => Promise<RichTextUploadResult> — 视频上传函数,返回完整元数据
fonts RichTextFontOption[] 内置字体表 「更多」菜单里字体子菜单的选项
fontSizes RichTextFontSizeOption[] 内置字号表 「更多」菜单里字号子菜单的选项
bulletListTypes RichTextListTypeOption[] 内置 4 档 无序列表样式子菜单的选项
orderedListTypes RichTextListTypeOption[] 内置 6 档 有序列表样式子菜单的选项

Events

名称 参数 说明
update:modelValue (html: string) 内容变化时触发
focus (event: FocusEvent) 获得焦点
blur (event: FocusEvent) 失去焦点
upload-inline (result: RichTextUploadResult, kind: 'image' | 'video') 内联资源上传完成,上报给外层收集
upload-error (error: Error, kind: 'image' | 'video') 上传失败
source-save (html: string) 「源代码」对话框保存并写回后触发(参数为清洗后的 HTML)
cleanup (result: RichTextCleanupResult) 整理格式执行完成(changed: false 表示本来就没有可清理的内容)
print () 发起打印(工具条菜单或预览弹窗里的「打印」都会触发)

Exposed(通过 ref 调用)

名称 签名 说明
editor Editor 底层 Tiptap 实例(进阶用法)
getHTML () => string 当前 HTML
getText () => string 纯文本(邮件 text/plain)
focus () => void 聚焦
clear () => void 清空内容
openSourceCode () => void 打开「源代码」对话框
openPreview () => void 打开「预览」对话框
print () => void 打印正文(只打印正文,不带宿主页面)
tidyFormat () => RichTextCleanupResult 整理格式:清理空段落 / 空白文本 / 空行内记号

后四个能力寄居在工具条里(按钮与对话框都在工具条组件内)。因此它们需要工具条被渲染 —— toolbar: false 时这些方法安全地什么都不做。

类型定义

export interface RichTextUploadResult {
  fileUrl: string
  fileId?: string
  cid?: string
  fileName?: string
  fileSize?: number
  [key: string]: unknown
}

export interface RichTextFontOption {
  label: string
  value: string
}

export interface RichTextFontSizeOption {
  label: string
  value: string
}

export interface RichTextListTypeOption {
  label: string
  /** 写进文档的值:无序对应 <ul type>、有序对应 <ol type>;空串表示「默认」不写属性 */
  value: string
  /** 菜单里画出来的真实符号(• / ○ / ■ / a. / I. …) */
  glyph: string
}

export interface RichTextCleanupResult {
  removedEmptyParagraphs: number
  trimmedBlankTexts: number
  removedEmptyMarks: number
  changed: boolean
}

样式变量

变量 默认值 说明
--g3-border-color #d9d9d9 容器 / 表格边框
--g3-radius 6px 容器圆角
--g3-fill rgba(0,0,0,.04) 工具条背景、表头背景
--g3-color-primary #18181b 激活态、链接、引用竖线颜色、聚焦时正文区的 2px 描边
--g3-color-primary-soft 主色 12% 按钮激活底、选中单元格底
--g3-rich-text-toolbar-shadow-near rgba(34,47,62,.1) 工具条投影近层(0 2px 2px -2px,toolbar="bottom" 时翻到上方)
--g3-rich-text-toolbar-shadow-far rgba(34,47,62,.07) 工具条投影远层(0 8px 8px -4px 弥散)
--g3-rich-text-swatch-text-1..10 见下 字体颜色预设色板(10 个)
--g3-rich-text-swatch-bg-1..10 见下 背景颜色预设色板(10 个)

预设色板默认值(文字色取 Ant Design 6 号色、背景色取 1 号色):

序号 文字色 背景色
1 #000000 #f5f5f5
2 #434343 #fff1f0
3 #8c8c8c #fff7e6
4 #bfbfbf #feffe6
5 #f5222d #f6ffed
6 #fa8c16 #e6fffb
7 #52c41a #e6f4ff
8 #1677ff #f0f5ff
9 #722ed1 #f9f0ff
10 #eb2f96 #fff0f6

这些变量挂在 .g3-rich-text__toolbar 上(不是 :root),作用域收紧在组件内;覆盖时在任意祖先上声明即可(就近原则生效)。

实现说明

  • 选型 Tiptap:MIT 协议、Vue 3 官方绑定(@tiptap/vue-3)、headless 架构便于贴合 G3 设计体系;相比 HugeRTE(TinyMCE 的 MIT fork)在 Vue 集成与 UI 定制上更契合组件库封装;
  • 不含代码块:从 starter-kit 剔除了 Code / CodeBlock(ERP 场景不需要);
  • 字体 / 字号 / 颜色 / 背景色:用官方 TextStyleKit(内置 setFontFamily / setFontSize / setColor / setBackgroundColor 命令),行高保持关闭;颜色只写 textStyle 记号(<span style="color: …">),不引入额外的 mark;
  • 对齐:官方 @tiptap/extension-text-align,只挂到 heading / paragraph,alignments 限左中右;面板里是 toggleTextAlign,点当前档位即取消;
  • 内容区即编辑区:minHeight / maxHeight 作用在内容区上,可编辑区(.tiptap)用 flex: 1 0 auto 撑满 —— 内容短时整块都能点(点空白即聚焦、光标落到正文末尾),内容长时按内容高度撑开、由内容区滚动且不裁切。注意 EditorContent 会在内容区与可编辑区之间再插一层包裹 DOM,所以 .tiptap 上的 min-height: inherit 取的是那一层的值(恒为 0),撑不高可编辑区;
  • 聚焦高亮只给正文区:编辑器获得焦点时(根节点 :focus-within),正文区四周亮起 2px 主色内描边,工具条不参与 —— 谁可编辑谁亮。用 inset box-shadow 而不是 border(不参与布局,聚焦不挤动正文),也不用 TinyMCE 那种 ::after 覆盖层(正文区就是滚动容器,覆盖层会跟着正文滚走,而滚动容器的阴影不随内容滚动,天然钉在内容盒上);
  • 工具条密度与「更多」:按钮 32px(等于 --g3-control-height),字体 / 字号与低频功能收进 ⋯ 菜单;⋯ 用 G3Dropdown 现成的 type: 'label'(分组标题)/ children(子菜单)/ shortcut / selected 能力拼出来,与工具条共用同一份 ToolButton 定义,激活态与禁用态一致。菜单内容约 295px,靠 Dropdown 默认的视口上限(calc(100vh - 48px))保证面板内部不出滚动条,不需要额外传 menuMaxHeight;
  • 工具条分组用 display: contents:组盒子不参与布局,组内按钮被提升为工具条的直接 flex 项,折行才能以按钮为单位(否则整组被推到下一行、右边空一大片)。代价是间距改由按钮的 margin-inline-end 承担(组内 2px / 组间 12px)。flex-wrap: wrap + min-width: 0 给组做不到这件事 —— 组对工具条始终是一个不可分割的 flex 项;
  • 工具条是可键盘操作的整体:role="toolbar" + roving tabindex,← / → 漫游、Home / End 跳首尾。按钮集合用 DOM 查询取得(按钮由多组数据拼出、还有 Dropdown / Popover 的触发器,声明式清单容易漏),DOM 顺序天然等于方向键该走的顺序。禁用态变化后用 watch(..., { flush: 'post' }) 重新归一化 tabindex —— 否则可能出现「全部 -1」导致 Tab 进不来工具条;
  • 图标分量:18px 图标 + lucide 原生描边(18px 下约 1.5px),只把前景从 ghost 默认的 text-2(65%)提到 text-1(88%)。试过描边 2.5、放大到 24px 都偏重;实测「发虚」的主因是浅灰前景,动颜色这一项性价比最高;
  • 段落格式下拉按真实排版渲染:headingLevels 里带 fontSize / fontWeight,菜单项直接用它们画出「标题 1 是 2em 粗体」的样子,选择变成「看起来像我要的那级」而不是「记住它是几号」。这三个值必须与 RichText.vue 里 .tiptap h1..h6 的规则一致,否则预览会撒谎。用 #menu 插槽而非 :items:DropdownItem 的图标位只适合小图标,塞整块文字排版会被行高与省略号规则裁掉;
  • 色板可覆盖 / 色值必解析:预设色板挂 CSS 变量(--g3-rich-text-swatch-*)以便宿主换肤,但写进文档的色值在点击时用 getComputedStyle 从色块解析成具体 rgb(...) —— 正文里存 var(--g3-…) 是错的(邮件 / 复制出去的内容没有这个变量)。选中环的判断同样走这套解析(resolveSwatchColor),保证「点了哪个色块」与「哪个色块显示为选中」一致;两侧色值的比较先统一归一化成 rgb(),否则文档里的 #f5222d 与变量渲染出的 rgb(245,34,45) 永远对不上;
  • 链接面板替代 window.prompt:prompt 观感割裂(全库只有它用原生弹窗)、无法校验(javascript: URL 会被直接写进 href)、嵌入式环境常被拦截、E2E 无法对 UI 断言。改为 G3Popover + 「链接地址 / 显示文字」两个输入框,光标已在链接上时多出「移除链接」。地址只放行 http / https / mailto / tel / # / / 开头的值。可提交性只看地址(「显示文字」不是必填):链接文字按「填的显示文字 → 选中文字 → 地址本身」兜底,所以只填地址也能插入,用地址当文字;
  • 改链接文字必须先落文字再选中再加链接:insertContent 会把选区塌缩到插入内容之后,此时直接 setLink 挂的是空区间(等于没加),所以要显式 TextSelection.create 回选刚落下的文字。而「编辑已有链接」要分两条路:文字没变就只 extendMarkRange + setLink 原地改 href;文字变了要先 extendMarkRange 选中整条链接再替换 —— 若照搬「在光标处插入」,会把一个链接劈成三段(断点两边各留一段旧 href);
  • 链接右键菜单(LinkMenu.vue):在正文的 <a> 上右键弹出「编辑链接 / 打开链接 / 删除链接」,带图标。没有复用 G3Popover 的 trigger="contextmenu" —— 那个模式要求浮层包住触发器元素,而 <a> 由 ProseMirror 渲染、随时重建,塞不进任何插槽;改用与 TableMenu 相同的手法(监听编辑器 DOM 的右键 + position: fixed 到鼠标位置 + popup 档 z-index)。删除只去链接、保留文字,且操作前把选区移到落点那个 <a> 上 —— 右键不一定移动光标,否则会删错链接;
  • 视频自写 node:Tiptap 官方无视频扩展,VideoNode.ts 定义了轻量 video block node,输出 <video controls> 标签;邮件场景由业务层决定是否降级为「封面图 + 附件」(编辑器不绑定邮件语义);
  • 内联资源上报:图片 / 视频上传后 emit('upload-inline', result, kind),外层把 result 收集进 inlines 数组,与邮件「inline 附件」模型对齐(参考 Email-Vue 的 @upload-image);
  • 列表样式走节点属性:无序写 <ul type="disc|circle|square">、有序写 <ol type="a|i|I|A">,不写类名 —— 属性自描述,导出的 HTML 离开组件样式表也正确。样式面板挂在两个列表按钮自己的下拉上(分裂按钮:点图标 toggleList 建/撤列表,点右侧箭头打开 ListTypeIcon 画出的 TinyMCE 同款缩略图面板)。点某一档时若光标不在列表里会先建列表再设样式。 ⚠️ 这两步必须分两个事务执行、不能串在同一条 chain 里:updateAttributes 是按 state.doc(本次事务开始前的文档)定位目标节点的,串在一起时它扫的还是「还没有列表」的旧文档,会静默什么都不做(表现为「选了方块列表,结果只是建了个默认圆点列表」)。代价是撤销栈里两步,但这正是「建列表」与「调样式」两件事的诚实反映。updateAttributes 清样式必须显式传 null(Tiptap 的 type 默认值是 null,传 undefined 会被跳过、旧样式留着);
  • 小写希腊字母要自写扩展:<ol type="lower-greek"> 是非法 HTML 值(<ol type> 只认 1 / a / A / i / I),浏览器会静默退回数字。OrderedListWithGreek(extensions.ts)在官方 OrderedList 上补了两处:renderHTML 对 CSS-only 档位改写 style="list-style-type: …" 而不写非法 type,addAttributes().type.parseHTML 在官方逻辑(不认 lower-greek)返回空时补认它。注册时必须在 StarterKit 里关掉内置的 orderedList,否则同名扩展重复注册会直接报错;
  • 整理格式走文档模型而不是正则:判据是「块节点的内容里有没有非空文本 / 非叶子节点」(content.size === 0 不足以覆盖 <p><strong> </strong></p>)。正则会误伤 <p><span></span></p>、也会漏掉带属性的空段落。多个待删区间先归一化合并再从后往前删 —— 嵌套区间(空段落 [5,8] 与段内空白文本 [6,7])若只维护一个游标会把外层段落判为「已覆盖」而跳过(这正是初版写的反了的地方);取消空 mark 的区间要用 tr.mapping 映射到删除后的坐标,否则会取消到错误位置。整篇清理合成一个事务,撤销栈里只留一步;
  • 预览用 v-html 而不是第二个编辑器实例:后者会把编辑态装饰(占位符、选中节点描边、列宽手柄)一起带进来。预览区复刻一份与 .tiptap 一致的排版规则,保证「编辑时 / 预览 / 打印」三者观感一致。打开时取内容快照,使「从预览里打印」与「屏幕上看到的」是同一份内容;
  • 打印用隐藏 iframe:直接 window.print() 会把宿主的导航与按钮一起打出来。iframe 用「零尺寸 + 移出视口」隐藏 —— 不能用 display: none(部分浏览器跳过渲染、打印空白),也不能用 visibility: hidden(会被打印管线继承,同样打出白纸)。srcdoc 必须等 load 事件后再 print(),否则文档还没解析完、打出空白页;清理挂在 afterprint 上并用 setTimeout 兜底(WebKit 的 print() 不阻塞);
  • 源代码对话框:只有「保存」写回(改到一半可直接取消),写回走 setContent 并保持一次可撤销;校验只用 DOMParser 拦「彻底解析失败」,不规范但能解析的写法交给 Tiptap 的 schema 清洗,界面里如实告知「不认识的标签会被过滤」,不假装能完整往返;
  • 依赖策略:Tiptap 扩展(@tiptap/extension-* / starter-kit)走 dependencies,随 @g3soft/ui 自动装上。@tiptap/extension-list 是显式声明的依赖(OrderedListWithGreek 直接 import 它)—— 它虽然也是 starter-kit 的传递依赖,但 pnpm 的严格隔离下传递依赖不可直接 import,不声明会在构建时解析失败;@tiptap/core / @tiptap/pm / @tiptap/vue-3 目前在 devDependencies,但构建产物会 import '@tiptap/vue-3' —— 发布前必须把它们移到 peerDependencies(或 dependencies),否则使用方装包后会报模块缺失。