文本
Loro 同时支持纯文本与富文本。如果不使用加标(mark/unmark)等富文本能力,文本容器就以纯文本方式工作,不会产生额外开销。
LoroText 在处理长字符串时表现出色,内部采用 B 树结构,插入、删除等基础操作都是 O(log N),即便文档包含数百万字符也能保持高性能。
想深入了解 Loro 富文本 CRDT 的原理,可阅读博客:Introduction to Loro’s Rich Text CRDT。
LoroText 还支持稳定的光标与选区。借助 text.getCursor、doc.getCursorPos、Side 等 API,可以创建在并发编辑下仍然有效的光标位置,类似 Yjs 的 RelativePosition。详情参见光标教程。
编辑器绑定
Loro 为常见编辑器提供官方绑定,方便将 CRDT 能力集成到现有项目。
ProseMirror 绑定
loro-prosemirror 提供与 ProseMirror 的无缝整合,包括:
- 支持富文本的文档同步
- 光标感知与同步
- 协作编辑下的撤销 / 重做
它也适用于基于 ProseMirror 的 Tiptap,可轻松为 Tiptap 应用添加协作能力。
import {
CursorAwareness,
LoroCursorPlugin,
LoroSyncPlugin,
LoroUndoPlugin,
undo,
redo,
} from "loro-prosemirror";
import { LoroDoc } from "loro-crdt";
import { EditorView } from "prosemirror-view";
import { EditorState } from "prosemirror-state";
import { keymap } from "prosemirror-keymap";
const doc = new LoroDoc();
const awareness = new CursorAwareness(doc.peerIdStr);
const plugins = [
...pmPlugins,
LoroSyncPlugin({ doc }),
LoroUndoPlugin({ doc }),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
LoroCursorPlugin(awareness, {}),
];
const editor = new EditorView(editorDom, {
state: EditorState.create({ doc, plugins }),
});CodeMirror 绑定
loro-codemirror 集成了 CodeMirror 6,提供:
- 文档状态同步
- 光标感知
- 撤销 / 重做
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { LoroExtensions } from "loro-codemirror";
import { Awareness, LoroDoc, UndoManager } from "loro-crdt";
const doc = new LoroDoc();
const awareness = new Awareness(doc.peerIdStr);
const undoManager = new UndoManager(doc, {});
new EditorView({
state: EditorState.create({
extensions: [
// ... 其他扩展
LoroExtensions(
doc,
{
awareness,
user: { name: "Bob", colorClassName: "user1" },
},
undoManager,
),
],
}),
parent: document.querySelector("#editor")!,
});LoroText 与字符串的区别
LoroText 与普通字符串的语义截然不同,合并结果也不同。
使用 LoroText:
const = new ();
.("0");
.("text").(0, "Hello");
const = new ();
.("1");
.("text").(0, "World");
.(.({ : "update" }));
.(.("text").()); // "HelloWorld"使用字符串:
const = new ();
.("0");
.("map").("text", "Hello");
const = new ();
.("1");
.("map").("text", "World");
.(.({ : "update" }));
.(.("map").("text")); // "World"合并语义
与采用最后写入生效(LWW)的 LoroMap 不同,LoroText 会尽量保留双端编辑。
当用户 A、B 同时编辑文本时:
- LoroText 会合并并保留双方改动
- LoroMap 由于 LWW 语义,只保留其中一方
何时选择在 LoroMap 中存字符串
在某些场景下,使用 LWW 的字符串更合适:
- URL:并发拼接可能导致无效链接,使用 LWW 可保证最终有效
- 哈希字符串:类似数据应保持准确与唯一性,适合 LWW
富文本配置
在使用富文本前,需要为每个格式声明“扩展行为”,以确定在格式边界插入新文本时是否继承标记。
共有四种扩展行为:
after(默认):在范围后侧插入时继承标记before:在范围前侧插入时继承标记none:在边界处插入时不继承both:前后侧插入都会继承
示例
const = new ();
.({
: { : "after" },
: { : "before" },
});
const = .("text");
.(0, "Hello World!");
.({ : 0, : 5 }, "bold", true);
(.()).([
{
: "Hello",
: {
: true,
},
},
{
: " World!",
},
] as <string>[]);
// 由于 bold 指定了 forward 扩展," Test" 会继承加粗
.(5, " Test");
(.()).([
{
: "Hello Test",
: {
: true,
},
},
{
: " World!",
},
] as <string>[]);方法
insert(pos: number, s: string)
在指定位置插入文本。
delete(pos: number, len: number)
删除指定区间的文本。
slice(start: number, end: number): string
获取子串。
toString(): string
返回纯文本内容。
charAt(pos: number): char
获取指定位置的字符。
splice(pos: number, len: number, text: string): string
删除指定区间,并在同一位置插入新文本,返回被删除的内容。
length: number
文本长度。
getCursor(pos: number, side?: Side): Cursor | undefined
为逻辑索引创建稳定位置句柄。用途类似 Yjs 的 RelativePosition,可跨并发编辑保持有效。
- 光标既可表示插入点,也可代表选区端点;保存两个光标即可表示选区
- 使用
doc.getCursorPos(cursor)获取当前偏移与 side,并可能返回更新后的光标以减少重放 - 文本偏移在 WASM 绑定中使用 UTF-16 索引
side控制光标位于目标 ID 的左侧(-1)、中间(0) 或右侧(1),影响并发插入时的行为
详见光标教程。
示例(光标):
const = new ();
const = .("text");
.(0, "123");
// 创建位于开头的稳定光标
const = .(0, 0);
{
const = .(!);
(.).(0);
}
// 在光标前插入,光标会向后移动
.(0, "1");
{
const = .(!);
(.).(1);
}示例(选区):
const = new ();
const = .("text");
.("Hello World");
// 锚点位于 0(左侧),头部位于 5(右侧)
const = .(0, -1)!;
const = .(5, 1)!;
// 需要时再解析绝对位置
const = .();
const = .();
// selection = [a.offset, h.offset)持久化与恢复光标:
const = new ();
const = .("text");
.("Hi");
const = .(1, 0)!;
// 序列化与共享
const = .();
// 另一节点恢复
const = .();
const = .();toJSON(): string
返回纯文本内容,不包含任何格式信息,与 toString() 等价。若需格式数据,请使用 toDelta()。
toDelta(): Delta<string>[]
返回包含富文本标记的 Delta(Quill Delta 格式)。
示例:
const = new ();
.({ : { : "after" } });
const = .("text");
.(0, "Hello World!");
.({ : 0, : 5 }, "bold", true);
.(.()); // "Hello World!"
.(.());
// [
// { insert: "Hello", attributes: { bold: true } },
// { insert: " World!" }
// ]mark(range, key, value)
在指定区间添加格式。
unmark(range, key)
移除指定区间的某个格式。
update(text: string)
将当前内容替换为提供的文本。
applyDelta(delta: Delta<string>[])
根据 Delta 更新文本。
- 对于
insert项,需要包含全部属性;否则未显式包含的属性会被移除 - 若在文本长度之外格式化属性,Loro 会先插入换行补齐
- 适用于与 Quill 等富文本编辑器的绑定
const = new ();
const = .("text");
.({ : { : "after" } });
.(0, "Hello World!");
.({ : 0, : 5 }, "bold", true);
const = .();
const = .("text2");
.();
(.()).();subscribe(f: (event: Listener))
订阅文本事件,返回订阅 ID,供取消订阅使用。
文本事件以 Delta<string>[] 表示,类型与 applyDelta 参数一致,可直接绑定富文本编辑器。
(async () => {
const = new ();
.({
: { : "none" },
: { : "after" },
});
const = .("text");
const = new ();
.({
: { : "none" },
: { : "after" },
});
const = .("text");
.(() => {
for (const of .) {
const = . as ;
.(.);
}
});
.(0, "foo");
.({ : 0, : 3 }, "link", true);
.();
await new (() => (, 1));
(.()).(.());
.(3, "baz");
.();
await new (() => (, 1));
(.()).([
{ : "foo", : { : true } },
{ : "baz" },
]);
(.()).(.());
.({ : 2, : 5 }, "bold", true);
.();
await new (() => (, 1));
(.()).(.());
})();