From 524ae1be8976a94f930ed1df33b2242b1d91cb0d Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 11:04:17 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20Quill=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/quill-delta/types.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/json-crdt-extensions/quill-delta/types.ts diff --git a/src/json-crdt-extensions/quill-delta/types.ts b/src/json-crdt-extensions/quill-delta/types.ts new file mode 100644 index 0000000000..78dd4a823f --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/types.ts @@ -0,0 +1,41 @@ +export interface QuillDeltaPatch { + ops: QuillDeltaOp[]; +} + +export type QuillDeltaOp = QuillDeltaOpInsert | QuillDeltaOpRetain | QuillDeltaOpDelete; + +export type QuillDeltaAttributes = Record; + +export interface QuillDeltaOpInsert { + insert: string | Record; + attributes?: QuillDeltaAttributes; +} + +export interface QuillDeltaOpRetain { + retain: number; + attributes?: QuillDeltaAttributes; +} + +export interface QuillDeltaOpDelete { + delete: number; +} + +export type QuillDeltaSliceDto = [ + /** First character session ID, inclusive. */ + sid1: number, + /** First character clock time, inclusive. */ + time1: number, + /** Last character session ID, inclusive. */ + sid2: number, + /** Last character clock time, inclusive. */ + time2: number, + /** Slice markup. */ + attributes: QuillDeltaAttributes, + /** Non-textual insert data. */ + insert?: Record, +]; + +export interface QuillTrace { + contents: QuillDeltaPatch; + transactions: QuillDeltaPatch['ops'][]; +} From 656728ee6fe1e45fca97e47555cc051dbc586484 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 11:27:25 +0200 Subject: [PATCH 02/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20QuillDeltaApi=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quill-delta/QuillDeltaApi.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts diff --git a/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts b/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts new file mode 100644 index 0000000000..30116b3626 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts @@ -0,0 +1,123 @@ +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 {ExtApi} from '../../json-crdt'; +import type { + QuillDeltaAttributes, + QuillDeltaOpDelete, + QuillDeltaOpInsert, + QuillDeltaOpRetain, + QuillDeltaPatch, +} from './types'; +import type {Peritext} from '../peritext'; + +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(); + 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 implements ExtApi { + 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 (op).retain === 'number') { + const {retain, attributes} = op; + rewriteAttributes(txt, attributes, pos, retain); + pos += retain; + } else if (typeof (op).delete === 'number') { + txt.delAt(pos, (op).delete); + } else if ((op).insert) { + const {insert} = op; + let {attributes} = 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; + } + } + } + } +} From ded8f6c1e07fa4a14b2c4139d7d80130398832c6 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 11:34:11 +0200 Subject: [PATCH 03/12] =?UTF-8?q?chore(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=A4=96=20cleanup=20Peritext=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 5 ----- src/json-crdt-extensions/peritext/PeritextNode.ts | 2 +- src/json-crdt-extensions/peritext/index.ts | 3 ++- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 1868407bcb..7173d0f540 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -285,11 +285,6 @@ export class Peritext implements Printable { return deleted; } - // public delSlice(sliceId: ITimestampStruct): void { - - // this.savedSlices.del(sliceId); - // } - // ------------------------------------------------------------------ markers /** @deprecated Use the method in `Editor` and `Cursor` instead. */ diff --git a/src/json-crdt-extensions/peritext/PeritextNode.ts b/src/json-crdt-extensions/peritext/PeritextNode.ts index a01ccc45a2..14a7f88880 100644 --- a/src/json-crdt-extensions/peritext/PeritextNode.ts +++ b/src/json-crdt-extensions/peritext/PeritextNode.ts @@ -14,7 +14,7 @@ export class PeritextNode extends ExtNode { return this.data.get(1)!; } - // ------------------------------------------------------------ ExtensionNode + // ------------------------------------------------------------------ ExtNode public readonly extId = ExtensionId.peritext; public name(): string { diff --git a/src/json-crdt-extensions/peritext/index.ts b/src/json-crdt-extensions/peritext/index.ts index 52e996fbed..38428ef441 100644 --- a/src/json-crdt-extensions/peritext/index.ts +++ b/src/json-crdt-extensions/peritext/index.ts @@ -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, From d6a4111c20b5e2b5350f09e7bb9b1fce721c8837 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 12:40:22 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20setup=20Quill=20Delta=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quill-delta/QuillDeltaNode.ts | 35 +++++++++++++++++++ .../quill-delta/constants.ts | 8 +++++ src/json-crdt-extensions/quill-delta/index.ts | 17 +++++++++ src/json-crdt-extensions/quill-delta/types.ts | 4 +++ 4 files changed, 64 insertions(+) create mode 100644 src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts create mode 100644 src/json-crdt-extensions/quill-delta/constants.ts create mode 100644 src/json-crdt-extensions/quill-delta/index.ts diff --git a/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts new file mode 100644 index 0000000000..608186b413 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts @@ -0,0 +1,35 @@ +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} from './constants'; +import {ExtNode} from '../../json-crdt/extensions/ExtNode'; +import type {QuillDataNode} from './types'; + +export class QuillDeltaNode extends ExtNode { + public readonly txt: Peritext; + + constructor(public readonly data: QuillDataNode) { + super(data); + this.txt = new Peritext(data.doc, this.text(), this.slices()); + } + + public text(): StrNode { + return this.data.get(0)!; + } + + public slices(): ArrNode { + return this.data.get(1)!; + } + + // ------------------------------------------------------------------ ExtNode + public readonly extId = ExtensionId.quill; + + public name(): string { + return MNEMONIC; + } + + public view(): string { + return this.text().view(); + } +} diff --git a/src/json-crdt-extensions/quill-delta/constants.ts b/src/json-crdt-extensions/quill-delta/constants.ts new file mode 100644 index 0000000000..09eb54fb48 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/constants.ts @@ -0,0 +1,8 @@ +import {ExtensionId, ExtensionName} from '../constants'; + +export const enum QuillConst { + EmbedChar = '\n', + EmbedSliceType = 0, +} + +export const MNEMONIC = ExtensionName[ExtensionId.peritext]; diff --git a/src/json-crdt-extensions/quill-delta/index.ts b/src/json-crdt-extensions/quill-delta/index.ts new file mode 100644 index 0000000000..5139a25f72 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/index.ts @@ -0,0 +1,17 @@ +import {ExtensionId} from '../constants'; +import {QuillDeltaNode} from './QuillDeltaNode'; +import {QuillDeltaApi} from './QuillDeltaApi'; +import {MNEMONIC} from './constants'; +import {Extension} from '../../json-crdt/extensions/Extension'; +import {SCHEMA} from '../peritext/constants'; +import type {QuillDataNode} from './types'; + +export {QuillDeltaNode, QuillDeltaApi}; + +export const quill = new Extension< + ExtensionId.quill, + QuillDataNode, + QuillDeltaNode, + QuillDeltaApi, + [text: string] +>(ExtensionId.quill, MNEMONIC, QuillDeltaNode, QuillDeltaApi, (text: string) => SCHEMA(text)); diff --git a/src/json-crdt-extensions/quill-delta/types.ts b/src/json-crdt-extensions/quill-delta/types.ts index 78dd4a823f..9fff85b1dc 100644 --- a/src/json-crdt-extensions/quill-delta/types.ts +++ b/src/json-crdt-extensions/quill-delta/types.ts @@ -1,3 +1,7 @@ +import type {PeritextDataNode} from '../peritext/types'; + +export type QuillDataNode = PeritextDataNode; + export interface QuillDeltaPatch { ops: QuillDeltaOp[]; } From ba2254cd726b2cd7bc77546adad2ea2e3db807c9 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 12:43:31 +0200 Subject: [PATCH 05/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20slice=20manipulation=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/quill-delta/util.ts | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/json-crdt-extensions/quill-delta/util.ts diff --git a/src/json-crdt-extensions/quill-delta/util.ts b/src/json-crdt-extensions/quill-delta/util.ts new file mode 100644 index 0000000000..9f6e5396b4 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/util.ts @@ -0,0 +1,83 @@ +import {isEmpty} from '@jsonjoy.com/util/lib/isEmpty'; +import {OverlayPoint} from '../peritext/overlay/OverlayPoint'; +import {QuillDeltaAttributes} from './types'; +import {PersistedSlice} from '../peritext/slice/PersistedSlice'; +import {SliceBehavior} from '../peritext/slice/constants'; +import type {PathStep} from '../../json-pointer'; + +export const getAttributes = (overlayPoint: OverlayPoint): QuillDeltaAttributes | undefined => { + const layers = overlayPoint.layers; + const layerLength = layers.length; + if (!layerLength) return; + const attributes: QuillDeltaAttributes = {}; + for (let i = 0; i < layerLength; i++) { + const slice = layers[i]; + if (!(slice instanceof PersistedSlice)) continue; + switch (slice.behavior) { + case SliceBehavior.Overwrite: { + const tag = slice.type as PathStep; + if (tag) attributes[tag] = slice.data(); + break; + } + case SliceBehavior.Erase: { + const tag = slice.type as PathStep; + if (tag) delete attributes[tag]; + break; + } + } + } + if (isEmpty(attributes)) return undefined; + return attributes; +}; + +const eraseAttributes = (attr: QuillDeltaAttributes | undefined): Record | undefined => { + if (!attr) return; + const keys = Object.keys(attr); + const length = keys.length; + if (!length) return; + const erased: Record = {}; + for (let i = 0; i < length; i++) erased[keys[i]] = null; + return erased; +}; + +export const removeErasures = (attr: QuillDeltaAttributes | undefined): QuillDeltaAttributes | undefined => { + if (!attr) return; + const keys = Object.keys(attr); + const length = keys.length; + if (!length) return; + const cleaned: QuillDeltaAttributes = {}; + for (let i = 0; i < length; i++) { + const key = keys[i]; + const value = attr[key]; + if (value !== null) cleaned[key] = value; + } + return isEmpty(cleaned) ? undefined : cleaned; +}; + +export const diffAttributes = ( + oldAttributes: QuillDeltaAttributes | undefined, + newAttributes: QuillDeltaAttributes | undefined, +): QuillDeltaAttributes | undefined => { + if (!oldAttributes) return removeErasures(newAttributes); + if (!newAttributes) return eraseAttributes(oldAttributes); + const diff: QuillDeltaAttributes = {}; + const keys = Object.keys(newAttributes); + const length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + const newValue = newAttributes[key]; + const oldValue = oldAttributes[key]; + if (newValue === oldValue) continue; + diff[key] = newValue; + } + const oldKeys = Object.keys(oldAttributes); + const oldLength = oldKeys.length; + for (let i = 0; i < oldLength; i++) { + const key = oldKeys[i]; + // tslint:disable-next-line:triple-equals + if (newAttributes[key] != undefined) continue; + diff[key] = null; + } + if (isEmpty(diff)) return undefined; + return diff; +}; From c21e15b249211e7aee1824e4fa5db83c3f0bc48a Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 14:23:48 +0200 Subject: [PATCH 06/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20Quill=20extension=20inference=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/api/proxy.ts | 6 ++++-- src/json-crdt/model/api/types.ts | 6 ++++-- src/json-crdt/schema/types.ts | 20 ++++++++++++-------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/json-crdt/model/api/proxy.ts b/src/json-crdt/model/api/proxy.ts index 5ad782a37d..6dcc3ec3f4 100644 --- a/src/json-crdt/model/api/proxy.ts +++ b/src/json-crdt/model/api/proxy.ts @@ -1,6 +1,6 @@ import type {JsonNodeApi} from './types'; import type * as nodes from '../../nodes'; -import type {PeritextNode} from '../../../json-crdt-extensions'; +import type {PeritextNode, QuillDeltaNode} from '../../../json-crdt-extensions'; import type {VecNodeExtensionData} from '../../schema/types'; export interface ProxyNode { @@ -44,4 +44,6 @@ export type JsonNodeToProxyNode = N extends nodes.ConNode ? ProxyNodeVec : N extends PeritextNode ? ProxyNode - : never; + : N extends QuillDeltaNode + ? ProxyNode + : never; diff --git a/src/json-crdt/model/api/types.ts b/src/json-crdt/model/api/types.ts index fe68c38bde..78d4b283d4 100644 --- a/src/json-crdt/model/api/types.ts +++ b/src/json-crdt/model/api/types.ts @@ -1,4 +1,4 @@ -import type {PeritextNode, PeritextApi} from '../../../json-crdt-extensions/peritext'; +import type {PeritextNode, PeritextApi, QuillDeltaNode, QuillDeltaApi} from '../../../json-crdt-extensions'; import type * as types from '../../nodes'; import type * as nodes from './nodes'; @@ -21,4 +21,6 @@ export type JsonNodeApi = N extends types.ConNode ? nodes.VecApi : N extends PeritextNode ? PeritextApi - : never; + : N extends QuillDeltaNode + ? QuillDeltaApi + : never; diff --git a/src/json-crdt/schema/types.ts b/src/json-crdt/schema/types.ts index 037207deec..bf2df60013 100644 --- a/src/json-crdt/schema/types.ts +++ b/src/json-crdt/schema/types.ts @@ -1,8 +1,8 @@ import type {ExtensionId} from '../../json-crdt-extensions'; import type {MvalNode} from '../../json-crdt-extensions/mval/MvalNode'; -import type {PeritextNode} from '../../json-crdt-extensions/peritext/PeritextNode'; +import type {PeritextNode, QuillDeltaNode} from '../../json-crdt-extensions'; import type {nodes as builder} from '../../json-crdt-patch'; -import {ExtNode} from '../extensions/ExtNode'; +import type {ExtNode} from '../extensions/ExtNode'; import type * as nodes from '../nodes'; // prettier-ignore @@ -22,9 +22,11 @@ export type SchemaToJsonNode = S extends builder.str ? nodes.ArrNode> : S extends builder.ext ? nodes.VecNode> - : S extends builder.ext - ? nodes.VecNode> - : nodes.JsonNode; + : S extends builder.ext + ? nodes.VecNode> + : S extends builder.ext + ? nodes.VecNode> + : nodes.JsonNode; export type ExtensionVecData> = {__BRAND__: 'ExtVecData'} & [ header: nodes.ConNode, @@ -51,9 +53,11 @@ export type JsonNodeToSchema = N extends nodes.StrNode ? (T extends ExtensionVecData ? EDataNode extends PeritextNode ? builder.ext - : EDataNode extends MvalNode - ? builder.ext - : builder.ext + : EDataNode extends QuillDeltaNode + ? builder.ext + : EDataNode extends MvalNode + ? builder.ext + : builder.ext : builder.vec<{[K in keyof T]: JsonNodeToSchema}>) : N extends nodes.ObjNode ? builder.obj<{[K in keyof T]: JsonNodeToSchema}> From 10e77219f1a9034211bcd446db39a50891c65086 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 14:25:34 +0200 Subject: [PATCH 07/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20Quill=20Delta=20extension=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/ModelWithExt.ts | 1 + src/json-crdt-extensions/ext.ts | 3 +- src/json-crdt-extensions/index.ts | 1 + .../peritext/constants.ts | 2 +- .../quill-delta/QuillDeltaApi.ts | 11 +++- .../quill-delta/__tests__/extension.spec.ts | 56 +++++++++++++++++++ .../quill-delta/constants.ts | 2 +- 7 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts diff --git a/src/json-crdt-extensions/ModelWithExt.ts b/src/json-crdt-extensions/ModelWithExt.ts index 665d784f52..1b22182dba 100644 --- a/src/json-crdt-extensions/ModelWithExt.ts +++ b/src/json-crdt-extensions/ModelWithExt.ts @@ -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}; diff --git a/src/json-crdt-extensions/ext.ts b/src/json-crdt-extensions/ext.ts index e825f27c3c..97d167d4d3 100644 --- a/src/json-crdt-extensions/ext.ts +++ b/src/json-crdt-extensions/ext.ts @@ -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}; diff --git a/src/json-crdt-extensions/index.ts b/src/json-crdt-extensions/index.ts index 3df9dfcfec..0189fd7774 100644 --- a/src/json-crdt-extensions/index.ts +++ b/src/json-crdt-extensions/index.ts @@ -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'; diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/constants.ts index 00d2b3e538..ae9b3ba98d 100644 --- a/src/json-crdt-extensions/peritext/constants.ts +++ b/src/json-crdt-extensions/peritext/constants.ts @@ -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', diff --git a/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts b/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts index 30116b3626..d0a511fb12 100644 --- a/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts +++ b/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts @@ -6,7 +6,7 @@ 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 {ExtApi} from '../../json-crdt'; +import type {ArrApi, ArrNode, ExtApi, StrApi} from '../../json-crdt'; import type { QuillDeltaAttributes, QuillDeltaOpDelete, @@ -15,6 +15,7 @@ import type { 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; @@ -88,6 +89,14 @@ const maybeUpdateAttributes = ( }; export class QuillDeltaApi extends NodeApi implements ExtApi { + public text(): StrApi { + return this.api.wrap(this.node.text()); + } + + public slices(): ArrApi> { + return this.api.wrap(this.node.slices()); + } + public apply(ops: QuillDeltaPatch['ops']) { const txt = this.node.txt; const overlay = txt.overlay; diff --git a/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts b/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts new file mode 100644 index 0000000000..2ad79d5425 --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts @@ -0,0 +1,56 @@ +import {s} from '../../../json-crdt-patch'; +import {ArrApi, StrApi, VecApi} from '../../../json-crdt/model'; +import {ModelWithExt, ext} from '../../ModelWithExt'; +import {Peritext} from '../../peritext'; +import {QuillDeltaApi} from '../QuillDeltaApi'; +import {QuillDeltaNode} from '../QuillDeltaNode'; + +const schema = s.obj({ + nested: s.obj({ + obj: s.obj({ + text: ext.quill.new('Hello, world\n'), + }), + }), +}); + +describe('typed access', () => { + test('can access PeritextNode in type safe way (using the proxy selector)', () => { + const model = ModelWithExt.create(schema); + let api = model.s.nested.obj.text.toExt(); + expect(api).toBeInstanceOf(QuillDeltaApi); + api = new QuillDeltaApi(api.node, api.api); + }); + + test('can access raw text "str" node in type safe way', () => { + const model = ModelWithExt.create(schema); + const str = model.s.nested.obj.text.toExt().text(); + expect(str).toBeInstanceOf(StrApi); + str.ins(str.length() - 1, '!'); + expect(model.view().nested.obj.text).toBe('Hello, world!\n'); + }); + + test('can access slices "arr" node in type safe way', () => { + const model = ModelWithExt.create(schema); + const arr = model.s.nested.obj.text.toExt().slices(); + expect(arr).toBeInstanceOf(ArrApi); + expect(arr.view()).toEqual([]); + }); + + test('can access Quill Delta node using parent proxy selector', () => { + const model = ModelWithExt.create(schema); + const api = model.s.nested.obj.text.toApi(); + expect(api).toBeInstanceOf(VecApi); + let node = api.node.ext(); + expect(node).toBeInstanceOf(QuillDeltaNode); + node = new QuillDeltaNode(node.data); + let api2 = api.asExt()!; + expect(api2).toBeInstanceOf(QuillDeltaApi); + api2 = new QuillDeltaApi(node, api.api); + }); + + test('can access Peritext context and Editor', () => { + const model = ModelWithExt.create(schema); + const api = model.s.nested.obj.text.toExt(); + expect(api.node.txt).toBeInstanceOf(Peritext); + }); +}); diff --git a/src/json-crdt-extensions/quill-delta/constants.ts b/src/json-crdt-extensions/quill-delta/constants.ts index 09eb54fb48..18c98c32e8 100644 --- a/src/json-crdt-extensions/quill-delta/constants.ts +++ b/src/json-crdt-extensions/quill-delta/constants.ts @@ -5,4 +5,4 @@ export const enum QuillConst { EmbedSliceType = 0, } -export const MNEMONIC = ExtensionName[ExtensionId.peritext]; +export const MNEMONIC = ExtensionName[ExtensionId.quill]; From c6435431446f7511b5d6cd2711fb2d5cf8440fa2 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 14:47:09 +0200 Subject: [PATCH 08/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20Quill=20Delta=20view=20computation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quill-delta/QuillDeltaNode.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts index 608186b413..6f995b0393 100644 --- a/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts +++ b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts @@ -1,10 +1,15 @@ +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} from './constants'; +import {MNEMONIC, QuillConst} from './constants'; import {ExtNode} from '../../json-crdt/extensions/ExtNode'; -import type {QuillDataNode} from './types'; +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 { public readonly txt: Peritext; @@ -29,7 +34,42 @@ export class QuillDeltaNode extends ExtNode { return MNEMONIC; } - public view(): string { - return this.text().view(); + /** @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 | 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}; + 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; } } From 079626ae1f3ddd450206b62c6bb578cb3119a587 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 14:51:45 +0200 Subject: [PATCH 09/12] =?UTF-8?q?chore(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=A4=96=20add=20quill-delta=20package=20for=20testing=20an?= =?UTF-8?q?d=20banchmarking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3bd5208231..3150a196f5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 108047c99a..40fdf350dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,7 +1008,6 @@ concat-map@0.0.1: "config-housekeeping@https://github.com/streamich/housekeeping#3532d2abeac159315ddf403d70517859d079c801": version "0.0.0" - uid "3532d2abeac159315ddf403d70517859d079c801" resolved "https://github.com/streamich/housekeeping#3532d2abeac159315ddf403d70517859d079c801" convert-source-map@^2.0.0: @@ -1077,7 +1076,6 @@ diff@^4.0.1: "editing-traces@https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b": version "0.0.0" - uid "6494020428530a6e382378b98d1d7e31334e2d7b" resolved "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b" electron-to-chromium@^1.4.796: @@ -1153,6 +1151,11 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +fast-diff@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + fast-json-patch@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947" @@ -1768,7 +1771,6 @@ jsesc@^2.5.1: "json-crdt-traces@https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d": version "0.0.1" - uid ec825401dc05cbb74b9e0b3c4d6527399f54d54d resolved "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d" json-logic-js@^2.0.2: @@ -1808,6 +1810,16 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -2032,6 +2044,15 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +quill-delta@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48" + integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA== + dependencies: + fast-diff "^1.3.0" + lodash.clonedeep "^4.5.0" + lodash.isequal "^4.5.0" + react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" From 50bdfcde86f9914600ad9b46898656af976b43cf Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 15:30:26 +0200 Subject: [PATCH 10/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20Quill=20Delta=20mutation=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quill-delta/__tests__/QuillDelta.spec.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts diff --git a/src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts b/src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts new file mode 100644 index 0000000000..24ced7a15e --- /dev/null +++ b/src/json-crdt-extensions/quill-delta/__tests__/QuillDelta.spec.ts @@ -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); +}); From 98d65cfafd2dbd20a79cedf27de70c4c23133c61 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 15:31:00 +0200 Subject: [PATCH 11/12] =?UTF-8?q?style(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=84=20run=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts | 2 +- src/json-crdt-extensions/quill-delta/index.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts index 6f995b0393..92f45eb2e8 100644 --- a/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts +++ b/src/json-crdt-extensions/quill-delta/QuillDeltaNode.ts @@ -42,7 +42,7 @@ export class QuillDeltaNode extends ExtNode { let chunk: undefined | StringChunk; const nextPair = overlay.tuples0(undefined); let pair: OverlayTuple | undefined; - while (pair = nextPair()) { + while ((pair = nextPair())) { const [p1, p2] = pair; const attributes: undefined | QuillDeltaAttributes = getAttributes(p1); let insert = ''; diff --git a/src/json-crdt-extensions/quill-delta/index.ts b/src/json-crdt-extensions/quill-delta/index.ts index 5139a25f72..4e144080b7 100644 --- a/src/json-crdt-extensions/quill-delta/index.ts +++ b/src/json-crdt-extensions/quill-delta/index.ts @@ -8,10 +8,10 @@ import type {QuillDataNode} from './types'; export {QuillDeltaNode, QuillDeltaApi}; -export const quill = new Extension< +export const quill = new Extension( ExtensionId.quill, - QuillDataNode, + MNEMONIC, QuillDeltaNode, QuillDeltaApi, - [text: string] ->(ExtensionId.quill, MNEMONIC, QuillDeltaNode, QuillDeltaApi, (text: string) => SCHEMA(text)); + (text: string) => SCHEMA(text), +); From 1d0323d46635e68e835354ffb0bb84c896f1c981 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 10 Jul 2024 15:38:27 +0200 Subject: [PATCH 12/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20correct=20test=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quill-delta/__tests__/extension.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts b/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts index 2ad79d5425..1e822d5310 100644 --- a/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts +++ b/src/json-crdt-extensions/quill-delta/__tests__/extension.spec.ts @@ -26,7 +26,7 @@ describe('typed access', () => { const str = model.s.nested.obj.text.toExt().text(); expect(str).toBeInstanceOf(StrApi); str.ins(str.length() - 1, '!'); - expect(model.view().nested.obj.text).toBe('Hello, world!\n'); + expect(model.view().nested.obj.text).toEqual([{insert: 'Hello, world!\n'}]); }); test('can access slices "arr" node in type safe way', () => {