Skip to content

Commit

Permalink
CRichText (Peritext implementation) (#249)
Browse files Browse the repository at this point in the history
  • Loading branch information
mweidner037 authored Apr 19, 2023
1 parent 757edcc commit f27a270
Show file tree
Hide file tree
Showing 39 changed files with 2,883 additions and 1,859 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,159 +3,30 @@ import { Data } from "../../../util";
import { ITextWithCursor } from "../../interfaces/text_with_cursor";
import { CollabsReplica } from "./replica";

interface RichCharEventsRecord extends collabs.CollabEventsRecord {
Format: { key: string } & collabs.CollabEvent;
}

class RichChar extends collabs.CObject<RichCharEventsRecord> {
private readonly _attributes: collabs.CValueMap<string, any>;

/**
* char comes from a Quill Delta's insert field, split
* into single characters if a string. So it is either
* a single char, or (for an embed) a JSON-serializable
* object with a single property.
*/
constructor(init: collabs.InitToken, readonly char: string | object) {
super(init);

this._attributes = this.registerCollab(
"",
(init) => new collabs.CValueMap(init)
);

// Events
this._attributes.on("Set", (e) => {
this.emit("Format", {
key: e.key,
meta: e.meta,
});
});
this._attributes.on("Delete", (e) => {
this.emit("Format", { key: e.key, meta: e.meta });
});
}

getAttribute(attribute: string): any | null {
return this._attributes.get(attribute) ?? null;
}

/**
* null attribute deletes the existing one.
*/
setAttribute(attribute: string, value: any | null) {
if (value === null) {
this._attributes.delete(attribute);
} else {
this._attributes.set(attribute, value);
}
}

attributes(): { [key: string]: any } {
return Object.fromEntries(this._attributes);
}
}

interface RichTextEventsRecord extends collabs.CollabEventsRecord {
Insert: { startIndex: number; count: number } & collabs.CollabEvent;
Delete: { startIndex: number; count: number } & collabs.CollabEvent;
Format: { index: number; key: string } & collabs.CollabEvent;
}

class RichTextInternal extends collabs.CObject<RichTextEventsRecord> {
readonly text: collabs.CList<RichChar, [char: string | object]>;

constructor(init: collabs.InitToken) {
super(init);

this.text = this.registerCollab(
"",
(init) =>
new collabs.CList(init, (valueInitToken, char) => {
const richChar = new RichChar(valueInitToken, char);
richChar.on("Format", (e) => {
this.emit("Format", { index: this.text.indexOf(richChar), ...e });
});
return richChar;
})
);
this.text.on("Insert", (e) => {
this.emit("Insert", {
startIndex: e.index,
count: e.values.length,
meta: e.meta,
});
});
this.text.on("Delete", (e) =>
this.emit("Delete", {
startIndex: e.index,
count: e.values.length,
meta: e.meta,
})
);
}

get(index: number): RichChar {
return this.text.get(index);
}

get length(): number {
return this.text.length;
}

insert(
index: number,
char: string | object,
attributes?: Record<string, any>
) {
const richChar = this.text.insert(index, char);
this.formatChar(richChar, attributes);
}

delete(startIndex: number, count: number) {
this.text.delete(startIndex, count);
}

/**
* null attribute deletes the existing one.
*/
format(index: number, newAttributes?: Record<string, any>) {
this.formatChar(this.get(index), newAttributes);
}

private formatChar(richChar: RichChar, newAttributes?: Record<string, any>) {
if (newAttributes) {
for (const entry of Object.entries(newAttributes)) {
richChar.setAttribute(...entry);
}
}
}
}

export function CollabsRichTextWithCursor(causalityGuaranteed: boolean) {
return class CollabsRichTextWithCursor
extends CollabsReplica
implements ITextWithCursor
{
private readonly richText: RichTextInternal;
private readonly richText: collabs.CRichText;
private cursor = -1;

constructor(onsend: (msg: Data) => void, replicaIdRng: seedrandom.prng) {
super(onsend, replicaIdRng, causalityGuaranteed);

this.richText = this.runtime.registerCollab(
"",
(init) => new RichTextInternal(init)
(init) => new collabs.CRichText(init)
);

// Maintain cursor position.
// We use the fact that all ops are single character insertions/deletions.
// TODO: use position-based cursors instead?
this.richText.on("Insert", (e) => {
if (!e.meta.isLocalOp && e.startIndex < this.cursor) this.cursor++;
if (!e.meta.isLocalOp && e.index < this.cursor) this.cursor++;
});
this.richText.on("Delete", (e) => {
if (!e.meta.isLocalOp && e.startIndex < this.cursor) this.cursor--;
if (!e.meta.isLocalOp && e.index < this.cursor) this.cursor--;
});
}

Expand All @@ -168,7 +39,7 @@ export function CollabsRichTextWithCursor(causalityGuaranteed: boolean) {
}

insert(char: string): void {
this.richText.insert(this.cursor, char);
this.richText.insert(this.cursor, char, {});
this.cursor++;
}

Expand All @@ -178,9 +49,7 @@ export function CollabsRichTextWithCursor(causalityGuaranteed: boolean) {
}

getText(): string {
return this.richText.text
.map((richChar) => <string>richChar.char)
.join("");
return this.richText.toString();
}

get length(): number {
Expand Down
5 changes: 5 additions & 0 deletions collabs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export {
CRDTMessageMeta,
CRDTMetaRequest,
CRDTSavedStateMeta,
CRichText,
CRuntime,
CSet,
CText,
Expand All @@ -77,6 +78,10 @@ export {
LocalList,
MultiValueMapItem,
PrimitiveCRDT,
RichTextEventsRecord,
RichTextFormatEvent,
RichTextInsertEvent,
RichTextRange,
RuntimeEventsRecord,
RuntimeOptions,
SendEvent,
Expand Down
8 changes: 5 additions & 3 deletions core/src/util/serializers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Buffer } from "buffer";
import * as utf8 from "@protobufjs/utf8";
import {
ArrayMessage,
CollabIDMessage,
Expand Down Expand Up @@ -247,10 +247,12 @@ export class StringSerializer implements Serializer<string> {
// Use StringSerializer.instance instead.
}
serialize(value: string): Uint8Array {
return new Uint8Array(Buffer.from(value, "utf-8"));
const ans = new Uint8Array(utf8.length(value));
utf8.write(value, ans, 0);
return ans;
}
deserialize(message: Uint8Array): string {
return Buffer.from(message).toString("utf-8");
return utf8.read(message, 0, message.length);
}
static readonly instance = new StringSerializer();
}
Expand Down
2 changes: 1 addition & 1 deletion crdts/src/list/c_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class CListEntry<C extends Collab> extends CObject {
* will attempt to throw an exception if it detects such modification,
* but this is not guaranteed.
*
* See also: [[CValueList]], [[CText]].
* See also: [[CValueList]], [[CText]], [[CRichText]].
*
* @typeParam C The value type, which is a Collab.
* @typeParam InsertArgs The type of arguments to [[insert]].
Expand Down
Loading

0 comments on commit f27a270

Please sign in to comment.