Skip to content

富文本编辑器(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:

按键macOSWindows 和 Linux
加粗、斜体、下划线⌘B, ⌘I, ⌘UCtrl+B, Ctrl+I, Ctrl+U
删除线⇧⌘XCtrl+Shift+X
代码⌘ECtrl+Shift+E
链接⌘KCtrl+K
编号列表、项目符号列表、任务列表⇧⌘7, ⇧⌘8, ⇧⌘LCtrl+Shift+7, 8, L
增加缩进、减少缩进⌘], ⌘[ (列表中为 Tab 和 ⇧Tab)Ctrl+], Ctrl+[
左对齐、居中、右对齐⌘{, ⌘|, ⌘}Ctrl+L, Ctrl+E, Ctrl+R
标题 1 至 6、正文⌥⌘1 to ⌥⌘6, ⌥⌘0Ctrl+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 写出的内容读回后保持一致。