-
-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #658 from streamich/quill-extension
Quill extension
- Loading branch information
Showing
20 changed files
with
578 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
109
src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
Oops, something went wrong.