From 25832e487eac3db35157af05fdc4088587ca0fc7 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:13:45 +0200 Subject: [PATCH 01/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20Stateful=20node=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/types.ts diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts new file mode 100644 index 0000000000..44f873dd52 --- /dev/null +++ b/src/json-crdt-extensions/peritext/types.ts @@ -0,0 +1,15 @@ +/** + * Represents an object which state can change over time. + */ +export interface Stateful { + /** + * Hash of the current state. Updated by calling `refresh()`. + */ + hash: number; + + /** + * Recomputes object's hash. + * @returns The new hash. + */ + refresh(): number; +} From 24c893ed1a2b8a2fd36e301a5d9e1cb66b1efcf5 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:15:08 +0200 Subject: [PATCH 02/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20slice=20serialization=20DTO=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/types.ts | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts index 44f873dd52..2b3f484a29 100644 --- a/src/json-crdt-extensions/peritext/types.ts +++ b/src/json-crdt-extensions/peritext/types.ts @@ -1,3 +1,7 @@ +import type {ITimestampStruct} from "../../json-crdt-patch"; +import type {Chunk} from "../../json-crdt/nodes/rga"; +import type {Path, PathStep} from "../../json-pointer"; + /** * Represents an object which state can change over time. */ @@ -13,3 +17,42 @@ export interface Stateful { */ refresh(): number; } + +export type StringChunk = Chunk; + +export type IdDto = [sid: number, time: number]; + +export type SpanDto = [sid: number, time: number, length: number]; + +export type SliceType = PathStep | Path; + +export type SliceDto = [ + /** + * Stores the behavior of the slice as well as anchor points of x1 and x2. + */ + flags: number, + + /** + * Start point of the slice. + */ + x1: ITimestampStruct, + + /** + * End point of the slice, if 0 then it is equal to x1. + */ + x2: ITimestampStruct | 0, + + /** + * App specific type of the slice. For slices with "split" behavior, this + * is a path of block nesting. For other slices, it specifies inline formatting, such + * as bold, italic, etc.; the value has to be a primitive number or a string. + */ + type: SliceType, + + /** + * Reference to additional metadata about the slice, usually an object. If + * data is not set, it will default to `1`. For "erase" slice behavior, data + * should not be specified. + */ + data?: unknown, +]; From 3cbc256c849c4d1ef5482465523d86e0d82f9eeb Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:18:07 +0200 Subject: [PATCH 03/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20specify=20Peritext=20slices=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/constants.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/constants.ts diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/constants.ts new file mode 100644 index 0000000000..dbb27c7074 --- /dev/null +++ b/src/json-crdt-extensions/peritext/constants.ts @@ -0,0 +1,43 @@ +export const enum Anchor { + Before = 0, + After = 1, +} + +export const enum SliceHeaderMask { + X1Anchor = 0b1, + X2Anchor = 0b10, + Behavior = 0b11100, +} + +export const enum SliceHeaderShift { + X1Anchor = 0, + X2Anchor = 1, + Behavior = 2, +} + +export const enum SliceBehavior { + /** + * A Split slice, which is used to mark a block split position in the document. + * For example, paragraph, heading, blockquote, etc. + */ + Split = 0b000, + + /** + * Appends attributes to a stack of attributes for a specific slice type. This + * is useful when the same slice type can have multiple attributes, like + * inline comments, highlights, etc. + */ + Stack = 0b001, + + /** + * Overwrites the stack of attributes for a specific slice type. Could be used + * for simple inline formatting, like bold, italic, etc. + */ + Overwrite = 0b010, + + /** + * Removes all attributes for a specific slice type. For example, could be + * used to re-verse inline formatting, like bold, italic, etc. + */ + Erase = 0b011, +} From b711e75c7c8721a52761be5dffdc53ec2bb9df57 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:26:20 +0200 Subject: [PATCH 04/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20setup=20Peritext=20base=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/Peritext.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts new file mode 100644 index 0000000000..69255a0888 --- /dev/null +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -0,0 +1,204 @@ +import {PersistedSlice} from './slice/PersistedSlice'; +import {Slices} from './slice/Slices'; +import {Overlay} from './overlay/Overlay'; +import {Anchor, SliceBehavior} from './constants'; +import {Point} from './point/Point'; +import {CONST, updateNum} from '../../json-hash'; +import {Blocks} from './block/Blocks'; +import {printTree} from '../../util/print/printTree'; +import {Editor} from './editor/Editor'; +import {Range} from './slice/Range'; +import {interval} from '../../json-crdt-patch/clock'; +import {type ITimestampStruct} from '../../json-crdt-patch/clock'; +import type {Model} from '../../json-crdt/model'; +import type {ArrayRga} from '../../json-crdt/types/rga-array/ArrayRga'; +import type {StringRga} from '../../json-crdt/types/rga-string/StringRga'; +import type {SliceType, Stateful, StringChunk} from './types'; +import type {Printable} from '../../util/print/types'; +import type {SplitSlice} from './slice/SplitSlice'; + +export class Peritext implements Printable, Stateful { + public readonly slices: Slices; + public readonly overlay = new Overlay(this); + public readonly blocks: Blocks; + public readonly editor: Editor; + + constructor(public readonly model: Model, public readonly str: StringRga, slices: ArrayRga) { + this.slices = new Slices(this, slices); + this.blocks = new Blocks(this); + this.editor = new Editor(this); + } + + public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { + return new Point(this, id, anchor); + } + + public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { + const str = this.str; + const id = str.find(pos); + if (!id) return this.point(str.id, Anchor.After); + return this.point(id, anchor); + } + + public range(start: Point, end: Point): Range { + return new Range(this, start, end); + } + + public rangeAt(start: number, length: number = 0): Range { + const str = this.str; + if (!length) { + const startId = !start ? str.id : str.find(start - 1) || str.id; + const point = this.point(startId, Anchor.After); + return this.range(point, point); + } + const startId = str.find(start) || str.id; + const endId = str.find(start + length - 1) || startId; + const startEndpoint = this.point(startId, Anchor.Before); + const endEndpoint = this.point(endId, Anchor.After); + return this.range(startEndpoint, endEndpoint); + } + + public insAt(pos: number, text: string): void { + const str = this.model.api.wrap(this.str); + str.ins(pos, text); + } + + public ins(after: ITimestampStruct, text: string): ITimestampStruct { + if (!text) throw new Error('NO_TEXT'); + const api = this.model.api; + const textId = api.builder.insStr(this.str.id, after, text); + api.apply(); + return textId; + } + + public insSplit(after: ITimestampStruct, type: SliceType, data?: unknown, char: string = '\n'): SplitSlice { + const api = this.model.api; + const builder = api.builder; + const str = this.str; + /** + * We skip one clock cycle to prevent Block-wise-RGA from merging adjacent + * characters. We wan the split chunk to be a distinct chunk. + */ + builder.nop(1); + const textId = builder.insStr(str.id, after, char[0]); + const point = this.point(textId, Anchor.Before); + const range = this.range(point, point); + return this.slices.ins(range, SliceBehavior.Split, type, data); + } + + /** @todo This can probably use .del() */ + public delSplit(split: SplitSlice): void { + const str = this.str; + const api = this.model.api; + const builder = api.builder; + const strChunk = split.start.chunk(); + if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]); + builder.del(this.slices.set.id, [interval(split.id, 0, 1)]); + api.apply(); + } + + 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; + } + + public delAt(pos: number, len: number): void { + const range = this.rangeAt(pos, len); + this.del(range); + } + + public del(range: Range): void { + this.delSlices(range); + this.delStr(range); + } + + public delStr(range: Range): void { + const nothingToDelete = range.isCollapsed(); + if (nothingToDelete) return; + const {start, end} = range; + const deleteStartId = start.anchor === Anchor.Before ? start.id : start.nextId(); + const deleteEndId = end.anchor === Anchor.After ? end.id : end.prevId(); + const str = this.str; + if (!deleteStartId || !deleteEndId) throw new Error('INVALID_RANGE'); + const spans = str.findInterval2(deleteStartId, deleteEndId); + const model = this.model; + const api = model.api; + api.builder.del(str.id, spans); + api.apply(); + if (start.anchor === Anchor.After) range.setCaret(start.id); + else range.setCaret(start.prevId() || str.id); + } + + public delSlices(range: Range): void { + this.overlay.refresh(); + range = range.clone(); + range.expand(); + const slices = this.overlay.findContained(range); + this.slices.delMany(Array.from(slices)); + } + + 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) { + const chunk = point.chunk(); + if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point); + } + const id = point.prevId(); + if (!id) return; + return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After)); + } + + public firstVisibleChunk(): StringChunk | undefined { + const str = this.str; + let curr = str.first(); + if (!curr) return; + while (curr.del) { + curr = str.next(curr); + if (!curr) return; + } + return curr; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const nl = () => ''; + return ( + this.constructor.name + + printTree(tab, [ + (tab) => this.editor.cursor.toString(tab), + nl, + (tab) => this.str.toString(tab), + nl, + (tab) => this.slices.toString(tab), + nl, + (tab) => this.overlay.toString(tab), + nl, + (tab) => this.blocks.toString(tab), + ]) + ); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + let state: number = CONST.START_STATE; + this.overlay.refresh(); + state = updateNum(state, this.blocks.refresh()); + state = updateNum(state, this.overlay.hash); + return (this.hash = state); + } +} From 189438899f365e5cd73da332b0a26b86d221419f Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:42:00 +0200 Subject: [PATCH 05/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ChunkSlice=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/types.ts | 3 --- src/json-crdt-extensions/peritext/util/types.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/util/types.ts diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts index 2b3f484a29..138a047e71 100644 --- a/src/json-crdt-extensions/peritext/types.ts +++ b/src/json-crdt-extensions/peritext/types.ts @@ -1,5 +1,4 @@ import type {ITimestampStruct} from "../../json-crdt-patch"; -import type {Chunk} from "../../json-crdt/nodes/rga"; import type {Path, PathStep} from "../../json-pointer"; /** @@ -18,8 +17,6 @@ export interface Stateful { refresh(): number; } -export type StringChunk = Chunk; - export type IdDto = [sid: number, time: number]; export type SpanDto = [sid: number, time: number, length: number]; diff --git a/src/json-crdt-extensions/peritext/util/types.ts b/src/json-crdt-extensions/peritext/util/types.ts new file mode 100644 index 0000000000..ebe9c39b94 --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/types.ts @@ -0,0 +1,8 @@ +import type {Chunk} from "../../../json-crdt/nodes/rga"; + +export type StringChunk = Chunk; + +export interface IChunkSlice { + readonly chunk: StringChunk; + view(): string; +} From 99b31690d27981d934899e9d65f8988871bece54 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:43:54 +0200 Subject: [PATCH 06/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20ChunkSlice=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/util/ChunkSlice.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/util/ChunkSlice.ts diff --git a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts new file mode 100644 index 0000000000..4c6c3cc2a9 --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts @@ -0,0 +1,69 @@ +import {CONST, updateNum} from '../../../json-hash'; +import {updateId} from '../../../json-crdt/hash'; +import {ITimestampStruct, Timestamp, toDisplayString} from '../../../json-crdt-patch/clock'; +import type {StringChunk, IChunkSlice} from './types'; +import type {Stateful} from '../types'; +import type {Printable} from '../../../util/print/types'; + +export class ChunkSlice implements IChunkSlice, Stateful, Printable { + constructor( + /** Chunk from which slice is computed. */ + chunk: StringChunk, + /** Start offset of the slice within the chunk. */ + public off: number, + /** Length of the slice. */ + public len: number, + ) { + this.chunk = chunk; + } + + // -------------------------------------------------------------- IChunkSlice + + public readonly chunk: StringChunk; + + public id(): ITimestampStruct { + const id = this.chunk.id; + const off = this.off; + return !off ? id : new Timestamp(id.sid, id.time + off); + } + + public key(): number | string { + const id = this.chunk.id; + const sid = id.sid; + const time = id.time + this.off; + return sid.toString(36) + time.toString(36); + } + + public view(): string { + const str = this.chunk.data; + if (!str) return ''; + // TODO: perf: if whole chunk is sliced, return chunk.data directly. + return str.slice(this.off, this.off + this.len); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + const {chunk, off, len} = this; + const delOffLenState = (((off << 16) + len) << 1) + +chunk.del; + let state = CONST.START_STATE; + state = updateId(state, chunk.id); + state = updateNum(state, delOffLenState); + return (this.hash = state); + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const name = this.constructor.name; + const off = this.off; + const len = this.len; + const str = this.view(); + const truncate = str.length > 32; + const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); + const id = toDisplayString(this.chunk.id); + return `${name} ${id} [${off}..${off + len}) ${text}`; + } +} From 899fdb0c0dcf32259beb5145e44208ed16d1b61a Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:54:09 +0200 Subject: [PATCH 07/22] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20.?= =?UTF-8?q?view()=20method=20to=20RGA=20chunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/nodes/arr/ArrNode.ts | 4 ++++ src/json-crdt/nodes/bin/BinNode.ts | 4 ++++ src/json-crdt/nodes/rga/AbstractRga.ts | 2 ++ src/json-crdt/nodes/str/StrNode.ts | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/src/json-crdt/nodes/arr/ArrNode.ts b/src/json-crdt/nodes/arr/ArrNode.ts index be7cee4221..23977f0424 100644 --- a/src/json-crdt/nodes/arr/ArrNode.ts +++ b/src/json-crdt/nodes/arr/ArrNode.ts @@ -63,6 +63,10 @@ export class ArrChunk implements Chunk { public clone(): ArrChunk { return new ArrChunk(this.id, this.span, this.data ? [...this.data] : undefined); } + + public view(): E[] { + return this.data ? [...this.data] : []; + } } /** diff --git a/src/json-crdt/nodes/bin/BinNode.ts b/src/json-crdt/nodes/bin/BinNode.ts index 1149e8c361..a30fe16c19 100644 --- a/src/json-crdt/nodes/bin/BinNode.ts +++ b/src/json-crdt/nodes/bin/BinNode.ts @@ -64,6 +64,10 @@ export class BinChunk implements Chunk { const chunk = new BinChunk(this.id, this.span, this.data); return chunk; } + + public view(): Uint8Array { + return this.data || new Uint8Array(0); + } } /** diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index 5e0437e04c..fe9fcbf101 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -55,6 +55,8 @@ export interface Chunk { delete(): void; /** Return a deep copy of itself. */ clone(): Chunk; + /** Return the data of the chunk, if not deleted. */ + view(): T; } const compareById = (c1: Chunk, c2: Chunk): number => { diff --git a/src/json-crdt/nodes/str/StrNode.ts b/src/json-crdt/nodes/str/StrNode.ts index 0f29711ce4..0338fa584f 100644 --- a/src/json-crdt/nodes/str/StrNode.ts +++ b/src/json-crdt/nodes/str/StrNode.ts @@ -62,6 +62,10 @@ export class StrChunk implements Chunk { const chunk = new StrChunk(this.id, this.span, this.data); return chunk; } + + public view(): string { + return this.data; + } } /** From b3db4c3302b8e25ede63f974af8a0d990d94c542 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 14:54:44 +0200 Subject: [PATCH 08/22] =?UTF-8?q?refactor(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=A1=20make=20ChunkSlice=20generic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/util/ChunkSlice.ts | 18 ++++++++---------- .../peritext/util/types.ts | 6 +++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts index 4c6c3cc2a9..37f5f89e65 100644 --- a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts +++ b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts @@ -1,14 +1,15 @@ import {CONST, updateNum} from '../../../json-hash'; import {updateId} from '../../../json-crdt/hash'; import {ITimestampStruct, Timestamp, toDisplayString} from '../../../json-crdt-patch/clock'; -import type {StringChunk, IChunkSlice} from './types'; +import type {IChunkSlice} from './types'; import type {Stateful} from '../types'; import type {Printable} from '../../../util/print/types'; +import type {Chunk} from '../../../json-crdt/nodes/rga'; -export class ChunkSlice implements IChunkSlice, Stateful, Printable { +export class ChunkSlice implements IChunkSlice, Stateful, Printable { constructor( /** Chunk from which slice is computed. */ - chunk: StringChunk, + chunk: Chunk, /** Start offset of the slice within the chunk. */ public off: number, /** Length of the slice. */ @@ -19,7 +20,7 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printable { // -------------------------------------------------------------- IChunkSlice - public readonly chunk: StringChunk; + public readonly chunk: Chunk; public id(): ITimestampStruct { const id = this.chunk.id; @@ -34,11 +35,8 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printable { return sid.toString(36) + time.toString(36); } - public view(): string { - const str = this.chunk.data; - if (!str) return ''; - // TODO: perf: if whole chunk is sliced, return chunk.data directly. - return str.slice(this.off, this.off + this.len); + public view(): T { + return this.chunk.view(); } // ----------------------------------------------------------------- Stateful @@ -60,7 +58,7 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printable { const name = this.constructor.name; const off = this.off; const len = this.len; - const str = this.view(); + const str = this.view() + ''; const truncate = str.length > 32; const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); const id = toDisplayString(this.chunk.id); diff --git a/src/json-crdt-extensions/peritext/util/types.ts b/src/json-crdt-extensions/peritext/util/types.ts index ebe9c39b94..b09b79c346 100644 --- a/src/json-crdt-extensions/peritext/util/types.ts +++ b/src/json-crdt-extensions/peritext/util/types.ts @@ -2,7 +2,7 @@ import type {Chunk} from "../../../json-crdt/nodes/rga"; export type StringChunk = Chunk; -export interface IChunkSlice { - readonly chunk: StringChunk; - view(): string; +export interface IChunkSlice { + readonly chunk: Chunk; + view(): T; } From 6627032e48b7ccb17853f326d1618a9df7e0e1c1 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 5 Apr 2024 21:26:07 +0200 Subject: [PATCH 09/22] =?UTF-8?q?fix(json-crdt-extensions):=20=F0=9F=90=9B?= =?UTF-8?q?=20compute=20ChunkSlice=20view=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/util/ChunkSlice.ts | 9 +- .../util/__tests__/ChunkSlice.spec.ts | 84 +++++++++++++++++++ src/json-crdt/nodes/rga/AbstractRga.ts | 2 +- 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts diff --git a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts index 37f5f89e65..67fe0a628a 100644 --- a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts +++ b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts @@ -28,7 +28,7 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printab return !off ? id : new Timestamp(id.sid, id.time + off); } - public key(): number | string { + public key(): string { const id = this.chunk.id; const sid = id.sid; const time = id.time + this.off; @@ -36,7 +36,8 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printab } public view(): T { - return this.chunk.view(); + const offset = this.off; + return this.chunk.view().slice(offset, offset + this.len); } // ----------------------------------------------------------------- Stateful @@ -60,8 +61,8 @@ export class ChunkSlice implements IChunkSlice, Stateful, Printab const len = this.len; const str = this.view() + ''; const truncate = str.length > 32; - const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); + const view = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); const id = toDisplayString(this.chunk.id); - return `${name} ${id} [${off}..${off + len}) ${text}`; + return `${name} ${id} [${off}..${off + len}) ${view}`; } } diff --git a/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts new file mode 100644 index 0000000000..26a10631ae --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts @@ -0,0 +1,84 @@ +import {s} from '../../../../json-crdt-patch'; +import {Model} from '../../../../json-crdt/model'; +import {ChunkSlice} from '../ChunkSlice'; + +const setup = () => { + const model = Model.withLogicalClock().setSchema(s.str('Hello world')); + const node = model.root.node(); + const chunk = node.first()!; + return { + model, + node, + chunk, + }; +}; + +describe('.id()', () => { + it('slice ID is equal to chunk ID plus offset', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 2, 2); + expect(slice.id().sid).toBe(chunk.id.sid); + expect(slice.id().time).toBe(chunk.id.time + 2); + }); +}); + +describe('.view()', () => { + it('can create a one-char slice in a visible chunk', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 0, 1); + expect(slice.view()).toBe('H'); + }); + + it('can create a two-char slice in a visible chunk', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 0, 2); + expect(slice.view()).toBe('He'); + }); + + it('can create a three-char slice in a visible chunk in the middle', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 3, 3); + expect(slice.view()).toBe('lo '); + }); + + it('can create a four-char slice in a visible chunk at the end', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 7, 4); + expect(slice.view()).toBe('orld'); + }); +}); + +describe('.refresh()', () => { + it('hash changes depending on slice position', () => { + const {chunk} = setup(); + const slice1 = new ChunkSlice(chunk, 0, 3); + const slice2 = new ChunkSlice(chunk, 1, 3); + const slice3 = new ChunkSlice(chunk, 2, 3); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + const hash3 = slice3.refresh(); + expect(hash1).not.toBe(hash2); + expect(hash2).not.toBe(hash3); + expect(hash3).not.toBe(hash1); + }); + + it('hash is the same for the same position', () => { + const {chunk} = setup(); + const slice1 = new ChunkSlice(chunk, 1, 6); + const slice2 = new ChunkSlice(chunk, 1, 6); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + expect(hash1).toBe(hash2); + }); + + it('hash is different for different chunks', () => { + const {chunk: chunk1} = setup(); + const {chunk: chunk2} = setup(); + const slice1 = new ChunkSlice(chunk1, 1, 6); + const slice2 = new ChunkSlice(chunk2, 1, 6); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + expect(hash1).not.toBe(hash2); + }); +}); + diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index fe9fcbf101..3df62cd776 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -56,7 +56,7 @@ export interface Chunk { /** Return a deep copy of itself. */ clone(): Chunk; /** Return the data of the chunk, if not deleted. */ - view(): T; + view(): T & {slice: (start: number, end: number) => T}; } const compareById = (c1: Chunk, c2: Chunk): number => { From 70c98c499b1ec043dd42ad7ad1fe89a118351995 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 14:52:26 +0200 Subject: [PATCH 10/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20Peritext=20Point=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 178 +------------ .../peritext/point/Point.ts | 251 ++++++++++++++++++ .../peritext/point/__tests__/Point.spec.ts | 167 ++++++++++++ 3 files changed, 422 insertions(+), 174 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/point/Point.ts create mode 100644 src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 69255a0888..35e5f4de1e 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,33 +1,13 @@ -import {PersistedSlice} from './slice/PersistedSlice'; -import {Slices} from './slice/Slices'; -import {Overlay} from './overlay/Overlay'; -import {Anchor, SliceBehavior} from './constants'; +import {Anchor} from './constants'; import {Point} from './point/Point'; -import {CONST, updateNum} from '../../json-hash'; -import {Blocks} from './block/Blocks'; import {printTree} from '../../util/print/printTree'; -import {Editor} from './editor/Editor'; -import {Range} from './slice/Range'; -import {interval} from '../../json-crdt-patch/clock'; +import {ArrNode, StrNode} from '../../json-crdt/nodes'; import {type ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Model} from '../../json-crdt/model'; -import type {ArrayRga} from '../../json-crdt/types/rga-array/ArrayRga'; -import type {StringRga} from '../../json-crdt/types/rga-string/StringRga'; -import type {SliceType, Stateful, StringChunk} from './types'; import type {Printable} from '../../util/print/types'; -import type {SplitSlice} from './slice/SplitSlice'; -export class Peritext implements Printable, Stateful { - public readonly slices: Slices; - public readonly overlay = new Overlay(this); - public readonly blocks: Blocks; - public readonly editor: Editor; - - constructor(public readonly model: Model, public readonly str: StringRga, slices: ArrayRga) { - this.slices = new Slices(this, slices); - this.blocks = new Blocks(this); - this.editor = new Editor(this); - } +export class Peritext implements Printable { + constructor(public readonly model: Model, public readonly str: StrNode, slices: ArrNode) {} public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { return new Point(this, id, anchor); @@ -40,136 +20,6 @@ export class Peritext implements Printable, Stateful { return this.point(id, anchor); } - public range(start: Point, end: Point): Range { - return new Range(this, start, end); - } - - public rangeAt(start: number, length: number = 0): Range { - const str = this.str; - if (!length) { - const startId = !start ? str.id : str.find(start - 1) || str.id; - const point = this.point(startId, Anchor.After); - return this.range(point, point); - } - const startId = str.find(start) || str.id; - const endId = str.find(start + length - 1) || startId; - const startEndpoint = this.point(startId, Anchor.Before); - const endEndpoint = this.point(endId, Anchor.After); - return this.range(startEndpoint, endEndpoint); - } - - public insAt(pos: number, text: string): void { - const str = this.model.api.wrap(this.str); - str.ins(pos, text); - } - - public ins(after: ITimestampStruct, text: string): ITimestampStruct { - if (!text) throw new Error('NO_TEXT'); - const api = this.model.api; - const textId = api.builder.insStr(this.str.id, after, text); - api.apply(); - return textId; - } - - public insSplit(after: ITimestampStruct, type: SliceType, data?: unknown, char: string = '\n'): SplitSlice { - const api = this.model.api; - const builder = api.builder; - const str = this.str; - /** - * We skip one clock cycle to prevent Block-wise-RGA from merging adjacent - * characters. We wan the split chunk to be a distinct chunk. - */ - builder.nop(1); - const textId = builder.insStr(str.id, after, char[0]); - const point = this.point(textId, Anchor.Before); - const range = this.range(point, point); - return this.slices.ins(range, SliceBehavior.Split, type, data); - } - - /** @todo This can probably use .del() */ - public delSplit(split: SplitSlice): void { - const str = this.str; - const api = this.model.api; - const builder = api.builder; - const strChunk = split.start.chunk(); - if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]); - builder.del(this.slices.set.id, [interval(split.id, 0, 1)]); - api.apply(); - } - - 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; - } - - public delAt(pos: number, len: number): void { - const range = this.rangeAt(pos, len); - this.del(range); - } - - public del(range: Range): void { - this.delSlices(range); - this.delStr(range); - } - - public delStr(range: Range): void { - const nothingToDelete = range.isCollapsed(); - if (nothingToDelete) return; - const {start, end} = range; - const deleteStartId = start.anchor === Anchor.Before ? start.id : start.nextId(); - const deleteEndId = end.anchor === Anchor.After ? end.id : end.prevId(); - const str = this.str; - if (!deleteStartId || !deleteEndId) throw new Error('INVALID_RANGE'); - const spans = str.findInterval2(deleteStartId, deleteEndId); - const model = this.model; - const api = model.api; - api.builder.del(str.id, spans); - api.apply(); - if (start.anchor === Anchor.After) range.setCaret(start.id); - else range.setCaret(start.prevId() || str.id); - } - - public delSlices(range: Range): void { - this.overlay.refresh(); - range = range.clone(); - range.expand(); - const slices = this.overlay.findContained(range); - this.slices.delMany(Array.from(slices)); - } - - 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) { - const chunk = point.chunk(); - if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point); - } - const id = point.prevId(); - if (!id) return; - return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After)); - } - - public firstVisibleChunk(): StringChunk | undefined { - const str = this.str; - let curr = str.first(); - if (!curr) return; - while (curr.del) { - curr = str.next(curr); - if (!curr) return; - } - return curr; - } - // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { @@ -177,28 +27,8 @@ export class Peritext implements Printable, Stateful { return ( this.constructor.name + printTree(tab, [ - (tab) => this.editor.cursor.toString(tab), - nl, (tab) => this.str.toString(tab), - nl, - (tab) => this.slices.toString(tab), - nl, - (tab) => this.overlay.toString(tab), - nl, - (tab) => this.blocks.toString(tab), ]) ); } - - // ----------------------------------------------------------------- Stateful - - public hash: number = 0; - - public refresh(): number { - let state: number = CONST.START_STATE; - this.overlay.refresh(); - state = updateNum(state, this.blocks.refresh()); - state = updateNum(state, this.overlay.hash); - return (this.hash = state); - } } diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts new file mode 100644 index 0000000000..17100e5736 --- /dev/null +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -0,0 +1,251 @@ +import {compare, type ITimestampStruct, toDisplayString, equal, tick, containsId} from '../../../json-crdt-patch/clock'; +import {Anchor} from '../constants'; +import {ChunkSlice} from '../util/ChunkSlice'; +import {updateId} from '../../../json-crdt/hash'; +import type {Stateful} from '../types'; +import type {Peritext} from '../Peritext'; +import type {Printable} from '../../../util/print/types'; +import type {StringChunk} from '../util/types'; + +/** + * A "point" in a rich-text Peritext document. It is a combination of a + * character ID and an anchor. Anchor specifies the side of the character to + * which the point is attached. For example, a point with an anchor "before" + * points just before the character, while a point with an anchor "after" points + * just after the character. + */ +export class Point implements Pick, Printable { + constructor(protected readonly txt: Peritext, public id: ITimestampStruct, public anchor: Anchor) {} + + public set(point: Point): void { + this.id = point.id; + this.anchor = point.anchor; + } + + public clone(): Point { + return new Point(this.txt, this.id, this.anchor); + } + + public compare(other: Point): -1 | 0 | 1 { + const cmp = compare(this.id, other.id); + if (cmp !== 0) return cmp; + return (this.anchor - other.anchor) as -1 | 0 | 1; + } + + public compareSpatial(other: Point): number { + const thisId = this.id; + const otherId = other.id; + const cmp0 = compare(thisId, otherId); + if (!cmp0) return this.anchor - other.anchor; + const cmp1 = this.pos() - other.pos(); + if (cmp1) return cmp1; + let chunk = this.chunk(); + if (!chunk) return cmp0; + if (containsId(chunk.id, chunk.span, otherId)) return thisId.time - otherId.time; + const str = this.txt.str; + chunk = str.next(chunk); + while (chunk) { + if (containsId(chunk.id, chunk.span, otherId)) return -1; + chunk = str.next(chunk); + } + return 1; + } + + private _chunk: StringChunk | undefined; + public chunk(): StringChunk | undefined { + let chunk = this._chunk; + const id = this.id; + if (chunk) { + const chunkId = chunk.id; + const chunkIdTime = chunkId.time; + const idTime = id.time; + if (id.sid === chunkId.sid && idTime >= chunkIdTime && idTime < chunkIdTime + chunk.span) return chunk; + } + this._chunk = chunk = this.txt.str.findById(this.id); + return chunk; + } + + public pos(): number { + const chunk = this.chunk(); + if (!chunk) return -1; + const pos = this.txt.str.pos(chunk); + if (chunk.del) return pos; + return pos + this.id.time - chunk.id.time; + } + + private _pos: number = -1; + /** @todo Is this needed? */ + public posCached(): number { + if (this._pos >= 0) return this._pos; + return (this._pos = this.pos()); + } + + public viewPos(): number { + const pos = this.pos(); + if (pos < 0) return 0; + return this.anchor === Anchor.Before ? pos : pos + 1; + } + + public nextId(move: number = 1): ITimestampStruct | undefined { + let remaining: number = move; + const {id, txt} = this; + const str = txt.str; + const startFromStrRoot = equal(id, str.id); + let chunk: StringChunk | undefined; + if (startFromStrRoot) { + chunk = str.first(); + while (chunk && chunk.del) chunk = str.next(chunk); + if (!chunk) return; + const span = chunk.span; + if (remaining <= span) return tick(chunk.id, remaining - 1); + remaining -= span; + chunk = str.next(chunk); + } else { + chunk = this.chunk(); + if (!chunk) return undefined; + if (!chunk.del) { + const offset = id.time - chunk.id.time; + const span = chunk.span; + if (offset + remaining < span) return tick(id, remaining); + else remaining -= span - offset - 1; + } + chunk = str.next(chunk); + } + let lastVisibleChunk: StringChunk | undefined; + while (chunk && remaining >= 0) { + if (chunk.del) { + chunk = str.next(chunk); + continue; + } + lastVisibleChunk = chunk; + const span = chunk.span; + if (remaining <= span) return remaining > 1 ? tick(chunk.id, remaining - 1) : chunk.id; + remaining -= span; + chunk = str.next(chunk); + } + return lastVisibleChunk ? tick(lastVisibleChunk.id, lastVisibleChunk.span - 1) : undefined; + } + + public prevId(move: number = 1): ITimestampStruct | undefined { + let remaining: number = move; + const {id, txt} = this; + const str = txt.str; + let chunk = this.chunk(); + if (!chunk) return str.id; + if (!chunk.del) { + const offset = id.time - chunk.id.time; + if (offset >= remaining) return tick(id, -remaining); + remaining -= offset; + } + chunk = str.prev(chunk); + while (chunk) { + if (chunk.del) { + chunk = str.prev(chunk); + continue; + } + const span = chunk.span; + if (remaining <= span) { + return tick(chunk.id, span - remaining); + } + remaining -= span; + chunk = str.prev(chunk); + } + return str.id; + } + + public rightChar(): ChunkSlice | undefined { + const str = this.txt.str; + const isBeginningOfDoc = equal(this.id, str.id) && this.anchor === Anchor.After; + if (isBeginningOfDoc) { + let chunk = str.first(); + while (chunk && chunk.del) chunk = str.next(chunk); + return chunk ? new ChunkSlice(chunk, 0, 1) : undefined; + } + let chunk = this.chunk(); + if (!chunk || chunk.del) return; + if (this.anchor === Anchor.Before) { + const off = this.id.time - chunk.id.time; + return new ChunkSlice(chunk, off, 1); + } + const off = this.id.time - chunk.id.time + 1; + if (off < chunk.span) return new ChunkSlice(chunk, off, 1); + chunk = str.next(chunk); + while (chunk && chunk.del) chunk = str.next(chunk); + if (!chunk) return; + return new ChunkSlice(chunk, 0, 1); + } + + public leftChar(): ChunkSlice | undefined { + let chunk = this.chunk(); + if (!chunk || chunk.del) return; + if (this.anchor === Anchor.After) { + const off = this.id.time - chunk.id.time; + return new ChunkSlice(chunk, off, 1); + } + const off = this.id.time - chunk.id.time - 1; + if (off > 0) return new ChunkSlice(chunk, off, 1); + const str = this.txt.str; + chunk = str.prev(chunk); + while (chunk && chunk.del) chunk = str.prev(chunk); + if (!chunk) return; + return new ChunkSlice(chunk, chunk.span - 1, 1); + } + + /** + * Moves point past given number of visible characters. Accepts positive + * and negative distances. + */ + public move(skip: number): void { + // TODO: handle cases when cursor reaches ends of string, it should adjust anchor positions as well + if (!skip) return; + if (skip > 0) { + const nextId = this.nextId(skip); + if (nextId) this.id = nextId; + } else { + const prevId = this.prevId(-skip); + if (prevId) this.id = prevId; + } + } + + /** + * Returns a point, which points at the same spatial location, but ensures + * that it is anchored after a character. + */ + public anchorBefore(): Point { + if (this.anchor === Anchor.Before) return this; + const next = this.nextId(); + const txt = this.txt; + if (!next) return new Point(txt, txt.str.id, Anchor.Before); + return new Point(txt, next, Anchor.Before); + } + + /** + * Returns a point, which points at the same spatial location, but ensures + * that it is anchored after a character. + */ + public anchorAfter(): Point { + if (this.anchor === Anchor.After) return this; + const prev = this.prevId(); + const txt = this.txt; + if (!prev) return new Point(txt, txt.str.id, Anchor.After); + return new Point(txt, prev, Anchor.After); + } + + // ----------------------------------------------------------------- Stateful + + public refresh(): number { + let state = this.anchor; + state = updateId(state, this.id); + return state; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = '', lite?: boolean): string { + const name = lite ? '' : this.constructor.name + ' '; + const pos = this.pos(); + const id = toDisplayString(this.id); + const anchor = this.anchor === Anchor.Before ? '.▢' : '▢.'; + return `${name}{ ${pos}, ${id}, ${anchor} }`; + } +} diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts new file mode 100644 index 0000000000..b6a3854ee0 --- /dev/null +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -0,0 +1,167 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Anchor} from '../../constants'; +import {tick} from '../../../../json-crdt-patch/clock'; + +const setup = () => { + const model = Model.withLogicalClock(); + model.api.root({ + text: 'abc', + slices: [], + }); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + return {model, peritext}; +}; + +describe('.set()', () => { + test('can overwrite a point with the same identity as another point', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id = chunk.id; + const p1 = peritext.point(id, Anchor.Before); + const p2 = peritext.point(id, Anchor.After); + expect(p1.refresh()).not.toBe(p2.refresh()); + p1.set(p2); + expect(p1.refresh()).toBe(p2.refresh()); + expect(p1.compare(p2)).toBe(0); + expect(p1.compareSpatial(p2)).toBe(0); + expect(p1.id.sid).toBe(p2.id.sid); + expect(p1.id.time).toBe(p2.id.time); + expect(p1.anchor).toBe(p2.anchor); + }); +}); + +describe('.clone()', () => { + test('can create a new point with the same identity as another point', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id = chunk.id; + const p1 = peritext.point(id, Anchor.Before); + const p2 = p1.clone(); + expect(p1.refresh()).toBe(p2.refresh()); + expect(p1.compare(p2)).toBe(0); + expect(p1.compareSpatial(p2)).toBe(0); + expect(p1.id.sid).toBe(p2.id.sid); + expect(p1.id.time).toBe(p2.id.time); + expect(p1.anchor).toBe(p2.anchor); + }); +}); + +describe('.compare()', () => { + test('returns 0 for equal points', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id = chunk.id; + const p1 = peritext.point(id, Anchor.Before); + const p2 = peritext.point(id, Anchor.Before); + expect(p1.compare(p2)).toBe(0); + }); + + test('compares by ID first, then by anchor', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id1 = chunk.id; + const id2 = tick(id1, 1); + const id3 = tick(id1, 2); + const p1 = peritext.point(id1, Anchor.Before); + const p2 = peritext.point(id1, Anchor.After); + const p3 = peritext.point(id2, Anchor.Before); + const p4 = peritext.point(id2, Anchor.After); + const p5 = peritext.point(id3, Anchor.Before); + const p6 = peritext.point(id3, Anchor.After); + const points = [p1, p2, p3, p4, p5, p6]; + for (let i = 0; i < points.length; i++) { + for (let j = 0; j < points.length; j++) { + const p1 = points[i]; + const p2 = points[j]; + if (i === j) { + expect(p1.compare(p2)).toBe(0); + } else if (i < j) { + expect(p1.compare(p2)).toBeLessThan(0); + } else { + expect(p1.compare(p2)).toBeGreaterThan(0); + } + } + } + }); +}); + +describe('.compareSpatial()', () => { + test('higher spacial points return positive value', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const id1 = chunk1.id; + const id2 = tick(id1, 1); + const p1 = peritext.point(id1, Anchor.Before); + const p2 = peritext.point(id1, Anchor.After); + const p3 = peritext.point(id2, Anchor.Before); + const p4 = peritext.point(id2, Anchor.After); + expect(p1.compareSpatial(p1)).toBe(0); + expect(p4.compareSpatial(p4)).toBe(0); + expect(p4.compareSpatial(p4)).toBe(0); + expect(p4.compareSpatial(p4)).toBe(0); + expect(p2.compareSpatial(p1) > 0).toBe(true); + expect(p3.compareSpatial(p1) > 0).toBe(true); + expect(p4.compareSpatial(p1) > 0).toBe(true); + expect(p3.compareSpatial(p2) > 0).toBe(true); + expect(p4.compareSpatial(p2) > 0).toBe(true); + expect(p4.compareSpatial(p3) > 0).toBe(true); + expect(p1.compareSpatial(p2) < 0).toBe(true); + expect(p1.compareSpatial(p3) < 0).toBe(true); + expect(p1.compareSpatial(p4) < 0).toBe(true); + expect(p2.compareSpatial(p3) < 0).toBe(true); + expect(p2.compareSpatial(p4) < 0).toBe(true); + expect(p3.compareSpatial(p4) < 0).toBe(true); + }); + + test('correctly orders points when tombstones are present', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '3', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const chunk3 = str.root!; + model.api.str(['text']).ins(1, '4'); + const chunk4 = str.last()!; + model.api.str(['text']).ins(0, '2'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(3, '5'); + const chunk5 = str.last()!; + model.api.str(['text']).ins(0, '1'); + const chunk1 = str.first()!; + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 2); + const p1 = peritext.point(chunk1.id, Anchor.Before); + const p2 = peritext.point(chunk2.id, Anchor.Before); + const p3 = peritext.point(chunk3.id, Anchor.Before); + const p4 = peritext.point(chunk4.id, Anchor.Before); + const p5 = peritext.point(chunk5.id, Anchor.Before); + const pp1 = peritext.point(chunk1.id, Anchor.After); + const pp2 = peritext.point(chunk2.id, Anchor.After); + const pp3 = peritext.point(chunk3.id, Anchor.After); + const pp4 = peritext.point(chunk4.id, Anchor.After); + const pp5 = peritext.point(chunk5.id, Anchor.After); + const points = [p1, pp1, p2, pp2, p3, pp3, p4, pp4, p5, pp5]; + for (let i = 0; i < points.length; i++) { + for (let j = 0; j < points.length; j++) { + const p1 = points[i]; + const p2 = points[j]; + try { + if (i === j) { + expect(p1.compareSpatial(p2)).toBe(0); + } else if (i < j) { + expect(p1.compareSpatial(p2)).toBeLessThan(0); + } else { + expect(p1.compareSpatial(p2)).toBeGreaterThan(0); + } + } catch (error) { + // tslint:disable-next-line:no-console + console.log('i: ', i, 'j: ', j, 'p1: ', p1 + '', 'p2: ', p2 + '', p1.compareSpatial(p2)); + throw error; + } + } + } + }); +}); From c9f0bed8e1031e849e343b3c79f20cae74af7341 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 17:59:38 +0200 Subject: [PATCH 11/22] =?UTF-8?q?fix(json-crdt-extensions):=20=F0=9F=90=9B?= =?UTF-8?q?=20correct=20bugs=20in=20.nextId()=20and=20.prevId()=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 18 +- .../peritext/point/__tests__/Point.spec.ts | 332 ++++++++++++++++++ 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 17100e5736..a03d7b091b 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -65,6 +65,9 @@ export class Point implements Pick, Printable { return chunk; } + /** + * @returns Returns position of the character referenced by the point. + */ public pos(): number { const chunk = this.chunk(); if (!chunk) return -1; @@ -77,9 +80,14 @@ export class Point implements Pick, Printable { /** @todo Is this needed? */ public posCached(): number { if (this._pos >= 0) return this._pos; - return (this._pos = this.pos()); + const pos = this._pos = this.pos(); + return pos; } + /** + * @returns Returns position of the point, as if it is a cursor in a text + * pointing between characters. + */ public viewPos(): number { const pos = this.pos(); if (pos < 0) return 0; @@ -123,9 +131,15 @@ export class Point implements Pick, Printable { remaining -= span; chunk = str.next(chunk); } + if (remaining > 0) return; return lastVisibleChunk ? tick(lastVisibleChunk.id, lastVisibleChunk.span - 1) : undefined; } + /** + * @returns ID of the character that is `move` characters before the + * character referenced by the point, or `undefined` if there is no + * such character. + */ public prevId(move: number = 1): ITimestampStruct | undefined { let remaining: number = move; const {id, txt} = this; @@ -150,7 +164,7 @@ export class Point implements Pick, Printable { remaining -= span; chunk = str.prev(chunk); } - return str.id; + return; } public rightChar(): ChunkSlice | undefined { diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index b6a3854ee0..75e4af4698 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -165,3 +165,335 @@ describe('.compareSpatial()', () => { } }); }); + +const setupWithText = () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '3', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const chunk3 = str.root!; + model.api.str(['text']).ins(1, '4'); + const chunk4 = str.last()!; + model.api.str(['text']).ins(0, '2'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(3, '5'); + const chunk5 = str.last()!; + model.api.str(['text']).ins(4, '678'); + const chunk6 = str.last()!; + model.api.str(['text']).ins(0, '1'); + const chunk1 = str.first()!; + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 3); + model.api.str(['text']).ins(1, '456'); + model.api.str(['text']).ins(0, '012'); + return { + model, + str, + peritext, + chunk1, + chunk2, + chunk3, + chunk4, + chunk5, + chunk6, + }; +}; + +const setupWithChunkedText = () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + model.api.str(['text']).ins(0, '789'); + const chunk3 = str.first()!; + model.api.str(['text']).ins(0, 'd'); + const chunkD2 = str.first()!; + model.api.str(['text']).ins(0, '456'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(0, 'd'); + const chunkD1 = str.first()!; + model.api.str(['text']).ins(0, '123'); + const chunk1 = str.first()!; + model.api.str(['text']).del(3, 1); + model.api.str(['text']).del(6, 1); + return { + model, + str, + peritext, + chunk1, + chunk2, + chunk3, + chunkD1, + chunkD2, + }; +}; + +describe('.pos()', () => { + test('returns character position, regardless of anchor point, for visible and deleted chars', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + expect(p1.pos()).toBe(i); + expect(p2.pos()).toBe(i); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.pos()).toBe(3); + expect(p1After.pos()).toBe(3); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.pos()).toBe(3); + expect(p2After.pos()).toBe(3); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.pos()).toBe(7); + expect(p4After.pos()).toBe(7); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.pos()).toBe(7); + expect(p5After.pos()).toBe(7); + }); +}); + +describe('.viewPos()', () => { + test('returns index position in view, for visible and deleted chars', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + expect(p1.viewPos()).toBe(i); + expect(p2.viewPos()).toBe(i + 1); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.viewPos()).toBe(3); + expect(p1After.viewPos()).toBe(4); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.viewPos()).toBe(3); + expect(p2After.viewPos()).toBe(4); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.viewPos()).toBe(7); + expect(p4After.viewPos()).toBe(8); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.viewPos()).toBe(7); + expect(p5After.viewPos()).toBe(8); + }); +}); + +describe('.nextId()', () => { + test('can iterate through all IDs, starting from visible or hidden', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5, chunk6} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length - 1; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + const nextP1 = p1.nextId(); + const nextP2 = p2.nextId(); + expect(nextP1).toEqual(visibleIDs[i + 1]); + expect(nextP2).toEqual(visibleIDs[i + 1]); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.nextId()).toEqual(visibleIDs[3]); + expect(p1After.nextId()).toEqual(visibleIDs[3]); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.nextId()).toEqual(visibleIDs[3]); + expect(p2After.nextId()).toEqual(visibleIDs[3]); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.nextId()).toEqual(tick(chunk6.id, 2)); + expect(p4After.nextId()).toEqual(tick(chunk6.id, 2)); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.nextId()).toEqual(tick(chunk6.id, 2)); + expect(p5After.nextId()).toEqual(tick(chunk6.id, 2)); + }); + + test('can iterate through multi-char chunk', () => { + const {peritext, chunk1, chunk2, chunk3, chunkD1, chunkD2} = setupWithChunkedText(); + const visibleIDs = [ + tick(chunk1.id, 0), + tick(chunk1.id, 1), + tick(chunk1.id, 2), + tick(chunk2.id, 0), + tick(chunk2.id, 1), + tick(chunk2.id, 2), + tick(chunk3.id, 0), + tick(chunk3.id, 1), + tick(chunk3.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length - 1; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + for (let j = i; j < visibleIDs.length - 1; j++) { + expect(p1.nextId(1 + j - i)).toEqual(visibleIDs[j + 1]); + expect(p2.nextId(1 + j - i)).toEqual(visibleIDs[j + 1]); + } + expect(p1.nextId(visibleIDs.length)).toEqual(undefined); + expect(p2.nextId(visibleIDs.length)).toEqual(undefined); + } + // Deleted characters + const p1Before = peritext.point(chunkD1.id, Anchor.Before); + const p1After = peritext.point(chunkD1.id, Anchor.After); + for (let i = 0; i < 6; i++) { + expect(p1Before.nextId(i + 1)).toEqual(visibleIDs[3 + i]); + expect(p1After.nextId(i + 1)).toEqual(visibleIDs[3 + i]); + } + expect(p1Before.nextId(visibleIDs.length)).toEqual(undefined); + expect(p1After.nextId(visibleIDs.length)).toEqual(undefined); + const p2Before = peritext.point(chunkD2.id, Anchor.Before); + const p2After = peritext.point(chunkD2.id, Anchor.After); + for (let i = 0; i < 3; i++) { + expect(p2Before.nextId(i + 1)).toEqual(visibleIDs[6 + i]); + expect(p2After.nextId(i + 1)).toEqual(visibleIDs[6 + i]); + } + expect(p2Before.nextId(visibleIDs.length)).toEqual(undefined); + expect(p2After.nextId(visibleIDs.length)).toEqual(undefined); + }); +}); + +describe('.prevId()', () => { + test('can iterate through all IDs, starting from visible or hidden', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 1; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + const nextP1 = p1.prevId(); + const nextP2 = p2.prevId(); + expect(nextP1).toEqual(visibleIDs[i - 1]); + expect(nextP2).toEqual(visibleIDs[i - 1]); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.prevId()).toEqual(visibleIDs[2]); + expect(p1After.prevId()).toEqual(visibleIDs[2]); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.prevId()).toEqual(visibleIDs[2]); + expect(p2After.prevId()).toEqual(visibleIDs[2]); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.prevId()).toEqual(visibleIDs[6]); + expect(p4After.prevId()).toEqual(visibleIDs[6]); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.prevId()).toEqual(visibleIDs[6]); + expect(p5After.prevId()).toEqual(visibleIDs[6]); + }); + + test('can iterate through multi-char chunk', () => { + const {peritext, chunk1, chunk2, chunk3, chunkD1, chunkD2} = setupWithChunkedText(); + const visibleIDs = [ + tick(chunk1.id, 0), + tick(chunk1.id, 1), + tick(chunk1.id, 2), + tick(chunk2.id, 0), + tick(chunk2.id, 1), + tick(chunk2.id, 2), + tick(chunk3.id, 0), + tick(chunk3.id, 1), + tick(chunk3.id, 2), + ]; + // Visible characters + for (let i = 1; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + for (let j = 1; j < i; j++) { + expect(p1.prevId(j)).toEqual(visibleIDs[i - j]); + expect(p2.prevId(j)).toEqual(visibleIDs[i - j]); + } + expect(p1.prevId(i + 1)).toEqual(undefined); + expect(p2.prevId(i + 1)).toEqual(undefined); + } + // Deleted characters + const p1Before = peritext.point(chunkD1.id, Anchor.Before); + const p1After = peritext.point(chunkD1.id, Anchor.After); + for (let i = 0; i < 3; i++) { + expect(p1Before.prevId(i + 1)).toEqual(visibleIDs[2 - i]); + expect(p1After.prevId(i + 1)).toEqual(visibleIDs[2 - i]); + } + expect(p1Before.prevId(4)).toEqual(undefined); + expect(p1After.prevId(4)).toEqual(undefined); + expect(p1Before.prevId(5)).toEqual(undefined); + expect(p1After.prevId(5)).toEqual(undefined); + const p2Before = peritext.point(chunkD2.id, Anchor.Before); + const p2After = peritext.point(chunkD2.id, Anchor.After); + for (let i = 0; i < 6; i++) { + expect(p2Before.prevId(i + 1)).toEqual(visibleIDs[5 - i]); + expect(p2After.prevId(i + 1)).toEqual(visibleIDs[5 - i]); + } + expect(p2Before.prevId(7)).toEqual(undefined); + expect(p2After.prevId(7)).toEqual(undefined); + expect(p2Before.prevId(8)).toEqual(undefined); + expect(p2After.prevId(8)).toEqual(undefined); + }); +}); From e67b9993215a5df9020ec8b96b21ba30d0ec36d6 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 18:01:20 +0200 Subject: [PATCH 12/22] =?UTF-8?q?style:=20=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/peritext/Peritext.ts | 13 ++++++------- src/json-crdt-extensions/peritext/point/Point.ts | 8 ++++++-- src/json-crdt-extensions/peritext/types.ts | 4 ++-- .../peritext/util/__tests__/ChunkSlice.spec.ts | 1 - src/json-crdt-extensions/peritext/util/types.ts | 2 +- src/json-crdt/nodes/rga/AbstractRga.ts | 6 ++++++ 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 35e5f4de1e..56d185112d 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -7,7 +7,11 @@ import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; export class Peritext implements Printable { - constructor(public readonly model: Model, public readonly str: StrNode, slices: ArrNode) {} + constructor( + public readonly model: Model, + public readonly str: StrNode, + slices: ArrNode, + ) {} public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { return new Point(this, id, anchor); @@ -24,11 +28,6 @@ export class Peritext implements Printable { public toString(tab: string = ''): string { const nl = () => ''; - return ( - this.constructor.name + - printTree(tab, [ - (tab) => this.str.toString(tab), - ]) - ); + return this.constructor.name + printTree(tab, [(tab) => this.str.toString(tab)]); } } diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index a03d7b091b..0ee2439263 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -15,7 +15,11 @@ import type {StringChunk} from '../util/types'; * just after the character. */ export class Point implements Pick, Printable { - constructor(protected readonly txt: Peritext, public id: ITimestampStruct, public anchor: Anchor) {} + constructor( + protected readonly txt: Peritext, + public id: ITimestampStruct, + public anchor: Anchor, + ) {} public set(point: Point): void { this.id = point.id; @@ -80,7 +84,7 @@ export class Point implements Pick, Printable { /** @todo Is this needed? */ public posCached(): number { if (this._pos >= 0) return this._pos; - const pos = this._pos = this.pos(); + const pos = (this._pos = this.pos()); return pos; } diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts index 138a047e71..5557617292 100644 --- a/src/json-crdt-extensions/peritext/types.ts +++ b/src/json-crdt-extensions/peritext/types.ts @@ -1,5 +1,5 @@ -import type {ITimestampStruct} from "../../json-crdt-patch"; -import type {Path, PathStep} from "../../json-pointer"; +import type {ITimestampStruct} from '../../json-crdt-patch'; +import type {Path, PathStep} from '../../json-pointer'; /** * Represents an object which state can change over time. diff --git a/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts index 26a10631ae..365ed1b867 100644 --- a/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts +++ b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts @@ -81,4 +81,3 @@ describe('.refresh()', () => { expect(hash1).not.toBe(hash2); }); }); - diff --git a/src/json-crdt-extensions/peritext/util/types.ts b/src/json-crdt-extensions/peritext/util/types.ts index b09b79c346..23de16ef60 100644 --- a/src/json-crdt-extensions/peritext/util/types.ts +++ b/src/json-crdt-extensions/peritext/util/types.ts @@ -1,4 +1,4 @@ -import type {Chunk} from "../../../json-crdt/nodes/rga"; +import type {Chunk} from '../../../json-crdt/nodes/rga'; export type StringChunk = Chunk; diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index 3df62cd776..909b15a45a 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -476,6 +476,12 @@ export abstract class AbstractRga { return next(curr); } + /** @todo Maybe use implementation from tree utils, if does not impact performance. */ + /** @todo Or better remove this method completely, as it does not require "this". */ + public prev(curr: Chunk): Chunk | undefined { + return prev(curr); + } + /** Content length. */ public length(): number { const root = this.root; From f794696fa538def1e4275f5b91170c5d44abddab Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 18:43:29 +0200 Subject: [PATCH 13/22] =?UTF-8?q?fix(json-crdt-extensions):=20=F0=9F=90=9B?= =?UTF-8?q?=20correct=20left=20character=20retrieval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 2 +- .../peritext/point/__tests__/Point.spec.ts | 126 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 0ee2439263..0c2d16e475 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -201,7 +201,7 @@ export class Point implements Pick, Printable { return new ChunkSlice(chunk, off, 1); } const off = this.id.time - chunk.id.time - 1; - if (off > 0) return new ChunkSlice(chunk, off, 1); + if (off >= 0) return new ChunkSlice(chunk, off, 1); const str = this.txt.str; chunk = str.prev(chunk); while (chunk && chunk.del) chunk = str.prev(chunk); diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 75e4af4698..453b2cce6d 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -497,3 +497,129 @@ describe('.prevId()', () => { expect(p2After.prevId(8)).toEqual(undefined); }); }); + +describe('.rightChar()', () => { + test('returns the right character', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: 'abc', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const point0 = peritext.pointAt(0); + const char0 = point0.rightChar()!; + expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('a'); + const point1 = peritext.pointAt(1); + const char1 = point1.rightChar()!; + expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); + const point2 = peritext.pointAt(2); + const char2 = point2.rightChar()!; + expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('c'); + }); + + test('multi-char chunks with deletes', () => { + const {peritext} = setupWithText(); + const res = '012345678'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); + + test('multi-char chunks with deletes (2)', () => { + const {peritext} = setupWithChunkedText(); + const res = '123456789'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); +}); + +describe('.leftChar()', () => { + test('returns the left character', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: 'abc', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + model.api.str(['text']).del(0, 3); + model.api.str(['text']).ins(0, '00a1b2c3'); + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 1); + model.api.str(['text']).del(2, 1); + model.api.str(['text']).del(3, 1); + const point0 = peritext.pointAt(2, Anchor.After); + const char0 = point0.leftChar()!; + expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('c'); + const point1 = peritext.pointAt(1, Anchor.After); + const char1 = point1.leftChar()!; + expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); + const point2 = peritext.pointAt(0, Anchor.After); + const char2 = point2.leftChar()!; + expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('a'); + }); + + test('multi-char chunks with deletes', () => { + const {peritext} = setupWithText(); + const res = '012345678'; + const start = peritext.pointAt(0, Anchor.Before); + expect(start.leftChar()).toBe(undefined); + const start2 = peritext.pointAt(0, Anchor.After); + expect(start2.leftChar()!.view()).toBe(res[0]); + const start3 = peritext.pointAt(1, Anchor.Before); + const slice = start3.leftChar(); + expect(slice!.view()).toBe(res[0]); + for (let i = 1; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i - 1]); + } + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i]); + } + }); + + test('multi-char chunks with deletes (2)', () => { + const {peritext} = setupWithChunkedText(); + const res = '123456789'; + const start = peritext.pointAt(0, Anchor.Before); + expect(start.leftChar()).toBe(undefined); + const start2 = peritext.pointAt(0, Anchor.After); + expect(start2.leftChar()!.view()).toBe(res[0]); + const start3 = peritext.pointAt(1, Anchor.Before); + const slice = start3.leftChar(); + expect(slice!.view()).toBe(res[0]); + for (let i = 1; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i - 1]); + } + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i]); + } + }); +}); From dc982cd2f69769bf63352735bd6b6cac148946f5 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 18:49:08 +0200 Subject: [PATCH 14/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20support=20left=20char=20retrieval=20for=20delete?= =?UTF-8?q?d=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/point/Point.ts | 8 +++++++- .../peritext/point/__tests__/Point.spec.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 0c2d16e475..989975027c 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -195,7 +195,13 @@ export class Point implements Pick, Printable { public leftChar(): ChunkSlice | undefined { let chunk = this.chunk(); - if (!chunk || chunk.del) return; + if (!chunk) return; + if (chunk.del) { + const prevId = this.prevId(); + if (!prevId) return; + const tmp = new Point(this.txt, prevId, Anchor.After); + return tmp.leftChar(); + } if (this.anchor === Anchor.After) { const off = this.id.time - chunk.id.time; return new ChunkSlice(chunk, off, 1); diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 453b2cce6d..da02c4fba6 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -551,6 +551,14 @@ describe('.rightChar()', () => { const end = peritext.pointAt(res.length - 1, Anchor.After); expect(end.rightChar()).toBe(undefined); }); + + test('retrieves left char of a deleted point', () => { + const {peritext, chunkD1} = setupWithChunkedText(); + const p1 = peritext.point(chunkD1.id, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('3'); + const p2 = peritext.point(chunkD1.id, Anchor.After); + expect(p2.leftChar()!.view()).toBe('3'); + }); }); describe('.leftChar()', () => { From fa7923b5ce1b51a9753d9b67aef1b822bf53220d Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 18:52:50 +0200 Subject: [PATCH 15/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20support=20right=20character=20retrieval=20for=20?= =?UTF-8?q?deleted=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 8 ++++++- .../peritext/point/__tests__/Point.spec.ts | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 989975027c..656f61211b 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -180,7 +180,13 @@ export class Point implements Pick, Printable { return chunk ? new ChunkSlice(chunk, 0, 1) : undefined; } let chunk = this.chunk(); - if (!chunk || chunk.del) return; + if (!chunk) return; + if (chunk.del) { + const nextId = this.nextId(); + if (!nextId) return; + const tmp = new Point(this.txt, nextId, Anchor.Before); + return tmp.rightChar(); + } if (this.anchor === Anchor.Before) { const off = this.id.time - chunk.id.time; return new ChunkSlice(chunk, off, 1); diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index da02c4fba6..55002fd87a 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -552,12 +552,16 @@ describe('.rightChar()', () => { expect(end.rightChar()).toBe(undefined); }); - test('retrieves left char of a deleted point', () => { - const {peritext, chunkD1} = setupWithChunkedText(); + test('retrieves right char of a deleted point', () => { + const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); const p1 = peritext.point(chunkD1.id, Anchor.Before); - expect(p1.leftChar()!.view()).toBe('3'); + expect(p1.rightChar()!.view()).toBe('4'); const p2 = peritext.point(chunkD1.id, Anchor.After); - expect(p2.leftChar()!.view()).toBe('3'); + expect(p2.rightChar()!.view()).toBe('4'); + const p3 = peritext.point(chunkD2.id, Anchor.Before); + expect(p3.rightChar()!.view()).toBe('7'); + const p4 = peritext.point(chunkD2.id, Anchor.After); + expect(p4.rightChar()!.view()).toBe('7'); }); }); @@ -630,4 +634,16 @@ describe('.leftChar()', () => { expect(char.view()).toBe(res[i]); } }); + + test('retrieves left char of a deleted point', () => { + const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); + const p1 = peritext.point(chunkD1.id, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('3'); + const p2 = peritext.point(chunkD1.id, Anchor.After); + expect(p2.leftChar()!.view()).toBe('3'); + const p3 = peritext.point(chunkD2.id, Anchor.Before); + expect(p3.leftChar()!.view()).toBe('6'); + const p4 = peritext.point(chunkD2.id, Anchor.After); + expect(p4.leftChar()!.view()).toBe('6'); + }); }); From 3c6831f1e2b3bba3f14ba73bb33bdfe805930d0d Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 22:26:14 +0200 Subject: [PATCH 16/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20Point=20movement=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 121 +++++++++++++----- .../peritext/point/__tests__/Point.spec.ts | 40 ++++++ 2 files changed, 130 insertions(+), 31 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 656f61211b..22ded3af6c 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -10,9 +10,9 @@ import type {StringChunk} from '../util/types'; /** * A "point" in a rich-text Peritext document. It is a combination of a * character ID and an anchor. Anchor specifies the side of the character to - * which the point is attached. For example, a point with an anchor "before" - * points just before the character, while a point with an anchor "after" points - * just after the character. + * which the point is attached. For example, a point with an anchor "before" .▢ + * points just before the character, while a point with an anchor "after" ▢. + * points just after the character. */ export class Point implements Pick, Printable { constructor( @@ -21,15 +21,33 @@ export class Point implements Pick, Printable { public anchor: Anchor, ) {} + /** + * Overwrites the internal state of this point with the state of the given + * point. + * + * @param point Point to copy. + */ public set(point: Point): void { this.id = point.id; this.anchor = point.anchor; } + /** + * Creates a copy of this point. + * + * @returns Returns a new point with the same ID and anchor as this point. + */ public clone(): Point { return new Point(this.txt, this.id, this.anchor); } + /** + * + * @param other The other point to compare to. + * @returns Returns 0 if the two points are equal, -1 if this point is less + * than the other point, and 1 if this point is greater than the other + * point. + */ public compare(other: Point): -1 | 0 | 1 { const cmp = compare(this.id, other.id); if (cmp !== 0) return cmp; @@ -98,13 +116,23 @@ export class Point implements Pick, Printable { return this.anchor === Anchor.Before ? pos : pos + 1; } + /** + * Goes to the next visible character in the string. The `move` parameter + * specifies how many characters to move the cursor by. If the cursor reaches + * the end of the string, it will return `undefined`. + * + * @param move How many characters to move the cursor by. + * @returns Next visible ID in string. + */ public nextId(move: number = 1): ITimestampStruct | undefined { + // TODO: add tests for when cursor is at the end. + if (this.isEndOfStr()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; - const startFromStrRoot = equal(id, str.id); let chunk: StringChunk | undefined; - if (startFromStrRoot) { + // TODO: add tests for when cursor starts from start of string. + if (this.isStartOfStr()) { chunk = str.first(); while (chunk && chunk.del) chunk = str.next(chunk); if (!chunk) return; @@ -145,10 +173,13 @@ export class Point implements Pick, Printable { * such character. */ public prevId(move: number = 1): ITimestampStruct | undefined { + // TODO: add tests for when cursor is at the start. + if (this.isStartOfStr()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; let chunk = this.chunk(); + // TODO: handle case when cursor starts from end of string. if (!chunk) return str.id; if (!chunk.del) { const offset = id.time - chunk.id.time; @@ -173,8 +204,7 @@ export class Point implements Pick, Printable { public rightChar(): ChunkSlice | undefined { const str = this.txt.str; - const isBeginningOfDoc = equal(this.id, str.id) && this.anchor === Anchor.After; - if (isBeginningOfDoc) { + if (this.isStartOfStr()) { let chunk = str.first(); while (chunk && chunk.del) chunk = str.next(chunk); return chunk ? new ChunkSlice(chunk, 0, 1) : undefined; @@ -201,6 +231,7 @@ export class Point implements Pick, Printable { public leftChar(): ChunkSlice | undefined { let chunk = this.chunk(); + // TODO: Handle case when point references end of str. if (!chunk) return; if (chunk.del) { const prevId = this.prevId(); @@ -221,6 +252,58 @@ export class Point implements Pick, Printable { return new ChunkSlice(chunk, chunk.span - 1, 1); } + public isStartOfStr(): boolean { + return equal(this.id, this.txt.str.id) && this.anchor === Anchor.After; + } + + public isEndOfStr(): boolean { + return equal(this.id, this.txt.str.id) && this.anchor === Anchor.Before; + } + + /** + * Modifies the location of the point, such that the spatial location remains + * and anchor remains the same, but ensures that the point references a + * visible (non-deleted) character. + */ + public refVisible(): void { + if (this.anchor === Anchor.Before) this.refBefore(); + else this.refAfter(); + } + + public refStart(): void { + this.id = this.txt.str.id; + this.anchor = Anchor.After; + } + + public refEnd(): void { + this.id = this.txt.str.id; + this.anchor = Anchor.Before; + } + + /** + * Modifies the location of the point, such that the spatial location remains + * the same, but ensures that it is anchored before a character. + */ + public refBefore(): void { + const chunk = this.chunk(); + if (!chunk) return this.refEnd(); + if (!chunk.del || this.anchor === Anchor.Before) return; + this.anchor = Anchor.Before; + this.id = this.nextId() || this.txt.str.id; + } + + /** + * Modifies the location of the point, such that the spatial location remains + * the same, but ensures that it is anchored after a character. + */ + public refAfter(): void { + const chunk = this.chunk(); + if (!chunk) return this.refStart(); + if (!chunk.del || this.anchor === Anchor.After) return; + this.anchor = Anchor.After; + this.id = this.prevId() || this.txt.str.id; + } + /** * Moves point past given number of visible characters. Accepts positive * and negative distances. @@ -237,30 +320,6 @@ export class Point implements Pick, Printable { } } - /** - * Returns a point, which points at the same spatial location, but ensures - * that it is anchored after a character. - */ - public anchorBefore(): Point { - if (this.anchor === Anchor.Before) return this; - const next = this.nextId(); - const txt = this.txt; - if (!next) return new Point(txt, txt.str.id, Anchor.Before); - return new Point(txt, next, Anchor.Before); - } - - /** - * Returns a point, which points at the same spatial location, but ensures - * that it is anchored after a character. - */ - public anchorAfter(): Point { - if (this.anchor === Anchor.After) return this; - const prev = this.prevId(); - const txt = this.txt; - if (!prev) return new Point(txt, txt.str.id, Anchor.After); - return new Point(txt, prev, Anchor.After); - } - // ----------------------------------------------------------------- Stateful public refresh(): number { diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 55002fd87a..0048414b04 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -404,6 +404,18 @@ describe('.nextId()', () => { expect(p2Before.nextId(visibleIDs.length)).toEqual(undefined); expect(p2After.nextId(visibleIDs.length)).toEqual(undefined); }); + + test('can move zero characters', () => { + const {peritext, chunk2, chunkD1} = setupWithChunkedText(); + const p1 = peritext.point(chunk2.id, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('3'); + p1.prevId(0); + expect(p1.leftChar()!.view()).toBe('3'); + const p2 = peritext.point(chunkD1.id, Anchor.Before); + expect(p2.leftChar()!.view()).toBe('3'); + p2.prevId(0); + expect(p2.leftChar()!.view()).toBe('3'); + }); }); describe('.prevId()', () => { @@ -496,6 +508,18 @@ describe('.prevId()', () => { expect(p2Before.prevId(8)).toEqual(undefined); expect(p2After.prevId(8)).toEqual(undefined); }); + + test('can move zero characters', () => { + const {peritext, chunk2, chunkD1} = setupWithChunkedText(); + const p1 = peritext.point(chunk2.id, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('4'); + p1.nextId(0); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = peritext.point(chunkD1.id, Anchor.Before); + expect(p2.rightChar()!.view()).toBe('4'); + p2.nextId(0); + expect(p2.rightChar()!.view()).toBe('4'); + }); }); describe('.rightChar()', () => { @@ -647,3 +671,19 @@ describe('.leftChar()', () => { expect(p4.leftChar()!.view()).toBe('6'); }); }); + +describe('.move()', () => { + test('can move forward', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = 0; i < txt.length - 1; i++) { + const p = peritext.pointAt(i, Anchor.Before); + for (let j = i + 1; j < txt.length - 1; j++) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.rightChar()!.view()).toBe(txt[j]); + } + } + }); +}); From 5472802356fd7d3c6f96eed1e262f16852b564f5 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 23:38:16 +0200 Subject: [PATCH 17/22] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20.nextId()=20edge=20case=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 8 ++++++++ .../peritext/point/Point.ts | 2 -- .../peritext/point/__tests__/Point.spec.ts | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 56d185112d..71bf7b4215 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -24,6 +24,14 @@ export class Peritext implements Printable { return this.point(id, anchor); } + public pointAtStart(): Point { + return this.point(this.str.id, Anchor.After); + } + + public pointAtEnd(): Point { + return this.point(this.str.id, Anchor.Before); + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 22ded3af6c..5317da8f36 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -125,13 +125,11 @@ export class Point implements Pick, Printable { * @returns Next visible ID in string. */ public nextId(move: number = 1): ITimestampStruct | undefined { - // TODO: add tests for when cursor is at the end. if (this.isEndOfStr()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; let chunk: StringChunk | undefined; - // TODO: add tests for when cursor starts from start of string. if (this.isStartOfStr()) { chunk = str.first(); while (chunk && chunk.del) chunk = str.next(chunk); diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 0048414b04..18634e7627 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -416,6 +416,25 @@ describe('.nextId()', () => { p2.prevId(0); expect(p2.leftChar()!.view()).toBe('3'); }); + + test('returns undefined, when at end of str', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAtEnd(); + expect(point.nextId()).toBe(undefined); + }); + + test('returns undefined, when at last char', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAt(8, Anchor.Before); + expect(point.nextId()).toBe(undefined); + }); + + test('returns first char, when at start of str', () => { + const {peritext, chunk1} = setupWithChunkedText(); + const point = peritext.pointAtStart(); + const id = point.nextId(); + expect(id).toEqual(chunk1.id); + }); }); describe('.prevId()', () => { From 2cf31d5b681738c094a4a2fdc380cfbe10cd7a0c Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 23:38:44 +0200 Subject: [PATCH 18/22] =?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/peritext/point/Point.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 5317da8f36..9bd7c86fca 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -42,7 +42,7 @@ export class Point implements Pick, Printable { } /** - * + * * @param other The other point to compare to. * @returns Returns 0 if the two points are equal, -1 if this point is less * than the other point, and 1 if this point is greater than the other @@ -120,7 +120,7 @@ export class Point implements Pick, Printable { * Goes to the next visible character in the string. The `move` parameter * specifies how many characters to move the cursor by. If the cursor reaches * the end of the string, it will return `undefined`. - * + * * @param move How many characters to move the cursor by. * @returns Next visible ID in string. */ From ab88faead9924f4ff5d4a51c4d2d3dca740f8342 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 23:43:35 +0200 Subject: [PATCH 19/22] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20.prevId()=20edge=20case=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 2 -- .../peritext/point/__tests__/Point.spec.ts | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 9bd7c86fca..232b421aeb 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -171,13 +171,11 @@ export class Point implements Pick, Printable { * such character. */ public prevId(move: number = 1): ITimestampStruct | undefined { - // TODO: add tests for when cursor is at the start. if (this.isStartOfStr()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; let chunk = this.chunk(); - // TODO: handle case when cursor starts from end of string. if (!chunk) return str.id; if (!chunk.del) { const offset = id.time - chunk.id.time; diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 18634e7627..f726fa57bd 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -539,6 +539,28 @@ describe('.prevId()', () => { p2.nextId(0); expect(p2.rightChar()!.view()).toBe('4'); }); + + test('returns undefined, when at start of str', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAtStart(); + expect(point.prevId()).toBe(undefined); + }); + + test('returns undefined, when at first char', () => { + const {peritext} = setupWithChunkedText(); + const point1 = peritext.pointAt(0, Anchor.Before); + const point2 = peritext.pointAt(0, Anchor.After); + expect(point1.prevId()).toBe(undefined); + expect(point2.prevId()).toBe(undefined); + }); + + test('returns last char, when at end of str', () => { + const {peritext} = setupWithChunkedText(); + const point1 = peritext.pointAtEnd(); + const point2 = peritext.pointAt(9, Anchor.Before); + const id = point1.prevId(); + expect(id).toEqual(point2.id); + }); }); describe('.rightChar()', () => { From b1be9846c36aef0757a9c31c5dfa06bb7f792b3e Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 6 Apr 2024 23:49:48 +0200 Subject: [PATCH 20/22] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20hand=20edge=20cases=20in=20.leftChar()=20and=20.?= =?UTF-8?q?rightChar()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 50 +++--- .../peritext/point/__tests__/Point.spec.ts | 150 ++++++++++-------- 2 files changed, 110 insertions(+), 90 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 232b421aeb..400a490cbd 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -198,6 +198,33 @@ export class Point implements Pick, Printable { return; } + public leftChar(): ChunkSlice | undefined { + const str = this.txt.str; + if (this.isEndOfStr()) { + let chunk = str.last(); + while (chunk && chunk.del) chunk = str.prev(chunk); + return chunk ? new ChunkSlice(chunk, chunk.span - 1, 1) : undefined; + } + let chunk = this.chunk(); + if (!chunk) return; + if (chunk.del) { + const prevId = this.prevId(); + if (!prevId) return; + const tmp = new Point(this.txt, prevId, Anchor.After); + return tmp.leftChar(); + } + if (this.anchor === Anchor.After) { + const off = this.id.time - chunk.id.time; + return new ChunkSlice(chunk, off, 1); + } + const off = this.id.time - chunk.id.time - 1; + if (off >= 0) return new ChunkSlice(chunk, off, 1); + chunk = str.prev(chunk); + while (chunk && chunk.del) chunk = str.prev(chunk); + if (!chunk) return; + return new ChunkSlice(chunk, chunk.span - 1, 1); + } + public rightChar(): ChunkSlice | undefined { const str = this.txt.str; if (this.isStartOfStr()) { @@ -225,29 +252,6 @@ export class Point implements Pick, Printable { return new ChunkSlice(chunk, 0, 1); } - public leftChar(): ChunkSlice | undefined { - let chunk = this.chunk(); - // TODO: Handle case when point references end of str. - if (!chunk) return; - if (chunk.del) { - const prevId = this.prevId(); - if (!prevId) return; - const tmp = new Point(this.txt, prevId, Anchor.After); - return tmp.leftChar(); - } - if (this.anchor === Anchor.After) { - const off = this.id.time - chunk.id.time; - return new ChunkSlice(chunk, off, 1); - } - const off = this.id.time - chunk.id.time - 1; - if (off >= 0) return new ChunkSlice(chunk, off, 1); - const str = this.txt.str; - chunk = str.prev(chunk); - while (chunk && chunk.del) chunk = str.prev(chunk); - if (!chunk) return; - return new ChunkSlice(chunk, chunk.span - 1, 1); - } - public isStartOfStr(): boolean { return equal(this.id, this.txt.str.id) && this.anchor === Anchor.After; } diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index f726fa57bd..3b032e59c9 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -563,73 +563,6 @@ describe('.prevId()', () => { }); }); -describe('.rightChar()', () => { - test('returns the right character', () => { - const model = Model.withLogicalClock(123456); - model.api.root({ - text: 'abc', - slices: [], - }); - const str = model.api.str(['text']).node; - const peritext = new Peritext(model, str, model.api.arr(['slices']).node); - const point0 = peritext.pointAt(0); - const char0 = point0.rightChar()!; - expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('a'); - const point1 = peritext.pointAt(1); - const char1 = point1.rightChar()!; - expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); - const point2 = peritext.pointAt(2); - const char2 = point2.rightChar()!; - expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('c'); - }); - - test('multi-char chunks with deletes', () => { - const {peritext} = setupWithText(); - const res = '012345678'; - for (let i = 0; i < res.length; i++) { - const point = peritext.pointAt(i, Anchor.Before); - const char = point.rightChar()!; - expect(char.view()).toBe(res[i]); - } - for (let i = 0; i < res.length - 1; i++) { - const point = peritext.pointAt(i, Anchor.After); - const char = point.rightChar()!; - expect(char.view()).toBe(res[i + 1]); - } - const end = peritext.pointAt(res.length - 1, Anchor.After); - expect(end.rightChar()).toBe(undefined); - }); - - test('multi-char chunks with deletes (2)', () => { - const {peritext} = setupWithChunkedText(); - const res = '123456789'; - for (let i = 0; i < res.length; i++) { - const point = peritext.pointAt(i, Anchor.Before); - const char = point.rightChar()!; - expect(char.view()).toBe(res[i]); - } - for (let i = 0; i < res.length - 1; i++) { - const point = peritext.pointAt(i, Anchor.After); - const char = point.rightChar()!; - expect(char.view()).toBe(res[i + 1]); - } - const end = peritext.pointAt(res.length - 1, Anchor.After); - expect(end.rightChar()).toBe(undefined); - }); - - test('retrieves right char of a deleted point', () => { - const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); - const p1 = peritext.point(chunkD1.id, Anchor.Before); - expect(p1.rightChar()!.view()).toBe('4'); - const p2 = peritext.point(chunkD1.id, Anchor.After); - expect(p2.rightChar()!.view()).toBe('4'); - const p3 = peritext.point(chunkD2.id, Anchor.Before); - expect(p3.rightChar()!.view()).toBe('7'); - const p4 = peritext.point(chunkD2.id, Anchor.After); - expect(p4.rightChar()!.view()).toBe('7'); - }); -}); - describe('.leftChar()', () => { test('returns the left character', () => { const model = Model.withLogicalClock(123456); @@ -711,6 +644,89 @@ describe('.leftChar()', () => { const p4 = peritext.point(chunkD2.id, Anchor.After); expect(p4.leftChar()!.view()).toBe('6'); }); + + test('at end of text should return the last char', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(8, Anchor.After); + const p2 = peritext.pointAtEnd(); + expect(p1.leftChar()!.view()).toBe('9'); + expect(p2.leftChar()!.view()).toBe('9'); + }); +}); + +describe('.rightChar()', () => { + test('returns the right character', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: 'abc', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const point0 = peritext.pointAt(0); + const char0 = point0.rightChar()!; + expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('a'); + const point1 = peritext.pointAt(1); + const char1 = point1.rightChar()!; + expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); + const point2 = peritext.pointAt(2); + const char2 = point2.rightChar()!; + expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('c'); + }); + + test('multi-char chunks with deletes', () => { + const {peritext} = setupWithText(); + const res = '012345678'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); + + test('multi-char chunks with deletes (2)', () => { + const {peritext} = setupWithChunkedText(); + const res = '123456789'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); + + test('retrieves right char of a deleted point', () => { + const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); + const p1 = peritext.point(chunkD1.id, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = peritext.point(chunkD1.id, Anchor.After); + expect(p2.rightChar()!.view()).toBe('4'); + const p3 = peritext.point(chunkD2.id, Anchor.Before); + expect(p3.rightChar()!.view()).toBe('7'); + const p4 = peritext.point(chunkD2.id, Anchor.After); + expect(p4.rightChar()!.view()).toBe('7'); + }); + + test('at start of text should return the first char', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(0, Anchor.Before); + const p2 = peritext.pointAtStart(); + expect(p1.rightChar()!.view()).toBe('1'); + expect(p2.rightChar()!.view()).toBe('1'); + }); }); describe('.move()', () => { From 2e48abf801809c64b87d7acebef41395ef23b167 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 7 Apr 2024 00:21:58 +0200 Subject: [PATCH 21/22] =?UTF-8?q?fix(json-crdt-extensions):=20=F0=9F=90=9B?= =?UTF-8?q?=20correct=20check=20for=20.refBefore()=20and=20.refAfter()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 12 ++- .../peritext/point/__tests__/Point.spec.ts | 90 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 400a490cbd..cf92a2b93e 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -282,24 +282,28 @@ export class Point implements Pick, Printable { /** * Modifies the location of the point, such that the spatial location remains - * the same, but ensures that it is anchored before a character. + * the same, but ensures that it is anchored before a character. Skips any + * deleted characters (chunks), attaching the point to the next visible + * character. */ public refBefore(): void { const chunk = this.chunk(); if (!chunk) return this.refEnd(); - if (!chunk.del || this.anchor === Anchor.Before) return; + if (!chunk.del && this.anchor === Anchor.Before) return; this.anchor = Anchor.Before; this.id = this.nextId() || this.txt.str.id; } /** * Modifies the location of the point, such that the spatial location remains - * the same, but ensures that it is anchored after a character. + * the same, but ensures that it is anchored after a character. Skips any + * deleted characters (chunks), attaching the point to the next visible + * character. */ public refAfter(): void { const chunk = this.chunk(); if (!chunk) return this.refStart(); - if (!chunk.del || this.anchor === Anchor.After) return; + if (!chunk.del && this.anchor === Anchor.After) return; this.anchor = Anchor.After; this.id = this.prevId() || this.txt.str.id; } diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index 3b032e59c9..fdd60a1526 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -729,6 +729,96 @@ describe('.rightChar()', () => { }); }); +describe('.isStartOfStr()', () => { + test('returns true if is start of string', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAtStart(); + const p2 = peritext.pointAt(0, Anchor.Before); + expect(p1.isStartOfStr()).toBe(true); + expect(p2.isStartOfStr()).toBe(false); + }); +}); + +describe('.isEndOfStr()', () => { + test('returns true if is end of string', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAtEnd(); + const p2 = peritext.pointAt(8, Anchor.After); + expect(p1.isEndOfStr()).toBe(true); + expect(p2.isEndOfStr()).toBe(false); + }); +}); + +describe('.refBefore()', () => { + test('goes to next character, when anchor is switched', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(0, Anchor.After); + expect(p1.rightChar()!.view()).toBe('2'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.rightChar()!.view()).toBe('2'); + expect(p1.anchor).toBe(Anchor.After); + expect(p2.anchor).toBe(Anchor.Before); + expect(p1.id.time + 1).toBe(p2.id.time); + }); + + test('skips deleted chars', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.rightChar()!.view()).toBe('4'); + expect(p1.anchor).toBe(Anchor.After); + expect(p2.anchor).toBe(Anchor.Before); + expect(p1.id.time).not.toBe(p2.id.time); + }); + + test('when on last character, attaches to end of str', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(8, Anchor.After); + expect(p1.leftChar()!.view()).toBe('9'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.isEndOfStr()).toBe(true); + }); +}); + +describe('.refAfter()', () => { + test('goes to next character, when anchor is switched', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(4, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('4'); + const p2 = p1.clone(); + p2.refAfter(); + expect(p2.leftChar()!.view()).toBe('4'); + expect(p1.anchor).toBe(Anchor.Before); + expect(p2.anchor).toBe(Anchor.After); + expect(p1.id.time - 1).toBe(p2.id.time); + }); + + test('skips deleted chars', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(7, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('7'); + const p2 = p1.clone(); + p2.refAfter(); + expect(p2.leftChar()!.view()).toBe('7'); + expect(p1.anchor).toBe(Anchor.Before); + expect(p2.anchor).toBe(Anchor.After); + expect(p2.chunk()!.del).toBe(false); + }); + + test('when on first character, attaches to start of str', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(0, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('1'); + const p2 = p1.clone(); + p2.refAfter(); + expect(p2.isStartOfStr()).toBe(true); + }); +}); + describe('.move()', () => { test('can move forward', () => { const {peritext, model} = setupWithChunkedText(); From 45b12411fd19430d32e83dfee09cfa3a723db5f7 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 7 Apr 2024 00:46:39 +0200 Subject: [PATCH 22/22] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20.move()=20method=20and=20fix=20.viewPo?= =?UTF-8?q?s()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/point/Point.ts | 17 +++- .../peritext/point/__tests__/Point.spec.ts | 83 ++++++++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index cf92a2b93e..e5299cd764 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -112,7 +112,7 @@ export class Point implements Pick, Printable { */ public viewPos(): number { const pos = this.pos(); - if (pos < 0) return 0; + if (pos < 0) return this.isStartOfStr() ? 0 : this.txt.str.length(); return this.anchor === Anchor.Before ? pos : pos + 1; } @@ -313,14 +313,23 @@ export class Point implements Pick, Printable { * and negative distances. */ public move(skip: number): void { - // TODO: handle cases when cursor reaches ends of string, it should adjust anchor positions as well if (!skip) return; + const anchor = this.anchor; + if (anchor !== Anchor.After) this.refAfter(); if (skip > 0) { const nextId = this.nextId(skip); - if (nextId) this.id = nextId; + if (!nextId) this.refEnd(); + else { + this.id = nextId; + if (anchor !== Anchor.After) this.refBefore(); + } } else { const prevId = this.prevId(-skip); - if (prevId) this.id = prevId; + if (!prevId) this.refStart(); + else { + this.id = prevId; + if (anchor !== Anchor.After) this.refBefore(); + } } } diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index fdd60a1526..5e4b62c693 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -820,15 +820,96 @@ describe('.refAfter()', () => { }); describe('.move()', () => { - test('can move forward', () => { + test('smoke test', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(1, Anchor.After); + expect(p.viewPos()).toBe(2); + p.move(1); + expect(p.viewPos()).toBe(3); + p.move(2); + expect(p.viewPos()).toBe(5); + p.move(2); + expect(p.viewPos()).toBe(7); + p.move(-3); + expect(p.viewPos()).toBe(4); + p.move(-3); + expect(p.viewPos()).toBe(1); + p.move(-3); + expect(p.viewPos()).toBe(0); + }); + + test('can reach the end of str', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(0, Anchor.After); + p.move(1); + p.move(2); + p.move(3); + p.move(4); + p.move(5); + p.move(6); + expect(p.isEndOfStr()).toBe(true); + expect(p.viewPos()).toBe(9); + expect(p.leftChar()!.view()).toBe('9'); + expect(p.anchor).toBe(Anchor.Before); + }); + + test('can reach the start of str', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(8, Anchor.Before); + p.move(-22); + expect(p.isStartOfStr()).toBe(true); + expect(p.viewPos()).toBe(0); + expect(p.rightChar()!.view()).toBe('1'); + expect(p.anchor).toBe(Anchor.After); + }); + + test('can move forward, when anchor = Before', () => { const {peritext, model} = setupWithChunkedText(); model.api.str(['text']).del(4, 1); const txt = '12346789'; for (let i = 0; i < txt.length - 1; i++) { const p = peritext.pointAt(i, Anchor.Before); + expect(p.pos()).toBe(i); for (let j = i + 1; j < txt.length - 1; j++) { const p2 = p.clone(); p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.Before); + expect(p2.rightChar()!.view()).toBe(txt[j]); + } + } + }); + + test('can move forward, when anchor = After', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = 0; i < txt.length - 1; i++) { + const p = peritext.pointAt(i, Anchor.After); + expect(p.pos()).toBe(i); + expect(p.leftChar()!.view()).toBe(txt[i]); + for (let j = i + 1; j < txt.length - 1; j++) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.After); + expect(p2.leftChar()!.view()).toBe(txt[j]); + } + } + }); + + test('can move backward, when anchor = Before', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = txt.length - 1; i > 0; i--) { + const p = peritext.pointAt(i, Anchor.Before); + expect(p.viewPos()).toBe(i); + for (let j = i - 1; j > 0; j--) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.Before); expect(p2.rightChar()!.view()).toBe(txt[j]); } }