前言
做了个富文本编辑器,先给大家看看效果: 【潜心研究一年,开发的笔记软件】
之前有个朋友私信我说,既然你的笔记软件收费了,能不能出点富文本的教学?
不是我不想出,实在是太复杂了呀 >_>!
我先写一篇,看看大家能不能看懂,如果写得都能看懂的话,那后面出一期教学又有何妨。
富文本编辑器需要解决以下几个难题(不仅仅):
- 编辑协议(数据结构)
- 选择器
- 数据渲染
- 富文本html解析
- 撤销重做
- 输入法对接
- 复制粘贴
富文本元素
首先看看有哪些富文本元素:
- 文本
- 标题
- 链接
- 公式
- 代码块
- 表格
- 分割线
- 图片
- 列表
- 有序列表
- 引用
对这些元素进行分类,行内元素有:
- 文本
- 标题
- 链接
- 公式
块元素有:
- 代码块
- 表格
- 图片
- 列表
- 有序列表
- 引用
对于行内元素,使用flutter自带的 RichText 即可实现,对于块元素,就需要单独处理。
例如图片元素,可以自定义一个图片 Widget。
数据渲染
为了提高渲染性能,渲染部分设计成局部渲染,按需加载。
即将内容分成行,内容由一行行块元素组成,块元素内部会包含行内元素。
渲染时,每个行块给个预设尺寸,等元素出现到视图中时,才会计算元素的真实尺寸。
当然真实尺寸其实也可以缓存到数据结构中,增加性能,但也会增加数据体积。
对于块元素,用一个抽象类 Block 表示。
整个界面通过 Scrollable + Stack 组件实现。
在滚动视图 Scollable 进行滚动时,需要根据 Block 尺寸计算出现在视野中的 Block。
在 Widget 的 layout 阶段,通过 ViewportOffset 的 applyViewportDimension 和 applyContentDimensions 通知滚动视图刷新视野尺寸和内容尺寸。
因此,我们可以在layout阶段根据预设尺寸计算可能出现在视野中的Block,再去计算出可能现在视野中的Block真实尺寸,进而刷新真实内容尺寸,然后将尺寸交给 ViewportOffset 即可处理滚动了。
有点类似微积分,在滚动过程中不断计算尺寸,ViewportOffset 会自动处理滚动逻辑,无需我们多做什么。
选择器
选择富文本内容时,需要根据点击坐标计算得到光标位置。
如何描述光标位置?不妨想想如何确定光标位置。
光标在哪一行?在这行的那个位置?
对于文本元素,就是第几行 + 第几个文字。
对于图片元素,就是第几行 + 0/1,0指的时图片前面,1指的是图片后面。
对于表格元素,就是第几行 + 第几表格列 + 第几表格行 + 第几个文字,但可以通过数学索引包含表格内行列位置,化简成第几行 + 第几个文字。
其它的元素也都可以化简成第几行+第几个文字的形式,不一一列举。
输入法对接
在 flutter 中,通过 TextInput.attach 即可完成输入法的接入。
在 attach 后,会返回一个 TextInputConnection 对象,通过这个 connect 对象与输入法完成交互。
需要结合 FocusNode,监听 Focus 变化,在聚焦时打开输入法,在非聚焦时关闭输入法。
通过以下代码更新内容到拼音输入法
void updateEditingValue(TextEditingValue value) {
if (!value.composing.isValid) {
if (value.text.isNotEmpty) {
inputCallback?.call(value);
connection?.setEditingState(const TextEditingValue());
} else {
inputStartCallback?.call(value);
}
} else {
inputComposingCallback?.call(value);
}
}
对于桌面端,还需要通过以下代码更新拼音输入法显示位置
void updateInputPosition(
Size editableSize, Rect? caretRect, Rect? composingRect) {
if (caretRect != null && composingRect != null) {
Offset? offset = _snapToPhysicalPixel(caretRect.topLeft);
if (offset != null) {
connection?.setCaretRect(caretRect.shift(offset));
connection?.setComposingRect(composingRect.shift(offset));
}
Matrix4? to = getTransformTo();
if (to != null) {
connection?.setEditableSizeAndTransform(editableSize, to);
}
if (connection?.attached == true) {
connection?.show();
}
}
}
另外,对于ios也要做特殊处理,我还没处理,各位有兴趣的可以去测试下。
复制粘贴
粘贴需要读取系统剪切板,通过 RichClipboard 得到 text 和 html。
如果有 html,证明是富文本,需要解析 html 到自定义的 Block 格式。
通过 Pasteboard.image 得到内存图,如果有内存图片,就需要读取内存图数据,存到自己设计好的文件夹中,然后转换为 ImageBlock,插入到行管理器中即可。
复制时,则将每行的 Block 写到 html 里面,然后将 html 交给 RichClipboard 即可。
Future<void> paste({
bool pasteText = false,
bool pasteHtml = false,
bool pasteMarkdown = false,
}) async {
var data = await RichClipboard.getData();
var text = data.text;
var html = data.html;
Uint8Lis? image;
if (!isMobile) {
image = await Pasteboard.image;
}
}
撤销重做
撤销重做需要定义一个撤销管理器 UndoManager 来记录每一步的动作。
在 UndoManager 用一个 List 存储每一步的 oldInfo 和 newInfo。
用一个 index 存储当前位置用于处理撤销和重做动作。
如果是撤销,则将newInfo转换为oldInfo,index–;
如果是重做,则将oldInfo转换为newInfo,index++;
index为-1,不可撤销;
index=list.length,不可重做。
当然,我做的富文本采用了yjs翻译过来的ydart,就没有做的那么麻烦,不过我做的思维导图组件是这样实现的= =。
class UndoManager {
MindMapController controller;
UndoManager(this.controller);
List<NodeDelta> deltaList = [];
int index = -1;
void add(NodeDelta delta) {
if (index < deltaList.length - 1) {
deltaList.removeRange(index + 1, deltaList.length);
}
deltaList.add(delta);
index++;
}
void undo() {
if (index >= 0) {
deltaList[index].undo(controller);
index--;
}
}
void redo() {
if (index < deltaList.length - 1) {
index++;
deltaList[index].redo(controller);
}
}
bool get canUndo => index >= 0;
bool get canRedo => index < deltaList.length - 1;
}
总结
本期简单介绍了如何 flutter 实现一个富文本,具体要实现其实还是很费脑筋的。
我之前开源的温知笔记有具体实现,虽然代码比较乱,但也勉强可以看一看,代码地址:https://github.com/lyming99/wenznote。
可以点个关注,我会不定期分享一些flutter知识,感谢各位!。