Skip to content

Commit 0d95319

Browse files
authored
Merge pull request #658 from streamich/quill-extension
Quill extension
2 parents e89ee5b + 1d0323d commit 0d95319

20 files changed

+578
-24
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
"jest": "^29.7.0",
144144
"json-crdt-traces": "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d",
145145
"json-logic-js": "^2.0.2",
146+
"quill-delta": "^5.1.0",
146147
"rxjs": "^7.8.1",
147148
"ts-jest": "^29.1.2",
148149
"ts-node": "^10.9.2",

src/json-crdt-extensions/ModelWithExt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const extensions = new Extensions();
1010
extensions.register(ext.cnt);
1111
extensions.register(ext.mval);
1212
extensions.register(ext.peritext);
13+
extensions.register(ext.quill);
1314

1415
export {ext};
1516

src/json-crdt-extensions/ext.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {cnt} from './cnt';
22
import {mval} from './mval';
33
import {peritext} from './peritext';
4+
import {quill} from './quill-delta';
45

5-
export {cnt, mval, peritext};
6+
export {cnt, mval, peritext, quill};

src/json-crdt-extensions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './mval';
22
export * from './cnt';
33
export * from './peritext';
4+
export * from './quill-delta';
45
export * from './ext';
56
export * from './ModelWithExt';
67
export * from './constants';

src/json-crdt-extensions/peritext/Peritext.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,6 @@ export class Peritext<T = string> implements Printable {
285285
return deleted;
286286
}
287287

288-
// public delSlice(sliceId: ITimestampStruct): void {
289-
290-
// this.savedSlices.del(sliceId);
291-
// }
292-
293288
// ------------------------------------------------------------------ markers
294289

295290
/** @deprecated Use the method in `Editor` and `Cursor` instead. */

src/json-crdt-extensions/peritext/PeritextNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class PeritextNode extends ExtNode<PeritextDataNode> {
1414
return this.data.get(1)!;
1515
}
1616

17-
// ------------------------------------------------------------ ExtensionNode
17+
// ------------------------------------------------------------------ ExtNode
1818
public readonly extId = ExtensionId.peritext;
1919

