Docs
Tutorial
Text

Text

Loro supports both plain text and rich text. When rich text features (like mark and unmark) are not used, the text container operates as plain text without any rich text overhead, making it efficient for simple text operations.

LoroText offers excellent performance, particularly when handling large strings. It significantly outperforms native JavaScript string operations due to its internal B-tree structure. All basic operations like insert and delete have O(log N) time complexity, making it highly efficient even when working with documents containing several million characters.

To learn how rich text CRDT in Loro works under the hood, please refer to our blog: Introduction to Loro's Rich Text CRDT.

Rich Text Config

To use rich text in Loro, you need to specify the expanding behaviors for each format first. When we insert new text at the format boundaries, they define whether the inserted text should inherit the format.

There are four kinds of expansion behaviors:

  • after(default): when inserting text right after the given range, the mark will be expanded to include the inserted text
  • before: when inserting text right before the given range, the mark will be expanded to include the inserted text
  • none: the mark will not be expanded to include the inserted text at the boundaries
  • both: when inserting text either right before or right after the given range, the mark will be expanded to include the inserted text

Example

const doc = new LoroDoc();
doc.configTextStyle({
  bold: { expand: "after" },
  link: { expand: "before" },
});
const text = doc.getText("text");
text.insert(0, "Hello World!");
text.mark({ start: 0, end: 5 }, "bold", true);
expect(text.toDelta()).toStrictEqual([
  {
    insert: "Hello",
    attributes: {
      bold: true,
    },
  },
  {
    insert: " World!",
  },
] as Delta<string>[]);
 
// " Test" will inherit the bold style because `bold` is configured to expand forward
text.insert(5, " Test");
expect(text.toDelta()).toStrictEqual([
  {
    insert: "Hello Test",
    attributes: {
      bold: true,
    },
  },
  {
    insert: " World!",
  },
] as Delta<string>[]);

Methods

insert(pos: number, s: string)

Insert text at the given pos.

delete(pos: number, len: number)

Delete text at the given range.

slice(start: number, end: number): string

Get a string slice.

toString(): string

Get the plain text value.

charAt(pos: number): char

Get the character at the given position.

splice(pos: number, len: number, text: string): string

Delete and return the string at the given range and insert a string at the same position.

length(): number

Get the length of text

getCursor(pos: number, side: Side): Cursor | undefined

Get the cursor at the given position.

toDelta(): Delta<string>[]

Get the rich text value. It's in Quill's Delta format (opens in a new tab).

mark(range: {start: number, end: number}, key: string, value: any): void

Mark the given range with a key-value pair.

unmark(range: {start: number, end: number}, key: string): void

Remove key-value pairs in the given range with the given key.

update(text: string)

Update the current text based on the provided text.

applyDelta(delta: Delta<string>[]): void

Change the state of this text by delta.

If a delta item is insert, it should include all the attributes of the inserted text. Loro's rich text CRDT may make the inserted text inherit some styles when you use the insert method directly. However, when you use applyDelta if some attributes are inherited from CRDT but not included in the delta, they will be removed.

Another special property of applyDelta is if you format an attribute for ranges out of the text length, Loro will insert new lines to fill the gap first. It's useful when you build the binding between Loro and rich text editors like Quill, which might assume there is always a newline at the end of the text implicitly.

const doc = new LoroDoc();
const text = doc.getText("text");
doc.configTextStyle({ bold: { expand: "after" } });
 
text.insert(0, "Hello World!");
text.mark({ start: 0, end: 5 }, "bold", true);
const delta = text.toDelta();
const text2 = doc.getText("text2");
text2.applyDelta(delta);
expect(text2.toDelta()).toStrictEqual(delta);

subscribe(f: (event: Listener)): number

This method returns a number that can be used to remove the subscription.

The text event is in Delta<string>[] format. It can be used to bind the rich text editor. It has the same type as the arg of applyDelta, so the following example works:

(async () => {
  const doc1 = new LoroDoc();
  doc1.configTextStyle({
    link: { expand: "none" },
    bold: { expand: "after" },
  });
  const text1 = doc1.getText("text");
  const doc2 = new LoroDoc();
  doc2.configTextStyle({
    link: { expand: "none" },
    bold: { expand: "after" },
  });
  const text2 = doc2.getText("text");
  text1.subscribe((event) => {
    const e = event.diff as TextDiff;
    text2.applyDelta(e.diff);
  });
  text1.insert(0, "foo");
  text1.mark({ start: 0, end: 3 }, "link", true);
  doc1.commit();
  await new Promise((r) => setTimeout(r, 1));
  expect(text2.toDelta()).toStrictEqual(text1.toDelta());
  text1.insert(3, "baz");
  doc1.commit();
  await new Promise((r) => setTimeout(r, 1));
  expect(text2.toDelta()).toStrictEqual([
    { insert: "foo", attributes: { link: true } },
    { insert: "baz" },
  ]);
  expect(text2.toDelta()).toStrictEqual(text1.toDelta());
  text1.mark({ start: 2, end: 5 }, "bold", true);
  doc1.commit();
  await new Promise((r) => setTimeout(r, 1));
  expect(text2.toDelta()).toStrictEqual(text1.toDelta());
})();