u
This commit is contained in:
1 parent
78eb811ff7
commit
97fe2ff306
83 files changed
+10083
-3662
No files matched your search
@@ -157,5 +157,9 @@ export interface InputFocusOptions {
|
||||
- **数字模式的取舍**:输入过程保留字符串(否则用户打不出 `12.`),只在 `change` / 失焦时转为 `Number`,空串保持 `''`,避免 `Number('')` 变成 `0`;
|
||||
- 光标位置在数字过滤后会重新计算(`setSelectionRange`),因此在中间插入非法字符不会把光标弹到末尾;
|
||||
- 多行模式的高度拖拽用 Pointer Events + `requestAnimationFrame` 合并更新,拖拽期间改的是内联 `height`(从小高度往大拖不会跳变);
|
||||
- `placeholder` 等原生属性声明为 prop 是刻意的:容器是 `<div>`,如果不声明,`placeholder` 会作为 fallthrough 属性挂到外层容器上,对真实输入框无效;
|
||||
- **`$attrs` 的落点**:根节点是包裹 `<div>`,真正的 `<input>` / `<textarea>` 在内部,所以组件设了 `inheritAttrs: false` 并手工分流 —— 归属输入行为的属性(`id` / `name` / `autocomplete` / 原生事件等)`v-bind` 到**内部控件**,表达布局的 `class` / `style` 留在**包裹层**。这样 `<label for="x">` 能正确聚焦、`@keydown` 的 `event.target` 是真正的输入框;
|
||||
- `placeholder` / `autocomplete` / `maxlength` 这类声明为 prop 是历史原因(早期未做 attrs 分流),保留是为了不打断既有调用方 —— 新代码可直接透传原生属性;
|
||||
- 聚焦样式用 `--g3-color-primary-ring` 画 2px 外环(而非 `outline`),这样在 `Space` / `Grid` 里不会因为 outline 被相邻元素裁掉。
|
||||
|
||||
> 注意:`type` 仅支持 `default` / `password` / `number` / `textarea` 四个值(组件内部据此切换模式与净化逻辑)。传 `url` / `email` 这类原生类型不会生效,会按 `default` 处理 —— 需要在表单里做 URL/邮箱校验时,用 `G3FormItem` 的 `type` 规则。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: RichText 富文本编辑器
|
||||
description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐、文字与背景色、上下标、图片视频上传与表格
|
||||
description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐、文字与背景色、上下标、列表样式、图片视频上传、表格、预览打印、整理格式与源代码
|
||||
---
|
||||
|
||||
# RichText 富文本编辑器
|
||||
@@ -23,12 +23,32 @@ description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐
|
||||
|
||||
## 工具条
|
||||
|
||||
工具条按「一天点几次」而不是「功能重不重要」排布,**始终一行、高度恒定 48px**:
|
||||
工具条按「一天点几次」而不是「功能重不重要」排布,并分成 7 组(组间靠**间距**区分,没有分割线):
|
||||
|
||||
- **常驻**(顺序:历史 → 正文格式 → 行内格式 → 对齐 → 颜色 → 列表与插入):撤销、重做 | 段落格式(正文 / 标题 1-3)| 加粗、斜体、下划线 | 对齐(左 / 中 / 右)| 字体颜色、背景颜色 | 无序列表、有序列表、链接、图片、插入表格;
|
||||
- **收进 `⋯ 更多`**:`字体` / `字号`(二级菜单,选中态跟随光标)、`格式` 组(删除线 / 上标 / 下标 / 引用)、`插入` 组(视频)。菜单项带分组标题与快捷键提示(对着 Tiptap 的真实键位写的,如删除线 `Ctrl+Shift+S`)。
|
||||
- **常驻**(顺序:历史 → 段落格式 → 行内格式 → 对齐 → 颜色 → 列表与插入 → 更多):撤销、重做 | 段落格式(正文 / 标题 1-6)| 加粗、斜体、下划线 | 对齐(左 / 中 / 右)| 字体颜色、背景颜色 | 无序列表、有序列表、图片、链接、插入表格 | `⋯`;
|
||||
- **收进 `⋯ 更多`**:`字体` / `字号`(二级菜单)、`格式` 组(删除线 / 上标 / 下标 / 引用 / **整理格式**)、`插入` 组(视频)、`查看与导出` 组(**预览** / **打印** / **源代码**)。菜单项带分组标题与快捷键提示(对着 Tiptap 的真实键位写的,如删除线 `Ctrl+Shift+S`)。
|
||||
|
||||
图标 16px、按钮 32px(等于 `--g3-control-height`)、组内间隙 4px、内边距 8px / 10px(左右 10px 让首列图标与正文的 12px 内边距对齐);图标前景用 `text-1`(88% 黑)而不是 ghost 按钮默认的 `text-2`(65%),描边保持 lucide 原生粗细 —— 实测「发虚」的主因是浅灰前景而不是线宽,所以只加深颜色、不加重描边。整条工具条自然宽度约 **744px**:760px 以上的容器都是一行,再窄才折行,且折行以「组」为单位,不会把分隔线甩到行首/行尾。表格插入按钮留在工具条上而不是进 `⋯`:它要先选行列,收起后承载不了那个面板。
|
||||
> 无序列表 / 有序列表的**样式**不在「更多」里 —— 它们挂在两个列表按钮自己的下拉上(点按钮展开样式面板),见「列表」一节。
|
||||
|
||||
图标 **18px**、按钮 32px(等于 `--g3-control-height`)、内边距 8px / 10px。图标前景用 `text-1`(88% 黑)而不是 ghost 按钮默认的 `text-2`(65%),描边保持 lucide 原生粗细 —— 实测「发虚」的主因是浅灰前景而不是线宽,所以只加深颜色、不加重描边。
|
||||
|
||||
### 分组与折行
|
||||
|
||||
分组用 `role="group"` + `aria-label` 表达语义,**视觉上不画分割线**(对齐 TinyMCE 的 `tox-toolbar__group`):
|
||||
|
||||
```html
|
||||
<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 进不来」。
|
||||
|
||||
## 对齐与颜色
|
||||
|
||||
@@ -38,7 +58,22 @@ description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐
|
||||
- **字体颜色**(`A` 图标 + 当前色条):写 `color`;
|
||||
- **背景颜色**(荧光笔图标 + 当前色条):写 `background-color`。
|
||||
|
||||
两个取色按钮都是「图标 + 当前色条」的形式,色条显示选区当前的颜色(未设色时是空位)。点开后是预设色板面板:常用色一屏排满,`无颜色` 清除该属性,右侧彩虹色块打开系统取色器选任意颜色。选完色面板自动收起。
|
||||
两个取色按钮都是「图标 + 当前色条」的形式,色条显示选区当前的颜色(未设色时是空位)。点开后是预设色板面板:常用色一屏排满(24px 色块),`无颜色` 清除该属性,右侧彩虹色块打开系统取色器选任意颜色。选完色面板自动收起。
|
||||
|
||||
### 换一套色板
|
||||
|
||||
预设色板走 CSS 变量(`--g3-rich-text-swatch-text-1..10` / `--g3-rich-text-swatch-bg-1..10`),宿主可在任意祖先上覆盖,**不需要改组件代码、不需要发版**:
|
||||
|
||||
```css
|
||||
/* 换成品牌色板 */
|
||||
.g3-rich-text__toolbar {
|
||||
--g3-rich-text-swatch-text-5: #ff4d4f;
|
||||
}
|
||||
```
|
||||
|
||||
变量化只解决「可覆盖」,默认色值不变(仍是 Ant Design 的 6 号色 / 1 号色)。**暗色主题下不会自动换一套色值** —— 正文颜色是文档内容的一部分,同一份 HTML 在不同主题下显示成不同颜色会让「所见即所得」失效;需要暗色调色板的宿主自行覆盖这些变量。
|
||||
|
||||
色板显示走变量,但**写进文档的始终是解析后的具体颜色**(`rgb(...)`),不会把 `var(--g3-…)` 存进 HTML:邮件正文、复制出去的内容、别人家的页面都没有这个变量,存变量会渲染成透明或黑色。
|
||||
|
||||
## 图片 / 视频上传
|
||||
|
||||
@@ -57,6 +92,129 @@ type Upload = (file: File) => Promise<{
|
||||
}>
|
||||
```
|
||||
|
||||
## 链接
|
||||
|
||||
工具条「链接」按钮弹出一个输入面板(不是 `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 }` 一致),用于裁剪档位或本地化文案:
|
||||
|
||||
```vue
|
||||
<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` 调用):
|
||||
|
||||
```vue
|
||||
<G3RichText ref="editor" v-model="html" />
|
||||
<script setup>
|
||||
const editor = ref()
|
||||
editor.value?.openSourceCode()
|
||||
</script>
|
||||
```
|
||||
|
||||
<demo vue="rich-text/tools.vue" />
|
||||
|
||||
## 只读 / 禁用
|
||||
|
||||
`readonly` 可聚焦可复制但不可编辑;`disabled` 整体灰化不可交互。
|
||||
@@ -65,19 +223,29 @@ type Upload = (file: File) => Promise<{
|
||||
|
||||
## 表格
|
||||
|
||||
工具条「插入表格」按钮弹出行 / 列选择面板(支持关闭表头行);光标进入表格后,表格下方会浮出就近操作栏:
|
||||
工具条「插入表格」按钮弹出**网格选择器**(对齐 TinyMCE / Word):移入网格即高亮出 `行 × 列` 的矩形区域,下方实时显示尺寸(如 `3 × 4`),点击即插入;支持**单击**与**拖拽划选**两种用法。网格上限 **8 行 × 10 列**,可选范围由网格本身限定,因此不存在「填了非法值再夹回」的问题。面板里还有「包含表头行」勾选(默认勾选)。
|
||||
|
||||
光标进入表格后,表格下方会浮出就近操作栏:
|
||||
|
||||
- **行**:上方插入行、下方插入行、删除行;
|
||||
- **列**:左侧插入列、右侧插入列、删除列;
|
||||
- **单元格**:合并单元格、拆分单元格(两个独立按钮,不可用时各自置灰);
|
||||
- **表头**:切换表头列、切换表头单元格;
|
||||
- **删除**:整表删除(与增删行列用分隔线隔开,防误点)。
|
||||
|
||||
单元格内文字的对齐、颜色在顶部工具条里设置(单元格内容也是普通段落),操作栏不再重复提供。
|
||||
|
||||
列宽拖拽:悬停列分隔线出现 `col-resize` 光标,左右拖动调整列宽。拖拽采用 `table-layout: fixed`,**只动被拖列、其余列纹丝不动**(Excel 语义);列宽以 `colwidth` 属性持久化进 HTML,最小列宽 10px。拖宽后表格总宽超过容器时,在表格容器内横向滚动,不撑破编辑器内容区。`Tab` / `Shift+Tab` 在单元格间跳转,末格 `Tab` 自动追加行。
|
||||
列宽拖拽:悬停列分隔线出现 `col-resize` 光标,左右拖动调整列宽。拖拽采用 `table-layout: fixed`,**只动被拖列、其余列纹丝不动**(Excel 语义);列宽以 `colwidth` 属性持久化进 HTML,最小列宽 10px。`Tab` / `Shift+Tab` 在单元格间跳转,末格 `Tab` 自动追加行。
|
||||
|
||||
> 行列数限制为 1–20,超出会在插入时自动夹回,避免一次性生成超大表格卡死编辑器。
|
||||
### 为什么插入多列表格不会出滚动条
|
||||
|
||||
插入与拖拽对「总宽超出容器」的处理**刻意相反**:
|
||||
|
||||
| 时机 | 行为 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 插入表格 | 列宽**等比压缩**到容器宽以内 | 刚生成的表由浏览器按内容均分,列一多必然超出;用户要的是「铺满」而不是「横滚」 |
|
||||
| 拖拽列宽结束 | **保留**超宽,超出部分在表格容器内横向滚动 | 用户是故意把列拖宽的,压缩等于立刻撤销他的操作(Excel 语义:表格可变宽) |
|
||||
|
||||
压缩时目标宽度取 `容器宽 - 1px` —— 这是 `border-collapse: collapse` 的舍入修正:折叠边框下浏览器会在表格外沿各补半像素,声明宽度 1060px 的表实际渲染为 1061px,而只要 `scrollWidth > clientWidth`(哪怕 1px)就会出现滚动条。
|
||||
|
||||
## 获取纯文本
|
||||
|
||||
@@ -102,6 +270,8 @@ type Upload = (file: File) => Promise<{
|
||||
| `uploadVideo` | `(file: File) => Promise<RichTextUploadResult>` | — | 视频上传函数,返回完整元数据 |
|
||||
| `fonts` | `RichTextFontOption[]` | 内置字体表 | 「更多」菜单里字体子菜单的选项 |
|
||||
| `fontSizes` | `RichTextFontSizeOption[]` | 内置字号表 | 「更多」菜单里字号子菜单的选项 |
|
||||
| `bulletListTypes` | `RichTextListTypeOption[]` | 内置 4 档 | 无序列表样式子菜单的选项 |
|
||||
| `orderedListTypes` | `RichTextListTypeOption[]` | 内置 6 档 | 有序列表样式子菜单的选项 |
|
||||
|
||||
### Events
|
||||
|
||||
@@ -112,6 +282,9 @@ type Upload = (file: File) => Promise<{
|
||||
| `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 调用)
|
||||
|
||||
@@ -122,6 +295,12 @@ type Upload = (file: File) => Promise<{
|
||||
| `getText` | `() => string` | 纯文本(邮件 text/plain) |
|
||||
| `focus` | `() => void` | 聚焦 |
|
||||
| `clear` | `() => void` | 清空内容 |
|
||||
| `openSourceCode` | `() => void` | 打开「源代码」对话框 |
|
||||
| `openPreview` | `() => void` | 打开「预览」对话框 |
|
||||
| `print` | `() => void` | 打印正文(只打印正文,不带宿主页面) |
|
||||
| `tidyFormat` | `() => RichTextCleanupResult` | 整理格式:清理空段落 / 空白文本 / 空行内记号 |
|
||||
|
||||
> 后四个能力寄居在工具条里(按钮与对话框都在工具条组件内)。因此它们需要工具条被渲染 —— `toolbar: false` 时这些方法安全地什么都不做。
|
||||
|
||||
### 类型定义
|
||||
|
||||
@@ -144,6 +323,21 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
### 样式变量
|
||||
@@ -157,6 +351,25 @@ export interface RichTextFontSizeOption {
|
||||
| `--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`),作用域收紧在组件内;覆盖时在任意祖先上声明即可(就近原则生效)。
|
||||
|
||||
## 实现说明
|
||||
|
||||
@@ -166,8 +379,21 @@ export interface RichTextFontSizeOption {
|
||||
- **对齐**:官方 `@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`)+ 组内 4px 间隙,字体 / 字号与低频功能收进 `⋯` 菜单,自然宽度从 928px 降到 744px;`⋯` 用 `G3Dropdown` 现成的 `type: 'label'`(分组标题)/ `children`(子菜单)/ `shortcut` / `selected` 能力拼出来,与工具条共用同一份 `ToolButton` 定义,激活态与禁用态一致。菜单内容约 295px,靠 Dropdown 默认的视口上限(`calc(100vh - 48px)`)保证面板内部不出滚动条,不需要额外传 `menuMaxHeight`;
|
||||
- **图标分量**:16px 图标 + lucide 原生描边(16px 下约 1.33px),只把前景从 ghost 默认的 `text-2`(65%)提到 `text-1`(88%)。试过描边 2.5 与放大到 24px,都偏重;实测「发虚」的主因是浅灰前景,动颜色这一项性价比最高;
|
||||
- **工具条密度与「更多」**:按钮 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`);
|
||||
- **依赖策略**:Tiptap 核心走 `peerDependencies`,扩展走 `dependencies`,不把编辑器全家桶强塞给所有 `@g3soft/ui` 使用者。
|
||||
- **列表样式走节点属性**:无序写 `<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`)**,否则使用方装包后会报模块缺失。
|
||||
Reference in new issue
Block a user