外观
富文本编辑器(Rich text editor)
ui/richtext 包编辑带属性的文本,如同 macOS 上的 NSTextView,以及绑定到 AttributedString 的 SwiftUI TextEditor。它支持:
- 格式: 加粗、斜体、下划线、删除线、行内代码、字体、字号和颜色。
- 链接: 点击即可打开,可由应用或按 ⌘K 设置。
- 段落: 标题、项目符号列表、编号列表和任务列表、引用、代码块和对齐。
- 附件: 行内图片。
- 编辑: 文字处理软件的快捷键和"格式"菜单,以及能连同文本一起恢复格式的撤销。
- 交换: 以富文本格式复制粘贴,按当前输入属性组合的输入法,Markdown 导出,以及能朗读格式的屏幕阅读器。
它只布局和绘制视野内的段落,因此编辑长文档与短文档一样轻松。
go
import "github.com/egoist/mygo/ui/richtext"
// 由应用保留,如同其其他状态。
app.doc = richtext.NewDocument(richtext.NewText("Hello"))
// 在视图中:
if richtext.Editor(c, app.doc).Fill().Changed() {
app.save(app.doc.Text())
}notes 示例是一个带有工具栏、链接弹出面板和"格式"菜单的笔记应用,它将笔记保存为 JSON。
文本
Text 是带属性的文本:由段落组成,每段有一个 ParagraphStyle,段落由片段(run)构成,每个片段有自己的 Attributes。它是一个值,如同字符串。它的方法返回新文本,被调用的原文本保持不变,并与新文本共享所有未改变的部分。保留一个版本用于撤销、稍后保存或比较,只需付出一个指针的代价,并且可以在生成其他版本的同时从任意 goroutine 读取某个版本。
go
note := richtext.TextOf(
richtext.Paragraph{Style: richtext.ParagraphStyle{Heading: 1}, Runs: []richtext.Run{{Text: "Groceries"}}},
richtext.Paragraph{Style: richtext.ParagraphStyle{List: richtext.Tasks}, Runs: []richtext.Run{
{Text: "Milk, "},
{Text: "oat", Attributes: richtext.Attributes{Weight: 700}},
}},
)
note = note.Format(0, 9, func(a *richtext.Attributes) { a.Color = ui.RGB(37, 99, 235) })
note = note.ReplaceString(note.Len(), note.Len(), "\nBread", richtext.Attributes{})偏移以 rune 计数,包含换行符,与 Element.TextSelection 一致。读取文本:
String和PlainText给出其字符;Paragraphs和Runs给出其结构;AttributesAt和ParagraphStyleAt描述某个 rune。
修改文本:
Replace和ReplaceString修改其文本;Format修改某个范围的属性;FormatParagraphs修改某个范围所触及段落的样式。
保持零值的属性采用编辑器的样式:其字体、字号和颜色。Code、Link 和 Tag(应用加在片段上的自有标记,如提及或评论)只涵盖它们所涵盖的内容:在其边缘键入的文本不会加入它们。
段落的样式及其换行符的属性属于该段落,如同文字处理软件中的规则:空段落会记住键入到那里的文本将获得的样式。段落合并或拆分时,每个段落采用其首个 rune 所属段落的样式,如同 AppKit 对段落样式的处理:
- 删除标题后的换行符会保留标题。
- 连同换行符一起删除整个标题,会留下其后的段落。
- 粘贴到段落开头的文本带来其自身的样式。
列表是扁平的,如同 Quill 和 Automerge:段落的 Indent 就是其层级,相邻的条目构成一个列表,每一层都从 1 开始编号(第一层以下使用字母和罗马数字)。
段落的 ID 是应用为它起的名字,作为应用所保存的相关数据(如笔记或时间线)的键。编辑器不显示它,并且它在编辑过程中始终跟随段落的文本:
- 拆分段落时,ID 保留在前半部分;若是在段落开头按 Enter 使段落下移,则 ID 保留在后半部分。另一半没有 ID。
- 合并两个段落时保留第一个的 ID,除非第一个为空。
- 粘贴或插入的段落没有 ID:
Replace从不采用其插入文本中的 ID,因此不会出现两个段落共用一个 ID。
撤销和重做会连同文本一起恢复 ID,JSON 会保存它们。剪贴板、HTML、Markdown 和 RTF 则不包含 ID。可以在创建文本时给出 ID(TextOf、UnmarshalJSON),或让文档为编辑产生的段落命名,在产生该段落的撤销步骤中进行:
go
app.doc = richtext.NewDocument(chapter)
app.doc.IdentifyParagraphs(func() string { return rand.Text() })
// 保存时,每个段落都有 ID:
for start, p := range app.doc.Text().Paragraphs() {
app.store.Save(p.Style.ID, start, p)
}MarshalJSON 和 UnmarshalJSON 保存文本。Markdown 和 HTML 导出它;ParseMarkdown、ParseHTML 和 ParseRTF 读取 Markdown、HTML 和 RTF,如同粘贴时的处理。
文档
Document 是编辑器所编辑的对象:一个文本、其选区以及在该处键入文本将获得的属性,还有撤销历史。应用跨帧保留它。其方法可从任意 goroutine 安全调用,因此工具栏、菜单或定时器可以在编辑器显示它的同时对其进行格式化和编辑。编辑器会在下一帧跟进。来自其他 goroutine 的更改请通过 Window.Update 调度,以便产生新的一帧。
命令像编辑器的快捷键一样作用于选区。在范围上,它们作为一个撤销步骤修改其中的片段或段落。在插入符处,字符格式只改变输入属性:接下来键入的文本会获得这些属性,而插入符的下一次移动会使它们失效,如同 NSTextView 中的 ⌘B。
go
f := app.doc.Formatting() // 选区的格式状态,供工具栏使用
bold := ui.Button(c, "B")
if f.Bold == richtext.FormatOn {
bold.Background(t.Accent.Alpha(0.18))
}
if bold.KeepFocus().Clicked() {
app.doc.ToggleBold()
}Formatting 以 FormatOn、FormatOff 或 FormatMixed 给出每种字符格式,并附上选区所在的链接及其首段的样式。以下方法会修改文档:
| 方法 | 作用 |
|---|---|
ToggleBold、ToggleItalic、ToggleUnderline、ToggleStrikethrough、ToggleCode | 打开某个格式;当整个选区都具有该格式时将其关闭 |
Format(fn) | 修改任意属性,如字体、字号或颜色 |
SetLink(url) | 为选区添加链接、修改插入符处的链接,或插入该 URL;"" 移除链接 |
SetHeading(level)、ToggleList(kind)、ToggleQuote、ToggleCodeBlock、Indent(levels)、SetAlign(a) | 修改所选段落 |
FormatParagraphs(fn) | 修改任意段落样式 |
ToggleChecked(i) | 勾选包含 rune i 的任务项 |
InsertText、Insert、InsertAttachment | 替换选区 |
Edit(fn) | 以任何方式修改文档,作为一个撤销步骤 |
Undo、Redo、BreakUndoCoalescing | 前后回退/前进各步骤 |
SetText | 替换文本(如打开另一篇笔记),并清除历史 |
IdentifyParagraphs(newID) | 为没有 ID 的段落以及编辑产生的段落命名 |
Edit 在一个 Edit 对象上运行函数,其 Replace、ReplaceString、Format、FormatParagraphs 和 Select 作为一个撤销步骤修改文档。选区会跟随变化,如同 AttributedString 跟踪索引的方式:插入文本处的插入符会移到其后,范围的末尾不会扩大。
go
app.doc.Edit(func(e *richtext.Edit) {
e.ReplaceString(0, 0, "Dear Ann,\n", richtext.Attributes{})
e.Format(0, 8, func(a *richtext.Attributes) { a.Italic = true })
})编辑器
Editor 是带有文本区域外观的编辑器:padding、背景、边框和圆角。EditorBase 没有这些,适用于应用自有外观的编辑器。
Changed 报告用户在编辑器中做出的编辑,而不报告应用通过文档做出的修改。EditorOptions 设置:
Label和Placeholder;Font,编辑器的字体和字号;ReadOnly,用户只能选择、复制文本并点击链接;NoMarkdown,关闭 Markdown 快捷格式;OnLink,由应用自己打开链接而非交给系统;OnLinkCommand,在按 ⌘K 时询问链接 URL,如在弹出面板中;TagStyle,为带有Tag的片段设置样式。Theme,文本的外观(参见主题)。
按键与文字处理软件一致,在 macOS 之外用 Control 代替 Command:
| 按键 | macOS | Windows 和 Linux |
|---|---|---|
| 加粗、斜体、下划线 | ⌘B, ⌘I, ⌘U | Ctrl+B, Ctrl+I, Ctrl+U |
| 删除线 | ⇧⌘X | Ctrl+Shift+X |
| 代码 | ⌘E | Ctrl+Shift+E |
| 链接 | ⌘K | Ctrl+K |
| 编号列表、项目符号列表、任务列表 | ⇧⌘7, ⇧⌘8, ⇧⌘L | Ctrl+Shift+7, 8, L |
| 增加缩进、减少缩进 | ⌘], ⌘[ (列表中为 Tab 和 ⇧Tab) | Ctrl+], Ctrl+[ |
| 左对齐、居中、右对齐 | ⌘{, ⌘|, ⌘} | Ctrl+L, Ctrl+E, Ctrl+R |
| 标题 1 至 6、正文 | ⌥⌘1 to ⌥⌘6, ⌥⌘0 | Ctrl+Alt+1 to 6, Ctrl+Alt+0 |
| 上一段、下一段 | ⌥↑, ⌥↓ | Ctrl+↑, Ctrl+↓ |
| 文本开头、文本结尾 | ⌘↑, ⌘↓ | Ctrl+Home, Ctrl+End |
段落的行为与 Notes 和 Google Docs 一致:
- Enter 会延续列表;在空条目上按 Enter 则退出列表。
- 在标题末尾按 Enter 会开始一个正文段落。
- 在列表条目、标题、引用或代码块的开头按 Backspace,会先移除样式,再将该段落与上方段落合并。
- 点击任务项的复选框会勾选它,点击链接会打开它:
http、https或mailto地址(ui.SafeToOpen),Markdown 视图也是这样打开链接的,因为在 Windows 上其他协议和路径会运行程序。设置OnLink后由应用自己打开链接。
Markdown 快捷方式会对所输入的文本施加格式:在段落开头,# 到 ###### 生成标题,- 、* 和 + 生成项目符号列表,1. 生成编号列表,[ ] 和 [x] 生成任务项,> 生成引用,``` 再按 Enter 生成代码块。围绕单词的 **bold**、*italic*、_italic_、`code` 和 ~~strikethrough~~ 会在键入闭合标记时对其内容施加格式;单词内部的下划线(如 snake_case)保持原样。撤销会将快捷格式恢复为所输入的原始文本。
"格式"菜单的角色(RoleFormatMenu,或 RoleBold、RoleAlignCenter 等)会格式化当前拥有键盘焦点的编辑器。"Paste and Match Style"(粘贴并匹配样式)会以输入属性粘贴纯文本。
主题
EditorOptions.Theme 设置文本的外观:正文、弱化文本、链接、插入符和选区的颜色;行内代码和代码块的字体、字号和颜色,以及代码块的圆角与间距;各级标题的字号、字重、颜色及其上下间距;正文段落的首行缩进及段落间距;列表的缩进、其标记和复选框的颜色;引用的竖条和文本;以及 Editor 的 padding,它会取代窗口主题的 padding。保持零值的项会针对当前窗口主题采用 DefaultTheme 的值,因此一个主题只需设置它要改变的部分,并会随窗口进入深色模式:
go
violet := ui.Hex("#7c3aed")
richtext.Editor(c, doc, richtext.EditorOptions{Theme: &richtext.Theme{
Link: violet,
Checkbox: violet,
Headings: [6]richtext.HeadingStyle{{Size: 2.2, Weight: 800}},
}})长度单位是 em,即编辑器字号的倍数,因此主题会随 Font 缩放;但 padding 和引用竖条的宽度以 DIP 计。更改主题会重新排版文本。编辑器显示的颜色会随其片段一并传给屏幕阅读器。
正文段落(非标题、列表项或代码)默认紧凑相连,如同笔记应用。对于散文,可用两种方式使其分隔:
FirstLineIndent缩进正文段落的首行,如同印刷小说(约 1.2 em)。标题、列表、代码块、空段落或采用其他对齐方式的段落(如居中的场景分隔符 "* * *")之后的第一个段落不缩进;引用的第一段也不缩进,因为无需再作区分。只有起始对齐的段落才会缩进。ParagraphSpacing在两个正文段落之间加入间距,如同屏幕上的散文排版。标题保持其自身的上下间距。
go
richtext.EditorBase(c, app.doc, richtext.EditorOptions{Theme: &richtext.Theme{
FirstLineIndent: 1.2,
}})缩进不是一个字符:插入符、选区、点击和方向键都会跳过它,如同文字处理软件中那样。
屏幕上的段落
Document.VisibleParagraphs 返回正在显示该文档的编辑器中视野内的段落:每段的索引、其起始 rune,以及其各行相对于编辑器元素顶部(已计入滚动)的顶部位置和高度,以 DIP 计。在构建视图时读取它,可以让侧栏与段落对齐,如时间线、页边注或段落编号。将侧栏放在编辑器旁边并对齐相同的顶部,在侧栏顶部加上各段落顶部的位置绘制:
go
boxes := app.doc.VisibleParagraphs()
ui.Row(c).Fill().AlignItems(ui.Stretch).Children(func() {
ui.Box(c).Width(40).Clip().Draw(func(p *ui.Painter, r ui.Rect) {
for _, b := range boxes {
p.Fill(ui.Rect{X: r.X + 16, Y: r.Y + b.Top, W: 4, H: b.Height}, t.Accent, 2)
}
})
richtext.EditorBase(c, app.doc).Grow(1)
})这些框就是编辑器在该帧中绘制段落的位置,因为它在读取时就完成了布局。当某帧使段落偏离读取时的位置(如编辑器滚动、宽度变化或显示插入符)时,它会再构建一组框,因此侧栏能自动跟随,应用无需监听滚动。段落上下的间距(标题的边距、段落间距)不计入其框,因此位于其顶部的标记与其首行对齐。VisibleParagraphs 只布局视野内的内容,与绘制相同,所以在长文档中的开销与短文档一样小。在有编辑器显示该文档之前,它返回 nil。
在格式边缘键入
键入的文本采用插入符前一个字符的属性。在段落开头,它采用其后一个字符的属性;替换选区时,采用所选第一个字符的属性。这些是 NSTextView 的规则(在 macOS 27 上实测),也是 ProseMirror、Lexical 和 Quill 的规则:
- 在加粗文本末尾键入会延续加粗;在其开头键入则不会。
- 在链接、代码片段或标记的末尾键入不会延续它。在其内部键入则会。
- 删除插入符之前的文本后,键入的文本会获得被删文本的属性,直到插入符移动:删去一个加粗的单词再键入,新文本就是加粗的。
- 按 Enter 后,键入的文本保留此前键入内容的属性。
附件
附件是一个对象替换字符(U+FFFC),其 Attributes.Attachment 持有一个 Attachment:一张图片,或由应用自行绘制的内容,以行内框的形式布局,立于基线之上。它像单个字符一样被选中、复制和删除,行会绕开它断开。宽于编辑器的框会被等比缩小以适应。
go
photo, _ := ui.DecodeBitmap(data)
app.doc.InsertAttachment(&richtext.Attachment{Image: photo, Name: "Beach", Width: 240})若没有图片也没有 Draw 函数,附件会在框中显示其 Name,如同尚未加载的图片。HTML 和 Markdown 会写出附件的 URL。
复制和粘贴
复制会将选区以三种格式放入剪贴板:
- 编辑器自有格式(
richtext.Format,JSON),MyGo 应用粘贴时完整保留,应用内还包含附件; - HTML;
- 纯文本。
粘贴时读取剪贴板中最丰富的格式:先是编辑器自有格式,然后 HTML、RTF、Markdown(transfer.Markdown,Markdown 视图和应用复制时使用的格式),最后是纯文本。Markdown 视图的选区粘贴时会带上其格式。
- 浏览器、邮件、Word 和 Google Docs 复制出的 HTML,会保留其标题、列表、任务列表、引用、代码块、对齐、加粗、斜体、下划线、删除线、代码和链接。
- TextEdit、Pages 和 WordPad 复制出的不含 HTML 的 RTF,会保留其段落、对齐、列表、加粗、斜体、下划线、删除线和链接。字体、字号和颜色会被忽略,如同 Notes 的做法,以便粘贴的内容采用编辑器自身的样式。"Paste and Match Style"(⇧⌥⌘V)会以输入属性粘贴纯文本。
输入法
输入法在插入符处按输入属性组合文本,如同 AppKit 和 Qt 的显示方式,组合期间带下划线。整个组合与其替换的内容一起构成一个撤销步骤。
无障碍
屏幕阅读器将编辑器作为文本字段来朗读,并带上其片段的格式:
- VoiceOver 读取
AXAttributedStringForRange:字体、颜色、下划线、删除线和链接。 - Orca 读取 ATK 的片段属性。
- Narrator 读取 UI Automation 的 Text 模式,按字符、格式、单词和行朗读,包含每个片段的字体、字重、斜体、行、颜色和标题级别,属性不一致时读出 mixed 值。
屏幕阅读器也会移动选区。
大型文档
文本是块的 rope,旁边配有持久化的段落序列,因此一次编辑只复制它所触及的内容及通往那里的路径,代价为 O(log n)。编辑器只布局视野内的段落,保留其附近段落的布局,并估算其余段落的高度。在含有 10 万个带格式段落的文档中键入一个字符并在 CPU 上绘制该帧,耗时与 1000 段的文档大致相同(ui/richtext 中的 BenchmarkTypeFrame)。
Markdown
Text.Markdown 写出带 GitHub 扩展的 CommonMark:
- 标题、列表、任务列表、引用和围栏代码块;
**bold**、*italic*、~~strikethrough~~、`code`、链接和图片;- 下划线写为
<u>;对于 Markdown 分隔符无法在其所在位置开启或闭合的标记,则写为<strong>、<em>和<del>,例如紧接 "bar" 之前的加粗 "foo."。
对齐、字体和颜色没有对应的 Markdown,会被省略。
ParseMarkdown 能读回它,以及模型或其他应用写出的内容:带 GitHub 扩展和脚注的 CommonMark,使用 Markdown 视图的解析器(ui/markdown/parser)。列表项首段之后的段落缩进在其之下,表格的行是各单元格以制表符分隔的段落。数学公式按原样保留,因为编辑器不显示它。Markdown 写出的内容读回后保持一致。