diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index d9fb75cbe3..2005c7e81b 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,6 +1,6 @@ -import {Anchor, SliceBehavior} from './constants'; -import {Point} from './point/Point'; -import {Range} from './slice/Range'; +import {Anchor} from './rga/constants'; +import {Point} from './rga/Point'; +import {Range} from './rga/Range'; import {Editor} from './editor/Editor'; import {printTree} from '../../util/print/printTree'; import {ArrNode, StrNode} from '../../json-crdt/nodes'; @@ -8,8 +8,6 @@ import {Slices} from './slice/Slices'; import {type ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; -import type {SliceType} from './types'; -import type {PersistedSlice} from './slice/PersistedSlice'; /** * Context for a Peritext instance. Contains all the data and methods needed to @@ -147,24 +145,6 @@ export class Peritext implements Printable { return textId; } - public insSlice( - range: Range, - behavior: SliceBehavior, - type: SliceType, - data?: unknown | ITimestampStruct, - ): PersistedSlice { - // if (range.isCollapsed()) throw new Error('INVALID_RANGE'); - // TODO: If range is not collapsed, check if there are any visible characters in the range. - const slice = this.slices.ins(range, behavior, type, data); - return slice; - } - - // ---------------------------------------------------------------- Deletions - - public delSlice(sliceId: ITimestampStruct): void { - this.slices.del(sliceId); - } - /** Select a single character before a point. */ public findCharBefore(point: Point): Range | undefined { if (point.anchor === Anchor.After) { diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 058b92264c..de975f2133 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,11 +1,12 @@ import {Cursor} from '../slice/Cursor'; -import {Anchor, SliceBehavior} from '../constants'; +import {Anchor} from '../rga/constants'; +import {SliceBehavior} from '../slice/constants'; import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock'; import {PersistedSlice} from '../slice/PersistedSlice'; -import type {Range} from '../slice/Range'; +import type {Range} from '../rga/Range'; import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; -import type {Point} from '../point/Point'; +import type {Point} from '../rga/Point'; import type {SliceType} from '../types'; export class Editor implements Printable { @@ -121,14 +122,14 @@ export class Editor implements Printable { } public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { - return this.txt.insSlice(this.cursor, SliceBehavior.Stack, type, data); + return this.txt.slices.ins(this.cursor, SliceBehavior.Stack, type, data); } public insertOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { - return this.txt.insSlice(this.cursor, SliceBehavior.Overwrite, type, data); + return this.txt.slices.ins(this.cursor, SliceBehavior.Overwrite, type, data); } public insertEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { - return this.txt.insSlice(this.cursor, SliceBehavior.Erase, type, data); + return this.txt.slices.ins(this.cursor, SliceBehavior.Erase, type, data); } } diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts similarity index 99% rename from src/json-crdt-extensions/peritext/point/Point.ts rename to src/json-crdt-extensions/peritext/rga/Point.ts index aa0821745f..30eb117b30 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/rga/Point.ts @@ -1,5 +1,5 @@ import {compare, type ITimestampStruct, toDisplayString, equal, tick, containsId} from '../../../json-crdt-patch/clock'; -import {Anchor} from '../constants'; +import {Anchor} from './constants'; import {ChunkSlice} from '../util/ChunkSlice'; import {updateId} from '../../../json-crdt/hash'; import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; diff --git a/src/json-crdt-extensions/peritext/slice/Range.ts b/src/json-crdt-extensions/peritext/rga/Range.ts similarity index 91% rename from src/json-crdt-extensions/peritext/slice/Range.ts rename to src/json-crdt-extensions/peritext/rga/Range.ts index 41f985bc6c..3759215c35 100644 --- a/src/json-crdt-extensions/peritext/slice/Range.ts +++ b/src/json-crdt-extensions/peritext/rga/Range.ts @@ -1,15 +1,17 @@ -import {Point} from '../point/Point'; -import {Anchor} from '../constants'; +import {Point} from './Point'; +import {Anchor} from './constants'; +import {updateNum} from '../../../json-hash'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Printable} from '../../../util/print/types'; import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; +import type {Stateful} from '../types'; /** * A range is a pair of points that represent a selection in the text. A range * can be collapsed to a single point, then it is called a *marker* * (if it is stored in the text), or *caret* (if it is a cursor position). */ -export class Range implements Printable { +export class Range implements Pick, Printable { /** * Creates a range from two points. The points are ordered so that the * start point is before or equal to the end point. @@ -92,6 +94,14 @@ export class Range implements Printable { return new Range(this.rga, this.start.clone(), this.end.clone()); } + public cmp(range: Range): -1 | 0 | 1 { + return this.start.cmp(range.start) || this.end.cmp(range.end); + } + + public cmpSpatial(range: Range): number { + return this.start.cmpSpatial(range.start) || this.end.cmpSpatial(range.end); + } + /** * Determines if the range is collapsed to a single point. Handles special * cases where the range is collapsed, but the points are not equal, for @@ -206,6 +216,14 @@ export class Range implements Printable { return result; } + // ----------------------------------------------------------------- Stateful + + public refresh(): number { + let state = this.start.refresh(); + state = updateNum(state, this.end.refresh()); + return state; + } + // ---------------------------------------------------------------- Printable public toString(tab: string = '', lite: boolean = true): string { diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/rga/__tests__/Point.spec.ts similarity index 99% rename from src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts rename to src/json-crdt-extensions/peritext/rga/__tests__/Point.spec.ts index e20a8ae5f4..163c0b761c 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/rga/__tests__/Point.spec.ts @@ -1,6 +1,6 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; -import {Anchor} from '../../constants'; +import {Anchor} from '../constants'; import {tick} from '../../../../json-crdt-patch/clock'; const setup = () => { diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts similarity index 99% rename from src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts rename to src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts index e08800c7dc..29bf962b4d 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts +++ b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts @@ -1,6 +1,6 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; -import {Anchor} from '../../constants'; +import {Anchor} from '../constants'; const setup = (insert: (peritext: Peritext) => void = (peritext) => peritext.strApi().ins(0, 'Hello world!')) => { const model = Model.withLogicalClock(); diff --git a/src/json-crdt-extensions/peritext/rga/constants.ts b/src/json-crdt-extensions/peritext/rga/constants.ts new file mode 100644 index 0000000000..2b03ccef96 --- /dev/null +++ b/src/json-crdt-extensions/peritext/rga/constants.ts @@ -0,0 +1,4 @@ +export const enum Anchor { + Before = 0, + After = 1, +} diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts index 483b47f1ea..0213522bf8 100644 --- a/src/json-crdt-extensions/peritext/slice/Cursor.ts +++ b/src/json-crdt-extensions/peritext/slice/Cursor.ts @@ -1,12 +1,13 @@ -import {Point} from '../point/Point'; -import {Anchor, SliceBehavior, Tags} from '../constants'; -import {Range} from './Range'; +import {Point} from '../rga/Point'; +import {CursorAnchor, SliceBehavior, Tags} from './constants'; +import {Range} from '../rga/Range'; import {printTree} from '../../../util/print/printTree'; +import {updateNum} from '../../../json-hash'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Peritext} from '../Peritext'; import type {Slice} from './types'; -export class Cursor extends Range implements Slice { +export class Cursor extends Range implements Slice { public readonly behavior = SliceBehavior.Overwrite; public readonly type = Tags.Cursor; @@ -15,32 +16,30 @@ export class Cursor extends Range implements Slice { * the end which does not move when user changes selection. The other * end is free to move, the moving end of the cursor is "focus". By default * "anchor" is the start of the cursor. - * - * @todo Create a custom enum for this, instead of using `Anchor`. */ - public base: Anchor = Anchor.Before; + public anchorSide: CursorAnchor = CursorAnchor.Start; constructor( public readonly id: ITimestampStruct, protected readonly txt: Peritext, - public start: Point, - public end: Point, + public start: Point, + public end: Point, ) { - super(txt.str, start, end); + super(txt.str as any, start, end); } - public anchor(): Point { - return this.base === Anchor.Before ? this.start : this.end; + public anchor(): Point { + return this.anchorSide === CursorAnchor.Start ? this.start : this.end; } - public focus(): Point { - return this.base === Anchor.Before ? this.end : this.start; + public focus(): Point { + return this.anchorSide === CursorAnchor.Start ? this.end : this.start; } - public set(start: Point, end?: Point, base: Anchor = Anchor.Before): void { + public set(start: Point, end?: Point, base: CursorAnchor = CursorAnchor.Start): void { if (!end || end === start) end = start.clone(); super.set(start, end); - this.base = base; + this.anchorSide = base; } public setAt(start: number, length: number = 0): void { @@ -51,7 +50,7 @@ export class Cursor extends Range implements Slice { len = -len; } super.setAt(at, len); - this.base = length < 0 ? Anchor.After : Anchor.Before; + this.anchorSide = length < 0 ? CursorAnchor.End : CursorAnchor.Start; } /** @@ -60,30 +59,25 @@ export class Cursor extends Range implements Slice { * @param point Point to set the edge to. * @param edge 0 for "focus", 1 for "anchor." */ - public setEdge(point: Point, edge: 0 | 1 = 0): void { + public setEdge(point: Point, edge: 0 | 1 = 0): void { if (this.start === this.end) this.end = this.end.clone(); let anchor = this.anchor(); let focus = this.focus(); if (edge === 0) focus = point; else anchor = point; if (focus.cmpSpatial(anchor) < 0) { - this.base = Anchor.After; + this.anchorSide = CursorAnchor.End; this.start = focus; this.end = anchor; } else { - this.base = Anchor.Before; + this.anchorSide = CursorAnchor.Start; this.start = anchor; this.end = focus; } } - /** @todo Maybe move it to another interface? */ - public del(): boolean { - return false; - } - - public data(): unknown { - return 1; + public data() { + return undefined; } public move(move: number): void { @@ -93,19 +87,23 @@ export class Cursor extends Range implements Slice { end.move(move); } - public toString(tab: string = ''): string { - const text = JSON.stringify(this.text()); - const focusIcon = this.base === Anchor.Before ? '.⇨|' : '|⇦.'; - const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`; - return main + printTree(tab, [() => text]); - } - // ----------------------------------------------------------------- Stateful public hash: number = 0; public refresh(): number { - // TODO: implement this ... - return this.hash; + let state = super.refresh(); + state = updateNum(state, this.anchorSide); + this.hash = state; + return state; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const text = JSON.stringify(this.text()); + const focusIcon = this.anchorSide === CursorAnchor.Start ? '.→|' : '|←.'; + const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`; + return main + printTree(tab, [() => text]); } } diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 0bab0ea42b..66fa218fb4 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -1,54 +1,123 @@ -import {Point} from '../point/Point'; -import {Range} from './Range'; -import {hashNode} from '../../../json-crdt/hash'; +import {Point} from '../rga/Point'; +import {Range} from '../rga/Range'; +import {updateNode} from '../../../json-crdt/hash'; import {printTree} from '../../../util/print/printTree'; -import {Anchor, SliceHeaderMask, SliceHeaderShift, SliceBehavior} from '../constants'; -import {ArrChunk} from '../../../json-crdt/nodes'; -import {type ITimestampStruct, Timestamp} from '../../../json-crdt-patch/clock'; -import type {Slice} from './types'; +import {Anchor} from '../rga/constants'; +import {SliceHeaderMask, SliceHeaderShift, SliceBehavior, SliceTupleIndex} from './constants'; +import {CONST} from '../../../json-hash'; +import {Timestamp, compare} from '../../../json-crdt-patch/clock'; +import {VecNode} from '../../../json-crdt/nodes'; +import {prettyOneLine} from '../../../json-pretty'; +import {validateType} from './util'; +import {s} from '../../../json-crdt-patch'; +import type {JsonNode} from '../../../json-crdt/nodes'; +import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; +import type {ArrChunk} from '../../../json-crdt/nodes'; +import type {MutableSlice, SliceUpdateParams} from './types'; import type {Peritext} from '../Peritext'; import type {SliceDto, SliceType, Stateful} from '../types'; import type {Printable} from '../../../util/print/types'; -import type {JsonNode, VecNode} from '../../../json-crdt/nodes'; +import type {AbstractRga} from '../../../json-crdt/nodes/rga'; -export class PersistedSlice extends Range implements Slice, Printable, Stateful { - public readonly id: ITimestampStruct; +export class PersistedSlice extends Range implements MutableSlice, Stateful, Printable { + public static deserialize(txt: Peritext, rga: AbstractRga, chunk: ArrChunk, tuple: VecNode): PersistedSlice { + const header = +(tuple.get(0)!.view() as SliceDto[0]); + const id1 = tuple.get(1)!.view() as ITimestampStruct; + const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; + const type = tuple.get(3)!.view() as SliceType; + if (typeof header !== 'number') throw new Error('INVALID_HEADER'); + if (!(id1 instanceof Timestamp)) throw new Error('INVALID_ID'); + if (!(id2 instanceof Timestamp)) throw new Error('INVALID_ID'); + validateType(type); + const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; + const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; + const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; + const p1 = new Point(rga, id1, anchor1); + const p2 = new Point(rga, id2, anchor2); + const slice = new PersistedSlice(txt, rga, chunk, tuple, behavior, type, p1, p2); + return slice; + } constructor( + /** The Peritext context. */ protected readonly txt: Peritext, + /** The text RGA. */ + protected readonly rga: AbstractRga, + /** The `arr` chunk of `arr` where the slice is stored. */ protected readonly chunk: ArrChunk, + /** The `vec` node which stores the serialized contents of this slice. */ public readonly tuple: VecNode, - public behavior: SliceBehavior, - /** @todo Rename to x1? */ - public start: Point, - /** @todo Rename to x2? */ - public end: Point, - public type: SliceType, + behavior: SliceBehavior, + type: SliceType, + public start: Point, + public end: Point, ) { - super(txt.str, start, end); - this.id = this.chunk.id; + super(rga, start, end); + this.id = chunk.id; + this.behavior = behavior; + this.type = type; + } + + public isSplit(): boolean { + return this.behavior === SliceBehavior.Split; } - protected tagNode(): JsonNode | undefined { - // TODO: Normalize `.get()` and `.getNode()` methods across VecNode and ArrNode. - return this.tuple.get(3); + protected tupleApi() { + return this.txt.model.api.wrap(this.tuple); + } + + // ------------------------------------------------------------- MutableSlice + + public readonly id: ITimestampStruct; + public behavior: SliceBehavior; + public type: SliceType; + + public update(params: SliceUpdateParams): void { + let updateHeader = false; + const {start, end} = this; + const changes: [number, unknown][] = []; + if (params.behavior !== undefined) { + this.behavior = params.behavior; + updateHeader = true; + } + if (params.range) { + const range = params.range; + if (range.start.anchor !== start.anchor) updateHeader = true; + if (range.end.anchor !== end.anchor) updateHeader = true; + if (compare(range.start.id, start.id) !== 0) changes.push([SliceTupleIndex.X1, s.con(range.start.id)]); + if (compare(range.end.id, end.id) !== 0) changes.push([SliceTupleIndex.X2, s.con(range.end.id)]); + this.setRange(range); + } + if (params.type !== undefined) { + this.type = params.type; + changes.push([SliceTupleIndex.Type, s.con(this.type)]); + } + if (params.data !== undefined) changes.push([SliceTupleIndex.Data, s.con(params.data)]); + if (updateHeader) { + const header = + (this.behavior << SliceHeaderShift.Behavior) + + (this.start.anchor << SliceHeaderShift.X1Anchor) + + (this.end.anchor << SliceHeaderShift.X2Anchor); + changes.push([SliceTupleIndex.Header, s.con(header)]); + } + this.tupleApi().set(changes); } public data(): unknown | undefined { - return this.tuple.get(4)?.view(); + return this.tuple.get(SliceTupleIndex.Data)?.view(); } - public del(): boolean { - return this.chunk.del; + public dataNode() { + const node = this.tuple.get(SliceTupleIndex.Data); + return node && this.txt.model.api.wrap(node); } - // ---------------------------------------------------------------- Printable + public del(): void { + this.txt.slices.del(this.id); + } - public toString(tab: string = ''): string { - const tagNode = this.tagNode(); - const range = `${this.start.toString('', true)} ↔ ${this.end.toString('', true)}`; - const header = `${this.constructor.name} ${range}`; - return header + printTree(tab, [!tagNode ? null : (tab) => tagNode.toString(tab)]); + public isDel(): boolean { + return this.chunk.del; } // ----------------------------------------------------------------- Stateful @@ -56,29 +125,26 @@ export class PersistedSlice extends Range implements Slice, Printable, Stateful public hash: number = 0; public refresh(): number { - const hash = hashNode(this.tuple); - const changed = hash !== this.hash; - this.hash = hash; + let state = CONST.START_STATE; + state = updateNode(state, this.tuple); + const changed = state !== this.hash; + this.hash = state; if (changed) { const tuple = this.tuple; - const header = +(tuple.get(0)!.view() as SliceDto[0]); - const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; - const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; - const type: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; - const id1 = tuple.get(1)!.view() as ITimestampStruct; - const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; - if (!(id1 instanceof Timestamp)) throw new Error('INVALID_ID'); - if (!(id2 instanceof Timestamp)) throw new Error('INVALID_ID'); - const subtype = tuple.get(3)!.view() as SliceType; - this.behavior = type; - this.type = subtype; - const x1 = this.start; - const x2 = this.end; - x1.id = id1; - x1.anchor = anchor1; - x2.id = id2; - x2.anchor = anchor2; + const slice = PersistedSlice.deserialize(this.txt, this.rga, this.chunk, tuple); + this.behavior = slice.behavior; + this.type = slice.type; + this.start = slice.start; + this.end = slice.end; } return this.hash; } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const data = this.data(); + const header = `${this.constructor.name} ${super.toString(tab)}, ${this.behavior}, ${JSON.stringify(this.type)}`; + return header + printTree(tab, [!data ? null : (tab) => prettyOneLine(data)]); + } } diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 67b80a4386..7ab183ae73 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -1,21 +1,22 @@ import {PersistedSlice} from './PersistedSlice'; -import {ITimespanStruct, ITimestampStruct, Timespan, Timestamp, compare, tss} from '../../../json-crdt-patch/clock'; -import {Range} from './Range'; +import {Timespan, compare, tss} from '../../../json-crdt-patch/clock'; +import {Range} from '../rga/Range'; import {updateRga} from '../../../json-crdt/hash'; import {CONST, updateNum} from '../../../json-hash'; import {printTree} from '../../../util/print/printTree'; -import {Anchor, SliceBehavior, SliceHeaderMask, SliceHeaderShift} from '../constants'; +import {SliceBehavior, SliceHeaderShift, SliceTupleIndex} from './constants'; import {SplitSlice} from './SplitSlice'; -import {Point} from '../point/Point'; -import {Slice} from './types'; import {VecNode} from '../../../json-crdt/nodes'; -import type {SliceDto, SliceType, Stateful} from '../types'; +import {AvlMap} from '../../../util/trees/avl/AvlMap'; +import type {Slice} from './types'; +import type {ITimespanStruct, ITimestampStruct} from '../../../json-crdt-patch/clock'; +import type {SliceType, Stateful} from '../types'; import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; import type {ArrChunk, ArrNode} from '../../../json-crdt/nodes'; export class Slices implements Stateful, Printable { - private list = new Map(); + private list = new AvlMap(compare); constructor( public readonly txt: Peritext, @@ -29,8 +30,8 @@ export class Slices implements Stateful, Printable { const api = model.api; const builder = api.builder; const tupleId = builder.vec(); - const start = range.start; - const end = range.end; + const start = range.start.clone(); + const end = range.end.clone(); const header = (behavior << SliceHeaderShift.Behavior) + (start.anchor << SliceHeaderShift.X1Anchor) + @@ -40,58 +41,70 @@ export class Slices implements Stateful, Printable { const x2Id = builder.const(compare(start.id, end.id) === 0 ? 0 : end.id); const subtypeId = builder.const(type); const tupleKeysUpdate: [key: number, value: ITimestampStruct][] = [ - [0, headerId], - [1, x1Id], - [2, x2Id], - [3, subtypeId], + [SliceTupleIndex.Header, headerId], + [SliceTupleIndex.X1, x1Id], + [SliceTupleIndex.X2, x2Id], + [SliceTupleIndex.Type, subtypeId], ]; - if (data !== undefined) tupleKeysUpdate.push([4, builder.json(data)]); + if (data !== undefined) tupleKeysUpdate.push([SliceTupleIndex.Data, builder.json(data)]); builder.insVec(tupleId, tupleKeysUpdate); const chunkId = builder.insArr(set.id, set.id, [tupleId]); api.apply(); const tuple = model.index.get(tupleId) as VecNode; const chunk = set.findById(chunkId)!; // TODO: Need to check if split slice text was deleted + const txt = this.txt; const slice = behavior === SliceBehavior.Split - ? new SplitSlice(this.txt, chunk, tuple, behavior, start, end, type) - : new PersistedSlice(this.txt, chunk, tuple, behavior, start, end, type); - this.list.set(chunk, slice); + ? new SplitSlice(txt, txt.str, chunk, tuple, behavior, type, start, end) + : new PersistedSlice(txt, txt.str, chunk, tuple, behavior, type, start, end); + this.list.set(chunk.id, slice); return slice; } + public insSplit(range: Range, type: SliceType, data?: unknown): SplitSlice { + return this.ins(range, SliceBehavior.Split, type, data) as SplitSlice; + } + + public insStack(range: Range, type: SliceType, data?: unknown): PersistedSlice { + return this.ins(range, SliceBehavior.Stack, type, data); + } + + public insOverwrite(range: Range, type: SliceType, data?: unknown): PersistedSlice { + return this.ins(range, SliceBehavior.Overwrite, type, data); + } + + public insErase(range: Range, type: SliceType, data?: unknown): PersistedSlice { + return this.ins(range, SliceBehavior.Erase, type, data); + } + protected unpack(chunk: ArrChunk): PersistedSlice { const txt = this.txt; + const rga = txt.str; const model = txt.model; const tupleId = chunk.data ? chunk.data[0] : undefined; - if (!tupleId) throw new Error('MARKER_NOT_FOUND'); + if (!tupleId) throw new Error('SLICE_NOT_FOUND'); const tuple = model.index.get(tupleId); if (!(tuple instanceof VecNode)) throw new Error('NOT_TUPLE'); - const header = +(tuple.get(0)!.view() as SliceDto[0]); - const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; - const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; - const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; - const id1 = tuple.get(1)!.view() as ITimestampStruct; - const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; - if (!(id1 instanceof Timestamp)) throw new Error('INVALID_ID'); - if (!(id2 instanceof Timestamp)) throw new Error('INVALID_ID'); - const p1 = new Point(txt.str, id1, anchor1); - const p2 = new Point(txt.str, id2, anchor2); - const type = tuple.get(3)!.view() as SliceType; - const slice = - behavior === SliceBehavior.Split - ? new SplitSlice(this.txt, chunk, tuple, behavior, p1, p2, type) - : new PersistedSlice(this.txt, chunk, tuple, behavior, p1, p2, type); + let slice = PersistedSlice.deserialize(txt, rga, chunk, tuple); + // TODO: Simplify, remove `SplitSlice` class. + if (slice.isSplit()) + slice = new SplitSlice(txt, rga, chunk, tuple, slice.behavior, slice.type, slice.start, slice.end); return slice; } + public get(id: ITimestampStruct): PersistedSlice | undefined { + return this.list.get(id); + } + public del(id: ITimestampStruct): void { + this.list.del(id); const api = this.txt.model.api; api.builder.del(this.set.id, [tss(id.sid, id.time, 1)]); api.apply(); } - public delMany(slices: Slice[]): void { + public delSlices(slices: Slice[]): void { const api = this.txt.model.api; const spans: ITimespanStruct[] = []; const length = slices.length; @@ -107,11 +120,11 @@ export class Slices implements Stateful, Printable { } public size(): number { - return this.list.size; + return this.list._size; } public forEach(callback: (item: PersistedSlice) => void): void { - this.list.forEach(callback); + this.list.forEach((node) => callback(node.v)); } // ----------------------------------------------------------------- Stateful @@ -125,16 +138,16 @@ export class Slices implements Stateful, Printable { this._topologyHash = topologyHash; let chunk: ArrChunk | undefined; for (const iterator = this.set.iterator(); (chunk = iterator()); ) { - const item = this.list.get(chunk); + const item = this.list.get(chunk.id); if (chunk.del) { - if (item) this.list.delete(chunk); + if (item) this.list.del(chunk.id); } else { - if (!item) this.list.set(chunk, this.unpack(chunk)); + if (!item) this.list.set(chunk.id, this.unpack(chunk)); } } } let hash: number = topologyHash; - this.list.forEach((item) => { + this.list.forEach(({v: item}) => { item.refresh(); hash = updateNum(hash, item.hash); }); @@ -148,10 +161,10 @@ export class Slices implements Stateful, Printable { this.constructor.name + printTree( tab, - [...this.list].map( - ([, slice]) => + [...this.list.entries()].map( + ({v}) => (tab) => - slice.toString(tab), + v.toString(tab), ), ) ); diff --git a/src/json-crdt-extensions/peritext/slice/SplitSlice.ts b/src/json-crdt-extensions/peritext/slice/SplitSlice.ts index 9c3b1ac3b7..a66c0d267d 100644 --- a/src/json-crdt-extensions/peritext/slice/SplitSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/SplitSlice.ts @@ -1,3 +1,9 @@ import {PersistedSlice} from './PersistedSlice'; +/** + * A *split slice* represents a block split in the text, i.e. it is a *marker* + * that shows where a block was split. + * + * @deprecated + */ export class SplitSlice extends PersistedSlice {} diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts new file mode 100644 index 0000000000..08d75e9dad --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts @@ -0,0 +1,75 @@ +import {SliceBehavior} from '../constants'; +import {setup} from './setup'; + +const setupSlice = () => { + const deps = setup(); + const range = deps.peritext.rangeAt(2, 3); + const slice = deps.peritext.slices.insSplit(range, 0); + return {...deps, range, slice}; +}; + +test('can read slice data', () => { + const {range, slice} = setupSlice(); + expect(slice.isSplit()).toBe(true); + expect(slice.behavior).toBe(SliceBehavior.Split); + expect(slice.type).toBe(0); + expect(slice.data()).toBe(undefined); + expect(slice.start).not.toBe(range.start); + expect(slice.start.cmp(range.start)).toBe(0); + expect(slice.end).not.toBe(range.end); + expect(slice.end.cmp(range.end)).toBe(0); +}); + +describe('.update()', () => { + const testUpdate = (name: string, update: (deps: ReturnType) => void) => { + test('can update: ' + name, () => { + const deps = setupSlice(); + const {slice} = deps; + const hash1 = slice.refresh(); + const hash2 = slice.refresh(); + expect(hash1).toBe(hash2); + update(deps); + const hash3 = slice.refresh(); + expect(hash3).not.toBe(hash2); + }); + }; + + testUpdate('behavior', ({slice}) => { + slice.update({behavior: SliceBehavior.Erase}); + expect(slice.behavior).toBe(SliceBehavior.Erase); + }); + + testUpdate('type', ({slice}) => { + slice.update({type: 1}); + expect(slice.type).toBe(1); + }); + + testUpdate('data', ({slice}) => { + slice.update({data: 123}); + expect(slice.data()).toBe(123); + }); + + testUpdate('range', ({peritext, slice}) => { + const range2 = peritext.rangeAt(0, 1); + slice.update({range: range2}); + expect(slice.cmp(range2)).toBe(0); + }); +}); + +describe('.del() and .isDel()', () => { + test('can delete a slice', () => { + const {peritext, slice} = setupSlice(); + expect(peritext.model.view().slices.length).toBe(1); + expect(slice.isDel()).toBe(false); + const slice2 = peritext.slices.get(slice.id)!; + expect(peritext.model.view().slices.length).toBe(1); + expect(slice2.isDel()).toBe(false); + expect(slice2).toBe(slice); + slice.del(); + expect(peritext.model.view().slices.length).toBe(0); + expect(slice.isDel()).toBe(true); + expect(slice2.isDel()).toBe(true); + const slice3 = peritext.slices.get(slice.id); + expect(slice3).toBe(undefined); + }); +}); diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts index 83da192c01..f2f29c0e6a 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts @@ -1,20 +1,10 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; - -const setup = () => { - const model = Model.withLogicalClock(); - model.api.root({ - text: '', - slices: [], - }); - model.api.str(['text']).ins(0, 'wworld'); - model.api.str(['text']).ins(0, 'helo '); - model.api.str(['text']).ins(2, 'l'); - model.api.str(['text']).del(7, 1); - model.api.str(['text']).ins(11, ' this game is awesome'); - const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); - return {model, peritext}; -}; +import {Range} from '../../rga/Range'; +import {Anchor} from '../../rga/constants'; +import {PersistedSlice} from '../PersistedSlice'; +import {SliceBehavior} from '../constants'; +import {setup} from './setup'; test('initially slice list is empty', () => { const {peritext} = setup(); @@ -23,25 +13,25 @@ test('initially slice list is empty', () => { expect(peritext.slices.size()).toBe(0); }); -describe('inserts', () => { +describe('.ins()', () => { test('can insert a slice', () => { - const {peritext} = setup(); - const {editor} = peritext; - editor.setCursor(12, 7); - const slice = editor.insertSlice('b', {bold: true}); - peritext.refresh(); + const {peritext, slices} = setup(); + const range = peritext.rangeAt(12, 7); + const slice = slices.ins(range, SliceBehavior.Stack, 'b', {bold: true}); expect(peritext.slices.size()).toBe(1); - expect(slice.start).toStrictEqual(editor.cursor.start); - expect(slice.end).toStrictEqual(editor.cursor.end); + expect(slice.start).toStrictEqual(range.start); + expect(slice.end).toStrictEqual(range.end); + expect(slice.behavior).toBe(SliceBehavior.Stack); + expect(slice.type).toBe('b'); expect(slice.data()).toStrictEqual({bold: true}); }); test('can insert two slices', () => { const {peritext} = setup(); const {editor} = peritext; - editor.setCursor(6, 5); + editor.cursor.setAt(6, 5); const slice1 = editor.insertSlice('strong', {bold: true}); - editor.setCursor(12, 4); + editor.cursor.setAt(12, 4); const slice2 = editor.insertSlice('i', {italic: true}); peritext.refresh(); expect(peritext.slices.size()).toBe(2); @@ -59,7 +49,7 @@ describe('inserts', () => { expect(changed1).toBe(true); expect(changed2).toBe(false); expect(hash1).toBe(hash2); - editor.setCursor(12, 7); + editor.cursor.setAt(12, 7); editor.insertSlice('b', {bold: true}); const changed3 = peritext.slices.hash !== peritext.slices.refresh(); const hash3 = peritext.slices.hash; @@ -69,7 +59,7 @@ describe('inserts', () => { expect(changed4).toBe(false); expect(hash1).not.toStrictEqual(hash3); expect(hash3).toBe(hash4); - editor.setCursor(12, 4); + editor.cursor.setAt(12, 4); editor.insertSlice('em', {italic: true}); const changed5 = peritext.slices.hash !== peritext.slices.refresh(); const hash5 = peritext.slices.hash; @@ -80,18 +70,65 @@ describe('inserts', () => { expect(hash3).not.toBe(hash5); expect(hash5).toBe(hash6); }); + + test('can store all different slice range and metadata combinations', () => { + const {peritext} = setup(); + const r1 = peritext.range(peritext.pointAt(4, Anchor.Before), peritext.pointAt(6, Anchor.After)); + const r2 = peritext.range(peritext.pointAt(2, Anchor.After), peritext.pointAt(8, Anchor.After)); + const r3 = peritext.range(peritext.pointAt(2, Anchor.After), peritext.pointAt(8, Anchor.Before)); + const r4 = peritext.range(peritext.pointAt(0, Anchor.Before), peritext.pointAt(8, Anchor.Before)); + const ranges = [r1, r2, r3, r4]; + const types = ['b', ['li', 'ul'], 0, 123, [1, 2, 3]]; + const datas = [{bold: true}, {list: 'ul'}, 0, 123, [1, 2, 3], null, undefined]; + const behaviors = [SliceBehavior.Stack, SliceBehavior.Erase, SliceBehavior.Overwrite, SliceBehavior.Split]; + for (const range of ranges) { + for (const type of types) { + for (const data of datas) { + for (const behavior of behaviors) { + const {peritext, model} = setup(); + const slice = peritext.slices.ins(range, behavior, type, data); + expect(slice.start.cmp(range.start)).toBe(0); + expect(slice.end.cmp(range.end)).toBe(0); + expect(slice.behavior).toBe(behavior); + expect(slice.type).toStrictEqual(type); + expect(slice.data()).toStrictEqual(data); + const buf = model.toBinary(); + const model2 = Model.fromBinary(buf); + const peritext2 = new Peritext(model2, model2.api.str(['text']).node, model2.api.arr(['slices']).node); + peritext2.refresh(); + const slice2 = peritext2.slices.get(slice.id)!; + expect(slice2.start.cmp(range.start)).toBe(0); + expect(slice2.end.cmp(range.end)).toBe(0); + expect(slice2.behavior).toBe(behavior); + expect(slice2.type).toStrictEqual(type); + expect(slice2.data()).toStrictEqual(data); + } + } + } + } + }); }); -describe('deletes', () => { +describe('.get()', () => { + test('can retrieve slice by id', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(6, 5); + const slice = peritext.slices.insOverwrite(range, 'italic'); + const slice2 = peritext.slices.get(slice.id); + expect(slice2).toBe(slice); + }); +}); + +describe('.del()', () => { test('can delete a slice', () => { const {peritext} = setup(); const {editor} = peritext; - editor.setCursor(6, 5); + editor.cursor.setAt(6, 5); const slice1 = editor.insertSlice('b', {bold: true}); peritext.refresh(); const hash1 = peritext.slices.hash; expect(peritext.slices.size()).toBe(1); - peritext.delSlice(slice1.id); + peritext.slices.del(slice1.id); peritext.refresh(); const hash2 = peritext.slices.hash; expect(peritext.slices.size()).toBe(0); @@ -99,15 +136,87 @@ describe('deletes', () => { }); }); -describe('tag changes', () => { - test('recomputes hash on tag change', () => { +describe('.delSlices()', () => { + test('can delete a slice', () => { + const {peritext} = setup(); + const {editor} = peritext; + editor.cursor.setAt(6, 5); + const slice1 = editor.insertSlice('b', {bold: true}); + peritext.refresh(); + const hash1 = peritext.slices.hash; + expect(peritext.slices.size()).toBe(1); + peritext.slices.delSlices([slice1]); + peritext.refresh(); + const hash2 = peritext.slices.hash; + expect(peritext.slices.size()).toBe(0); + expect(hash1).not.toBe(hash2); + }); +}); + +describe('.refresh()', () => { + const testSliceUpdate = (name: string, update: (controls: {range: Range; slice: PersistedSlice}) => void) => { + test('changes hash on: ' + name, () => { + const {peritext, encodeAndDecode} = setup(); + const range = peritext.rangeAt(6, 5); + const slice = peritext.slices.insOverwrite(range, 'b', {howBold: 'very'}); + const hash1 = peritext.slices.refresh(); + const hash2 = peritext.slices.refresh(); + expect(hash1).toBe(hash2); + expect(slice.type).toBe('b'); + update({range, slice}); + const hash3 = peritext.slices.refresh(); + const hash4 = peritext.slices.refresh(); + expect(hash3).not.toBe(hash2); + expect(hash4).toBe(hash3); + const {peritext2} = encodeAndDecode(); + peritext2.refresh(); + const slice2 = peritext2.slices.get(slice.id)!; + expect(slice2.cmp(slice)).toBe(0); + }); + }; + + testSliceUpdate('slice behavior change', ({slice}) => { + slice.update({behavior: SliceBehavior.Stack}); + expect(slice.behavior).toBe(SliceBehavior.Stack); + }); + + testSliceUpdate('slice type change', ({slice}) => { + slice.update({type: 123}); + expect(slice.type).toBe(123); + }); + + testSliceUpdate('slice data overwrite', ({slice}) => { + slice.update({data: 'the data'}); + expect(slice.data()).toEqual('the data'); + }); + + testSliceUpdate('slice start anchor change', ({range, slice}) => { + range.start.anchor = Anchor.After; + slice.update({range}); + }); + + testSliceUpdate('slice end anchor change', ({range, slice}) => { + range.end.anchor = Anchor.Before; + slice.update({range}); + }); + + testSliceUpdate('slice start position', ({range, slice}) => { + range.start.id = range.start.nextId()!; + slice.update({range}); + }); + + testSliceUpdate('slice end position', ({range, slice}) => { + range.end.id = range.start.prevId()!; + slice.update({range}); + }); + + test('recomputes hash on inline data change', () => { const {peritext} = setup(); const {editor} = peritext; - editor.setCursor(6, 5); + editor.cursor.setAt(6, 5); const slice1 = editor.insertSlice('b', {bold: true}); peritext.refresh(); const hash1 = peritext.slices.hash; - const tag = slice1.data()!; peritext.model.api.obj(['slices', 0, 4]).set({bold: false}); peritext.refresh(); const hash2 = peritext.slices.hash; diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts b/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts new file mode 100644 index 0000000000..f31bb4c04b --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts @@ -0,0 +1,24 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; + +export const setup = () => { + const model = Model.withLogicalClock(12345678); + model.api.root({ + text: '', + slices: [], + }); + model.api.str(['text']).ins(0, 'wworld'); + model.api.str(['text']).ins(0, 'helo '); + model.api.str(['text']).ins(2, 'l'); + model.api.str(['text']).del(7, 1); + model.api.str(['text']).ins(11, ' this game is awesome'); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + const slices = peritext.slices; + const encodeAndDecode = () => { + const buf = model.toBinary(); + const model2 = Model.fromBinary(buf); + const peritext2 = new Peritext(model2, model2.api.str(['text']).node, model2.api.arr(['slices']).node); + return {model2, peritext2}; + }; + return {model, peritext, slices, encodeAndDecode}; +}; diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts similarity index 78% rename from src/json-crdt-extensions/peritext/constants.ts rename to src/json-crdt-extensions/peritext/slice/constants.ts index f91ba633f6..cbba411386 100644 --- a/src/json-crdt-extensions/peritext/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -1,6 +1,10 @@ -export const enum Anchor { - Before = 0, - After = 1, +/** + * Specifies which cursor end is the "anchor", e.g. the end which does not move + * when user changes selection. + */ +export const enum CursorAnchor { + Start = 0, + End = 1, } export const enum Tags { @@ -45,3 +49,11 @@ export const enum SliceBehavior { */ Erase = 0b011, } + +export const enum SliceTupleIndex { + Header = 0, + X1 = 1, + X2 = 2, + Type = 3, + Data = 4, +} diff --git a/src/json-crdt-extensions/peritext/slice/types.ts b/src/json-crdt-extensions/peritext/slice/types.ts index 04ef95a9e9..6afdd511de 100644 --- a/src/json-crdt-extensions/peritext/slice/types.ts +++ b/src/json-crdt-extensions/peritext/slice/types.ts @@ -1,13 +1,49 @@ -import type {Range} from './Range'; +import type {Range} from '../rga/Range'; import type {SliceType, Stateful} from '../types'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; -import type {SliceBehavior} from '../constants'; +import type {SliceBehavior} from './constants'; -export interface Slice extends Range, Stateful { - /** ID used for layer sorting. */ +export interface Slice extends Range, Stateful { + /** + * ID of the slice. ID is used for layer sorting. + */ id: ITimestampStruct; + + /** + * The low-level behavior of the slice. Specifies whether the slice is a split, + * i.e. a "marker" for a block split, in which case it represents a single + * place in the text where text is split into blocks. Otherwise, specifies + * the low-level behavior or the rich-text formatting of the slice. + */ behavior: SliceBehavior; + + /** + * The high-level behavior of the slice. Specifies the user-defined type of the + * slice, e.g. paragraph, heading, blockquote, etc. + */ type: SliceType; - data(): unknown; - del(): boolean; + + /** + * High-level user-defined metadata of the slice, which accompanies the slice + * type. + */ + data(): unknown | undefined; +} + +export interface MutableSlice extends Slice { + update(params: SliceUpdateParams): void; + + del(): void; + + /** + * Whether the slice is deleted. + */ + isDel(): boolean; +} + +export interface SliceUpdateParams { + behavior?: SliceBehavior; + type?: SliceType; + data?: unknown; + range?: Range; } diff --git a/src/json-crdt-extensions/peritext/slice/util.ts b/src/json-crdt-extensions/peritext/slice/util.ts new file mode 100644 index 0000000000..0a4ad670e3 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/util.ts @@ -0,0 +1,27 @@ +import type {SliceType} from '../types'; + +export const validateType = (type: SliceType) => { + switch (typeof type) { + case 'string': + case 'number': + return; + case 'object': { + if (!(type instanceof Array)) throw new Error('INVALID_TYPE'); + if (type.length > 32) throw new Error('INVALID_TYPE'); + const length = type.length; + LOOP: for (let i = 0; i < length; i++) { + const step = type[i]; + switch (typeof step) { + case 'string': + case 'number': + continue LOOP; + default: + throw new Error('INVALID_TYPE'); + } + } + return; + } + default: + throw new Error('INVALID_TYPE'); + } +}; diff --git a/src/json-pretty/index.ts b/src/json-pretty/index.ts new file mode 100644 index 0000000000..24e2d5f013 --- /dev/null +++ b/src/json-pretty/index.ts @@ -0,0 +1,5 @@ +export const prettyOneLine = (value: unknown): string => { + let json = JSON.stringify(value); + json = json.replace(/([\{\[\:\,])/g, '$1 ').replace(/([\}\]])/g, ' $1'); + return json; +}; diff --git a/src/util/trees/__bench__/bench.map.insert.nums.native.ts b/src/util/trees/__bench__/bench.map.insert.nums.native.ts index 0c3214118a..32a1b16511 100644 --- a/src/util/trees/__bench__/bench.map.insert.nums.native.ts +++ b/src/util/trees/__bench__/bench.map.insert.nums.native.ts @@ -3,6 +3,7 @@ import {runBenchmark, IBenchmark} from '../../../__bench__/runBenchmark'; import {Tree} from '../Tree'; import {AvlBstNumNumMap} from '../avl/AvlBstNumNumMap'; +import {AvlMap} from '../avl/AvlMap'; import {RadixTree} from '../radix/RadixTree'; import * as payloads from './payloads'; @@ -67,6 +68,20 @@ const benchmark: IBenchmark = { }; }, }, + { + name: 'json-joy AvlMap', + setup: () => { + return (num: unknown) => { + const map = new AvlMap(); + const numbers = num as number[]; + const length = numbers.length; + for (let i = 0; i < length; i++) { + const key = numbers[i]; + map.set(key, key); + } + }; + }, + }, { name: 'json-joy Tree', setup: () => { diff --git a/src/util/trees/__bench__/payloads.ts b/src/util/trees/__bench__/payloads.ts index e826e07cfd..aeac0d016d 100644 --- a/src/util/trees/__bench__/payloads.ts +++ b/src/util/trees/__bench__/payloads.ts @@ -8,6 +8,14 @@ const naturalNumbersRandomSmallList = Array.from({length: 100}, (_, i) => Math.f const naturalNumbersRandom = Array.from({length: 1000}, (_, i) => Math.floor(Math.random() * 1000)); export const numbers = [ + { + name: (json: any) => `Random ${naturalNumbersRandomSmallList.length} numbers`, + data: naturalNumbersRandomSmallList, + }, + { + name: (json: any) => `Random ${naturalNumbersRandom.length} numbers`, + data: naturalNumbersRandom, + }, { name: (json: any) => `${(json as any).length} natural numbers from ${(json as any)[0]} to ${(json as any)[(json as any).length - 1]}`, @@ -38,12 +46,4 @@ export const numbers = [ `${(json as any).length} natural numbers from ${(json as any)[0]} to ${(json as any)[(json as any).length - 1]}`, data: naturalNumbersReverse, }, - { - name: (json: any) => `Random ${naturalNumbersRandomSmallList.length} numbers`, - data: naturalNumbersRandomSmallList, - }, - { - name: (json: any) => `Random ${naturalNumbersRandom.length} numbers`, - data: naturalNumbersRandom, - }, ]; diff --git a/src/util/trees/avl/AvlMap.ts b/src/util/trees/avl/AvlMap.ts index e7df5adc9b..700eac2a2e 100644 --- a/src/util/trees/avl/AvlMap.ts +++ b/src/util/trees/avl/AvlMap.ts @@ -29,6 +29,7 @@ export class AvlMap implements Printable { public insert(k: K, v: V): AvlNodeReference> { const item = new AvlNode(k, v); this.root = insert(this.root, item, this.comparator); + this._size++; return item; } @@ -47,6 +48,7 @@ export class AvlMap implements Printable { const node = new AvlNode(k, v); this.root = cmp < 0 ? (insertLeft(root, node, curr) as AvlNode) : (insertRight(root, node, curr) as AvlNode); + this._size++; return node; } @@ -69,10 +71,12 @@ export class AvlMap implements Printable { const node = this.find(k); if (!node) return false; this.root = remove(this.root, node as IAvlTreeNode); + this._size--; return true; } public clear(): void { + this._size = 0; this.root = undefined; } @@ -80,13 +84,10 @@ export class AvlMap implements Printable { return !!this.find(k); } + public _size: number = 0; + public size(): number { - const root = this.root; - if (!root) return 0; - let curr = first(root); - let size = 1; - while ((curr = next(curr as HeadlessNode) as AvlNode | undefined)) size++; - return size; + return this._size; } public isEmpty(): boolean { diff --git a/src/util/trees/util.ts b/src/util/trees/util.ts index 9894abb2c6..0ef39d42d3 100644 --- a/src/util/trees/util.ts +++ b/src/util/trees/util.ts @@ -44,13 +44,14 @@ export const prev = (curr: N): N | undefined => { return p; }; +const size_ = (root: N): number => { + const l = root.l; + const r = root.r; + return 1 + (l ? size_(l) : 0) + (r ? size_(r) : 0); +}; + export const size = (root: N | undefined): number => { - if (!root) return 0; - const start = first(root)!; - let curr: N | undefined = start; - let result = 1; - while ((curr = next(curr))) result++; - return result; + return root ? size_(root) : 0; }; export const find = (