文本

Loro 同时支持纯文本与富文本。如果不使用加标(mark/unmark)等富文本能力,文本容器就以纯文本方式工作,不会产生额外开销。

LoroText 在处理长字符串时表现出色,内部采用 B 树结构,插入、删除等基础操作都是 O(log N),即便文档包含数百万字符也能保持高性能。

想深入了解 Loro 富文本 CRDT 的原理,可阅读博客:Introduction to Loro’s Rich Text CRDT

LoroText 还支持稳定的光标与选区。借助 text.getCursordoc.getCursorPosSide 等 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));
  (.()).(.());
})();