2020
public name(): string {

src/json-crdt-extensions/peritext/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {nodes, s} from '../../json-crdt-patch';
22
import {ExtensionId, ExtensionName} from '../constants';
3-
import {SliceSchema} from './slice/types';
3+
import type {SliceSchema} from './slice/types';
44

55
export const enum Chars {
66
BlockSplitSentinel = '\n',

src/json-crdt-extensions/peritext/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {ExtensionId} from '../constants';
22
import {PeritextNode} from './PeritextNode';
33
import {PeritextApi} from './PeritextApi';
4+
import {Peritext} from './Peritext';
45
import {SCHEMA, MNEMONIC} from './constants';
56
import {Extension} from '../../json-crdt/extensions/Extension';
67
import type {PeritextDataNode} from './types';
78

8-
export {PeritextNode, PeritextApi};
9+
export {PeritextNode, PeritextApi, Peritext};
910

1011
export const peritext = new Extension<
1112
ExtensionId.peritext,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {QuillConst} from './constants';
2+
import {PathStep} from '../../json-pointer';
3+
import {QuillDeltaNode} from './QuillDeltaNode';
4+
import {NodeApi} from '../../json-crdt/model/api/nodes';
5+
import {konst} from '../../json-crdt-patch/builder/Konst';
6+
import {SliceBehavior} from '../peritext/slice/constants';
7+
import {PersistedSlice} from '../peritext/slice/PersistedSlice';
8+
import {diffAttributes, getAttributes, removeErasures} from './util';
9+
import type {ArrApi, ArrNode, ExtApi, StrApi} from '../../json-crdt';
10+
import type {
11+
QuillDeltaAttributes,
12+
QuillDeltaOpDelete,
13+
QuillDeltaOpInsert,
14+
QuillDeltaOpRetain,
15+
QuillDeltaPatch,
16+
} from './types';
17+
import type {Peritext} from '../peritext';
18+
import type {SliceNode} from '../peritext/slice/types';
19+
20+
const updateAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | undefined, pos: number, len: number) => {
21+
if (!attributes) return;
22+
const range = txt.rangeAt(pos, len);
23+
const keys = Object.keys(attributes);
24+
const length = keys.length;
25+
const savedSlices = txt.savedSlices;
26+
for (let i = 0; i < length; i++) {
27+
const key = keys[i];
28+
const value = attributes[key];
29+
if (value === null) {
30+
savedSlices.ins(range, SliceBehavior.Erase, key);
31+
} else {
32+
savedSlices.ins(range, SliceBehavior.Overwrite, key, konst(value));
33+
}
34+
}
35+
};
36+
37+
const rewriteAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | undefined, pos: number, len: number) => {
38+
if (!attributes) return;
39+
const range = txt.rangeAt(pos, len);
40+
range.expand();
41+
const slices = txt.overlay.findOverlapping(range);
42+
const length = slices.size;
43+
const relevantOverlappingButNotContained = new Set<PathStep>();
44+
if (length) {
45+
const savedSlices = txt.savedSlices;
46+
slices.forEach((slice) => {
47+
if (slice instanceof PersistedSlice) {
48+
const isContained = range.contains(slice);
49+
if (!isContained) {
50+
relevantOverlappingButNotContained.add(slice.type as PathStep);
51+
return;
52+
}
53+
const type = slice.type as PathStep;
54+
if (type in attributes) {
55+
savedSlices.del(slice.id);
56+
}
57+
}
58+
});
59+
}
60+
const keys = Object.keys(attributes);
61+
const attributeLength = keys.length;
62+
const attributesCopy = {...attributes};
63+
for (let i = 0; i < attributeLength; i++) {
64+
const key = keys[i];
65+
const value = attributes[key];
66+
if (value === null && !relevantOverlappingButNotContained.has(key)) {
67+
delete attributesCopy[key];
68+
}
69+
}
70+
updateAttributes(txt, attributesCopy, pos, len);
71+
};
72+
73+
const maybeUpdateAttributes = (
74+
txt: Peritext,
75+
attributes: QuillDeltaAttributes | undefined,
76+
pos: number,
77+
len: number,
78+
): void => {
79+
const range = txt.rangeAt(pos, 1);
80+
const overlayPoint = txt.overlay.getOrNextLower(range.start);
81+
if (!overlayPoint && !attributes) return;
82+
if (!overlayPoint) {
83+
updateAttributes(txt, removeErasures(attributes), pos, len);
84+
return;
85+
}
86+
const pointAttributes = getAttributes(overlayPoint);
87+
const attributeDiff = diffAttributes(pointAttributes, attributes);
88+
if (attributeDiff) updateAttributes(txt, attributeDiff, pos, len);
89+
};
90+
91+
export class QuillDeltaApi extends NodeApi<QuillDeltaNode> implements ExtApi<QuillDeltaNode> {
92+
public text(): StrApi {
93+
return this.api.wrap(this.node.text());
94+
}
95+
96+
public slices(): ArrApi<ArrNode<SliceNode>> {
97+
return this.api.wrap(this.node.slices());
98+
}
99+
100+
public apply(ops: QuillDeltaPatch['ops']) {
101+
const txt = this.node.txt;
102+
const overlay = txt.overlay;
103+
const length = ops.length;
104+
let pos = 0;
105+
for (let i = 0; i < length; i++) {
106+
overlay.refresh(true);
107+
const op = ops[i];
108+
if (typeof (<QuillDeltaOpRetain>op).retain === 'number') {
109+
const {retain, attributes} = <QuillDeltaOpRetain>op;
110+
rewriteAttributes(txt, attributes, pos, retain);
111+
pos += retain;
112+
} else if (typeof (<QuillDeltaOpDelete>op).delete === 'number') {
113+
txt.delAt(pos, (<QuillDeltaOpDelete>op).delete);
114+
} else if ((<QuillDeltaOpInsert>op).insert) {
115+
const {insert} = <QuillDeltaOpInsert>op;
116+
let {attributes} = <QuillDeltaOpInsert>op;
117+
if (typeof insert === 'string') {
118+
txt.insAt(pos, insert);
119+
const insertLength = insert.length;
120+
maybeUpdateAttributes(txt, attributes, pos, insertLength);
121+
pos += insertLength;
122+
} else {
123+
txt.insAt(pos, QuillConst.EmbedChar);
124+
if (!attributes) attributes = {};
125+
attributes[QuillConst.EmbedSliceType] = insert;
126+
maybeUpdateAttributes(txt, attributes, pos, 1);
127+
pos += 1;
128+
}
129+
}
130+
}
131+
}
132+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {isEmpty} from '@jsonjoy.com/util/lib/isEmpty';
2+
import {deepEqual} from '../../json-equal/deepEqual';
3+
import {StrNode} from '../../json-crdt/nodes/str/StrNode';
4+
import {ArrNode} from '../../json-crdt/nodes/arr/ArrNode';
5+
import {Peritext} from '../peritext';
6+
import {ExtensionId} from '../constants';
7+
import {MNEMONIC, QuillConst} from './constants';
8+
import {ExtNode} from '../../json-crdt/extensions/ExtNode';
9+
import {getAttributes} from './util';
10+
import type {QuillDataNode, QuillDeltaAttributes, QuillDeltaOp, QuillDeltaOpInsert} from './types';
11+
import type {StringChunk} from '../peritext/util/types';
12+
import type {OverlayTuple} from '../peritext/overlay/types';
13+
14+
export class QuillDeltaNode extends ExtNode<QuillDataNode> {
15+
public readonly txt: Peritext<string>;
16+
17+
constructor(public readonly data: QuillDataNode) {
18+
super(data);
19+
this.txt = new Peritext<string>(data.doc, this.text(), this.slices());
20+
}
21+
22+
public text(): StrNode<string> {
23+
return this.data.get(0)!;
24+
}
25+
26+
public slices(): ArrNode {
27+
return this.data.get(1)!;
28+
}
29+
30+
// ------------------------------------------------------------------ ExtNode
31+
public readonly extId = ExtensionId.quill;
32+
33+
public name(): string {
34+
return MNEMONIC;
35+
}
36+
37+
/** @todo Cache this value based on overlay hash. */
38+
public view(): QuillDeltaOp[] {
39+
const ops: QuillDeltaOp[] = [];
40+
const overlay = this.txt.overlay;
41+
overlay.refresh(true);
42+
let chunk: undefined | StringChunk;
43+
const nextPair = overlay.tuples0(undefined);
44+
let pair: OverlayTuple<string> | undefined;
45+
while ((pair = nextPair())) {
46+
const [p1, p2] = pair;
47+
const attributes: undefined | QuillDeltaAttributes = getAttributes(p1);
48+
let insert = '';
49+
chunk = overlay.chunkSlices0(chunk, p1, p2, (chunk, off, len) => {
50+
const data = chunk.data;
51+
// console.log(JSON.stringify(data), off, len);
52+
if (data) insert += data.slice(off, off + len);
53+
});
54+
if (insert) {
55+
if (insert === QuillConst.EmbedChar && attributes && attributes[QuillConst.EmbedSliceType]) {
56+
const op: QuillDeltaOpInsert = {insert: attributes[QuillConst.EmbedSliceType] as Record<string, unknown>};
57+
delete attributes[QuillConst.EmbedSliceType];
58+
if (!isEmpty(attributes)) op.attributes = attributes;
59+
ops.push(op);
60+
break;
61+
} else {
62+
const lastOp = ops[ops.length - 1] as QuillDeltaOpInsert;
63+
if (lastOp && typeof lastOp.insert === 'string' && deepEqual(lastOp.attributes, attributes)) {
64+
lastOp.insert += insert;
65+
break;
66+
}
67+
}
68+
const op: QuillDeltaOpInsert = {insert};
69+
if (attributes) op.attributes = attributes;
70+
ops.push(op);
71+
}
72+
}
73+
return ops;
74+
}
75+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {mval} from '../../mval';
2+
import {quill} from '..';
3+
import {Model} from '../../../json-crdt/model';
4+
import Delta from 'quill-delta';
5+
6+
test('can construct delta with new line character', () => {
7+
const model = Model.create();
8+
model.ext.register(mval);
9+
model.ext.register(quill);
10+
model.api.root(quill.new('\n'));
11+
expect(model.view()).toMatchObject([{insert: '\n'}]);
12+
});
13+
14+
test('creates a string-set 2-tuple', () => {
15+
const model = Model.create();
16+
model.ext.register(mval);
17+
model.ext.register(quill);
18+
model.api.root(quill.new(''));
19+
model.api.apply();
20+
const api = model.api.in().asExt(quill);
21+
api.apply([{insert: 'a'}]);
22+
api.apply([{retain: 1}, {insert: 'b'}]);
23+
api.apply([{retain: 2}, {insert: 'c'}]);
24+
const model2 = Model.fromBinary(model.toBinary());
25+
expect(model2.view()).toMatchObject([expect.any(Uint8Array), ['abc', []]]);
26+
});
27+
28+
test('can annotate range with attribute', () => {
29+
const model = Model.create();
30+
model.ext.register(mval);
31+
model.ext.register(quill);
32+
model.api.root({
33+
foo: 'bar',
34+
richText: quill.new('Hello world!'),
35+
});
36+
const api = model.api.in(['richText']).asExt(quill);
37+
api.apply([
38+
{retain: 6},
39+
{
40+
retain: 5,
41+
attributes: {
42+
bold: true,
43+
},
44+
},
45+
]);
46+
expect(model.view()).toEqual({
47+
foo: 'bar',
48+
richText: [{insert: 'Hello '}, {insert: 'world', attributes: {bold: true}}, {insert: '!'}],
49+
});
50+
});
51+
52+
test('inserting in the middle of annotated text does not create new slice', () => {
53+
const model = Model.create();
54+
model.ext.register(quill);
55+
model.api.root(quill.new(''));
56+
const api = model.api.in().asExt(quill);
57+
api.apply([{insert: 'ac', attributes: {bold: true}}]);
58+
api.node.txt.overlay.refresh();
59+
expect(api.node.txt.savedSlices.size()).toBe(1);
60+
api.apply([{retain: 1}, {insert: 'b', attributes: {bold: true}}]);
61+
api.node.txt.overlay.refresh();
62+
expect(api.node.txt.savedSlices.size()).toBe(1);
63+
});
64+
65+
test('inserting in the middle of annotated text does not create new slice - 2', () => {
66+
const model = Model.create();
67+
model.ext.register(quill);
68+
model.api.root(quill.new(''));
69+
const api = model.api.in().asExt(quill);
70+
api.apply([{insert: '\n'}]);
71+
api.apply([{insert: 'aaa'}]);
72+
api.apply([{retain: 1}, {retain: 2, attributes: {bold: true}}]);
73+
expect(api.node.txt.savedSlices.size()).toBe(1);
74+
api.apply([{retain: 2}, {insert: 'a', attributes: {bold: true}}]);
75+
api.apply([{retain: 3}, {insert: 'a', attributes: {bold: true}}]);
76+
expect(api.node.txt.savedSlices.size()).toBe(1);
77+
api.node.txt.overlay.refresh();
78+
});
79+
80+
test('can insert text in an annotated range', () => {
81+
const model = Model.create();
82+
model.ext.register(quill);
83+
model.api.root(quill.new('\n'));
84+
const api = model.api.in().asExt(quill);
85+
api.apply([{insert: 'abc xyz'}]);
86+
api.apply([{retain: 4}, {retain: 3, attributes: {bold: true}}]);
87+
api.apply([{retain: 3}, {insert: 'def'}]);
88+
api.apply([{retain: 8}, {insert: '1', attributes: {bold: true}}]);
89+
expect(api.view()).toEqual([{insert: 'abcdef '}, {insert: 'x1yz', attributes: {bold: true}}, {insert: '\n'}]);
90+
expect(model.view()).toEqual(api.view());
91+
});
92+
93+
test('can insert italic-only text in bold text', () => {
94+
const model = Model.create();
95+
model.ext.register(quill);
96+
model.api.root(quill.new(''));
97+
const api = model.api.in().asExt(quill);
98+
api.apply([{insert: 'aa', attributes: {bold: true}}]);
99+
api.apply([{retain: 1}, {insert: 'b', attributes: {italic: true}}]);
100+
let delta = new Delta([{insert: 'aa', attributes: {bold: true}}]);
101+
delta = delta.compose(new Delta([{retain: 1}, {insert: 'b', attributes: {italic: true}}]));
102+
expect(api.view()).toEqual([
103+
{insert: 'a', attributes: {bold: true}},
104+
{insert: 'b', attributes: {italic: true}},
105+
{insert: 'a', attributes: {bold: true}},
106+
]);
107+
expect(model.view()).toEqual(api.view());
108+
expect(model.view()).toEqual(delta.ops);
109+
});

0 commit comments

Comments
 (0)