Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quill extension #658

Merged
merged 12 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"jest": "^29.7.0",
"json-crdt-traces": "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d",
"json-logic-js": "^2.0.2",
"quill-delta": "^5.1.0",
"rxjs": "^7.8.1",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
Expand Down
1 change: 1 addition & 0 deletions src/json-crdt-extensions/ModelWithExt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const extensions = new Extensions();
extensions.register(ext.cnt);
extensions.register(ext.mval);
extensions.register(ext.peritext);
extensions.register(ext.quill);

export {ext};

Expand Down
3 changes: 2 additions & 1 deletion src/json-crdt-extensions/ext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {cnt} from './cnt';
import {mval} from './mval';
import {peritext} from './peritext';
import {quill} from './quill-delta';

export {cnt, mval, peritext};
export {cnt, mval, peritext, quill};
1 change: 1 addition & 0 deletions src/json-crdt-extensions/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './mval';
export * from './cnt';
export * from './peritext';
export * from './quill-delta';
export * from './ext';
export * from './ModelWithExt';
export * from './constants';
5 changes: 0 additions & 5 deletions src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,6 @@ export class Peritext<T = string> implements Printable {
return deleted;
}

// public delSlice(sliceId: ITimestampStruct): void {

// this.savedSlices.del(sliceId);
// }

// ------------------------------------------------------------------ markers

/** @deprecated Use the method in `Editor` and `Cursor` instead. */
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt-extensions/peritext/PeritextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class PeritextNode extends ExtNode<PeritextDataNode> {
return this.data.get(1)!;
}

// ------------------------------------------------------------ ExtensionNode
// ------------------------------------------------------------------ ExtNode
public readonly extId = ExtensionId.peritext;

public name(): string {
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt-extensions/peritext/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {nodes, s} from '../../json-crdt-patch';
import {ExtensionId, ExtensionName} from '../constants';
import {SliceSchema} from './slice/types';
import type {SliceSchema} from './slice/types';

export const enum Chars {
BlockSplitSentinel = '\n',
Expand Down
3 changes: 2 additions & 1 deletion src/json-crdt-extensions/peritext/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {ExtensionId} from '../constants';
import {PeritextNode} from './PeritextNode';
import {PeritextApi} from './PeritextApi';
import {Peritext} from './Peritext';
import {SCHEMA, MNEMONIC} from './constants';
import {Extension} from '../../json-crdt/extensions/Extension';
import type {PeritextDataNode} from './types';

export {PeritextNode, PeritextApi};
export {PeritextNode, PeritextApi, Peritext};

export const peritext = new Extension<
ExtensionId.peritext,
Expand Down
132 changes: 132 additions & 0 deletions src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {QuillConst} from './constants';
import {PathStep} from '../../json-pointer';
import {QuillDeltaNode} from './QuillDeltaNode';
import {NodeApi} from '../../json-crdt/model/api/nodes';
import {konst} from '../../json-crdt-patch/builder/Konst';
import {SliceBehavior} from '../peritext/slice/constants';
import {PersistedSlice} from '../peritext/slice/PersistedSlice';
import {diffAttributes, getAttributes, removeErasures} from './util';
import type {ArrApi, ArrNode, ExtApi, StrApi} from '../../json-crdt';
import type {
QuillDeltaAttributes,
QuillDeltaOpDelete,
QuillDeltaOpInsert,
QuillDeltaOpRetain,
QuillDeltaPatch,
} from './types';
import type {Peritext} from '../peritext';
import type {SliceNode} from '../peritext/slice/types';

const updateAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | undefined, pos: number, len: number) => {
if (!attributes) return;
const range = txt.rangeAt(pos, len);
const keys = Object.keys(attributes);
const length = keys.length;
const savedSlices = txt.savedSlices;
for (let i = 0; i < length; i++) {
const key = keys[i];
const value = attributes[key];
if (value === null) {
savedSlices.ins(range, SliceBehavior.Erase, key);
} else {
savedSlices.ins(range, SliceBehavior.Overwrite, key, konst(value));
}
}
};

const rewriteAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | undefined, pos: number, len: number) => {
if (!attributes) return;
const range = txt.rangeAt(pos, len);
range.expand();
const slices = txt.overlay.findOverlapping(range);
const length = slices.size;
const relevantOverlappingButNotContained = new Set<PathStep>();
if (length) {
const savedSlices = txt.savedSlices;
slices.forEach((slice) => {
if (slice instanceof PersistedSlice) {
const isContained = range.contains(slice);
if (!isContained) {
relevantOverlappingButNotContained.add(slice.type as PathStep);
return;
}
const type = slice.type as PathStep;
if (type in attributes) {
savedSlices.del(slice.id);
}
}
});
}
const keys = Object.keys(attributes);
const attributeLength = keys.length;
const attributesCopy = {...attributes};
for (let i = 0; i < attributeLength; i++) {
const key = keys[i];
const value = attributes[key];
if (value === null && !relevantOverlappingButNotContained.has(key)) {
delete attributesCopy[key];
}
}
updateAttributes(txt, attributesCopy, pos, len);
};

