Skip to content

Commit

Permalink
Merge pull request #658 from streamich/quill-extension
Browse files Browse the repository at this point in the history
Quill extension
  • Loading branch information
streamich authored Jul 10, 2024
2 parents e89ee5b + 1d0323d commit 0d95319
Show file tree
Hide file tree
Showing 20 changed files with 578 additions and 24 deletions.
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

0 comments on commit 0d95319

Please sign in to comment.