const maybeUpdateAttributes = (
txt: Peritext,
attributes: QuillDeltaAttributes | undefined,
pos: number,
len: number,
): void => {
const range = txt.rangeAt(pos, 1);
const overlayPoint = txt.overlay.getOrNextLower(range.start);
if (!overlayPoint && !attributes) return;
if (!overlayPoint) {
updateAttributes(txt, removeErasures(attributes), pos, len);
return;
}
const pointAttributes = getAttributes(overlayPoint);
const attributeDiff = diffAttributes(pointAttributes, attributes);
if (attributeDiff) updateAttributes(txt, attributeDiff, pos, len);
};

export class QuillDeltaApi extends NodeApi<QuillDeltaNode> implements ExtApi<QuillDeltaNode> {
public text(): StrApi {
return this.api.wrap(this.node.text());
}

public slices(): ArrApi<ArrNode<SliceNode>> {
return this.api.wrap(this.node.slices());
}

public apply(ops: QuillDeltaPatch['ops']) {
const txt = this.node.txt;
const overlay = txt.overlay;
const length = ops.length;
let pos = 0;
for (let i = 0; i < length; i++) {
overlay.refresh(true);
const op = ops[i];
if (typeof (<QuillDeltaOpRetain>op).retain === 'number') {
const {retain, attributes} = <QuillDeltaOpRetain>op;
rewriteAttributes(txt, attributes, pos, retain);
pos += retain;
} else if (typeof (<QuillDeltaOpDelete>op).delete === 'number') {
txt.delAt(pos, (<QuillDeltaOpDelete>op).delete);
} else if ((<QuillDeltaOpInsert>op).insert) {
const {insert} = <QuillDeltaOpInsert>op;
let {attributes} = <QuillDeltaOpInsert>op;
if (typeof insert === 'string') {
txt.insAt(pos, insert);
const insertLength = insert.length;
maybeUpdateAttributes(txt, attributes, pos, insertLength);
pos += insertLength;
} else {
txt.insAt(pos, QuillConst.EmbedChar);
if (!attributes) attributes = {};
attributes[QuillConst.EmbedSliceType] = insert;
maybeUpdateAttributes(txt, attributes, pos, 1);
pos += 1;
}
}
}
}
}
75 changes: 75 additions & 0 deletions src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {isEmpty} from '@jsonjoy.com/util/lib/isEmpty';
import {deepEqual} from '../../json-equal/deepEqual';
import {StrNode} from '../../json-crdt/nodes/str/StrNode';
import {ArrNode} from '../../json-crdt/nodes/arr/ArrNode';
import {Peritext} from '../peritext';
import {ExtensionId} from '../constants';
import {MNEMONIC, QuillConst} from './constants';
import {ExtNode} from '../../json-crdt/extensions/ExtNode';
import {getAttributes} from './util';
import type {QuillDataNode, QuillDeltaAttributes, QuillDeltaOp, QuillDeltaOpInsert} from './types';
import type {StringChunk} from '../peritext/util/types';
import type {OverlayTuple} from '../peritext/overlay/types';

export class QuillDeltaNode extends ExtNode<QuillDataNode> {
public readonly txt: Peritext<string>;

constructor(public readonly data: QuillDataNode) {
super(data);
this.txt = new Peritext<string>(data.doc, this.text(), this.slices());
}

public text(): StrNode<string> {
return this.data.get(0)!;
}

public slices(): ArrNode {
return this.data.get(1)!;
}

// ------------------------------------------------------------------ ExtNode
public readonly extId = ExtensionId.quill;

public name(): string {
return MNEMONIC;
}

/** @todo Cache this value based on overlay hash. */
public view(): QuillDeltaOp[] {
const ops: QuillDeltaOp[] = [];
const overlay = this.txt.overlay;
overlay.refresh(true);
let chunk: undefined | StringChunk;
const nextPair = overlay.tuples0(undefined);
let pair: OverlayTuple<string> | undefined;
while ((pair = nextPair())) {
const [p1, p2] = pair;
const attributes: undefined | QuillDeltaAttributes = getAttributes(p1);
let insert = '';
chunk = overlay.chunkSlices0(chunk, p1, p2, (chunk, off, len) => {
const data = chunk.data;
// console.log(JSON.stringify(data), off, len);
if (data) insert += data.slice(off, off + len);
});
if (insert) {
if (insert === QuillConst.EmbedChar && attributes && attributes[QuillConst.EmbedSliceType]) {
const op: QuillDeltaOpInsert = {insert: attributes[QuillConst.EmbedSliceType] as Record<string, unknown>};
delete attributes[QuillConst.EmbedSliceType];
if (!isEmpty(attributes)) op.attributes = attributes;
ops.push(op);
break;
} else {
const lastOp = ops[ops.length - 1] as QuillDeltaOpInsert;
if (lastOp && typeof lastOp.insert === 'string' && deepEqual(lastOp.attributes, attributes)) {
lastOp.insert += insert;
break;
}
}
const op: QuillDeltaOpInsert = {insert};
if (attributes) op.attributes = attributes;
ops.push(op);
}
}
return ops;
}
}
109 changes: 109 additions & 0 deletions src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {mval} from '../../mval';
import {quill} from '..';
import {Model} from '../../../json-crdt/model';
import Delta from 'quill-delta';

test('can construct delta with new line character', () => {
const model = Model.create();
model.ext.register(mval);
model.ext.register(quill);
model.api.root(quill.new('\n'));
expect(model.view()).toMatchObject([{insert: '\n'}]);
});

test('creates a string-set 2-tuple', () => {
const model = Model.create();
model.ext.register(mval);
model.ext.register(quill);
model.api.root(quill.new(''));
model.api.apply();
const api = model.api.in().asExt(quill);
api.apply([{insert: 'a'}]);
api.apply([{retain: 1}, {insert: 'b'}]);
api.apply([{retain: 2}, {insert: 'c'}]);
const model2 = Model.fromBinary(model.toBinary());
expect(model2.view()).toMatchObject([expect.any(Uint8Array), ['abc', []]]);
});

test('can annotate range with attribute', () => {
const model = Model.create();
model.ext.register(mval);
model.ext.register(quill);
model.api.root({
foo: 'bar',
richText: quill.new('Hello world!'),
});
const api = model.api.in(['richText']).asExt(quill);
api.apply([
{retain: 6},
{
retain: 5,
attributes: {
bold: true,
},
},
]);
expect(model.view()).toEqual({
foo: 'bar',
richText: [{insert: 'Hello '}, {insert: 'world', attributes: {bold: true}}, {insert: '!'}],
});
});

test('inserting in the middle of annotated text does not create new slice', () => {
const model = Model.create();
model.ext.register(quill);
model.api.root(quill.new(''));
const api = model.api.in().asExt(quill);
api.apply([{insert: 'ac', attributes: {bold: true}}]);
api.node.txt.overlay.refresh();
expect(api.node.txt.savedSlices.size()).toBe(1);
api.apply([{retain: 1}, {insert: 'b', attributes: {bold: true}}]);
api.node.txt.overlay.refresh();
expect(api.node.txt.savedSlices.size()).toBe(1);
});

test('inserting in the middle of annotated text does not create new slice - 2', () => {
const model = Model.create();
model.ext.register(quill);
model.api.root(quill.new(''));
const api = model.api.in().asExt(quill);
api.apply([{insert: '\n'}]);
api.apply([{insert: 'aaa'}]);
api.apply([{retain: 1}, {retain: 2, attributes: {bold: true}}]);
expect(api.node.txt.savedSlices.size()).toBe(1);
api.apply([{retain: 2}, {insert: 'a', attributes: {bold: true}}]);
api.apply([{retain: 3}, {insert: 'a', attributes: {bold: true}}]);
expect(api.node.txt.savedSlices.size()).toBe(1);
api.node.txt.overlay.refresh();
});

test('can insert text in an annotated range', () => {
const model = Model.create();
model.ext.register(quill);
model.api.root(quill.new('\n'));
const api = model.api.in().asExt(quill);
api.apply([{insert: 'abc xyz'}]);
api.apply([{retain: 4}, {retain: 3, attributes: {bold: true}}]);
api.apply([{retain: 3}, {insert: 'def'}]);
api.apply([{retain: 8}, {insert: '1', attributes: {bold: true}}]);
expect(api.view()).toEqual([{insert: 'abcdef '}, {insert: 'x1yz', attributes: {bold: true}}, {insert: '\n'}]);
expect(model.view()).toEqual(api.view());
});

test('can insert italic-only text in bold text', () => {
const model = Model.create();
model.ext.register(quill);
model.api.root(quill.new(''));
const api = model.api.in().asExt(quill);
api.apply([{insert: 'aa', attributes: {bold: true}}]);
api.apply([{retain: 1}, {insert: 'b', attributes: {italic: true}}]);
let delta = new Delta([{insert: 'aa', attributes: {bold: true}}]);
delta = delta.compose(new Delta([{retain: 1}, {insert: 'b', attributes: {italic: true}}]));
expect(api.view()).toEqual([
{insert: 'a', attributes: {bold: true}},
{insert: 'b', attributes: {italic: true}},
{insert: 'a', attributes: {bold: true}},
]);
expect(model.view()).toEqual(api.view());
expect(model.view()).toEqual(delta.ops);
});
Loading
Loading