Skip to content

Commit 3c6831f

Browse files
committed
feat(json-crdt-extensions): 🎸 improve Point movement APIs
1 parent fa7923b commit 3c6831f

File tree

2 files changed

+130
-31
lines changed

2 files changed

+130
-31
lines changed

src/json-crdt-extensions/peritext/point/Point.ts

Lines changed: 90 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import type {StringChunk} from '../util/types';
1010
/**
1111
* A "point" in a rich-text Peritext document. It is a combination of a
1212
* character ID and an anchor. Anchor specifies the side of the character to
13-
* which the point is attached. For example, a point with an anchor "before"
14-
* points just before the character, while a point with an anchor "after" points
15-
* just after the character.
13+
* which the point is attached. For example, a point with an anchor "before" .▢
14+
* points just before the character, while a point with an anchor "after" ▢.
15+
* points just after the character.
1616
*/
1717
export class Point implements Pick<Stateful, 'refresh'>, Printable {
1818
constructor(
@@ -21,15 +21,33 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
2121
public anchor: Anchor,
2222
) {}
2323

24+
/**
25+
* Overwrites the internal state of this point with the state of the given
26+
* point.
27+
*
28+
* @param point Point to copy.
29+
*/
2430
public set(point: Point): void {
2531
this.id = point.id;
2632
this.anchor = point.anchor;
2733
}
2834

35+
/**
36+
* Creates a copy of this point.
37+
*
38+
* @returns Returns a new point with the same ID and anchor as this point.
39+
*/
2940
public clone(): Point {
3041
return new Point(this.txt, this.id, this.anchor);
3142
}
3243

44+
/**
45+
*
46+
* @param other The other point to compare to.
47+
* @returns Returns 0 if the two points are equal, -1 if this point is less
48+
* than the other point, and 1 if this point is greater than the other
49+
* point.
50+
*/
3351
public compare(other: Point): -1 | 0 | 1 {
3452
const cmp = compare(this.id, other.id);
3553
if (cmp !== 0) return cmp;
@@ -98,13 +116,23 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
98116
return this.anchor === Anchor.Before ? pos : pos + 1;
99117
}
100118

119+
/**
120+
* Goes to the next visible character in the string. The `move` parameter
121+
* specifies how many characters to move the cursor by. If the cursor reaches
122+
* the end of the string, it will return `undefined`.
123+
*
124+
* @param move How many characters to move the cursor by.
125+
* @returns Next visible ID in string.
126+
*/
101127
public nextId(move: number = 1): ITimestampStruct | undefined {
128+
// TODO: add tests for when cursor is at the end.
129+
if (this.isEndOfStr()) return;
102130
let remaining: number = move;
103131
const {id, txt} = this;
104132
const str = txt.str;
105-
const startFromStrRoot = equal(id, str.id);
106133
let chunk: StringChunk | undefined;
107-
if (startFromStrRoot) {
134+
// TODO: add tests for when cursor starts from start of string.
135+
if (this.isStartOfStr()) {
108136
chunk = str.first();
109137
while (chunk && chunk.del) chunk = str.next(chunk);
110138
if (!chunk) return;
@@ -145,10 +173,13 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
145173
* such character.
146174
*/
147175
public prevId(move: number = 1): ITimestampStruct | undefined {
176+
// TODO: add tests for when cursor is at the start.
177+
if (this.isStartOfStr()) return;
148178
let remaining: number = move;
149179
const {id, txt} = this;
150180
const str = txt.str;
151181
let chunk = this.chunk();
182+
// TODO: handle case when cursor starts from end of string.
152183
if (!chunk) return str.id;
153184
if (!chunk.del) {
154185
const offset = id.time - chunk.id.time;
@@ -173,8 +204,7 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
173204

174205
public rightChar(): ChunkSlice | undefined {
175206
const str = this.txt.str;
176-
const isBeginningOfDoc = equal(this.id, str.id) && this.anchor === Anchor.After;
177-
if (isBeginningOfDoc) {
207+
if (this.isStartOfStr()) {
178208
let chunk = str.first();
179209
while (chunk && chunk.del) chunk = str.next(chunk);
180210
return chunk ? new ChunkSlice(chunk, 0, 1) : undefined;
@@ -201,6 +231,7 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
201231

202232
public leftChar(): ChunkSlice | undefined {
203233
let chunk = this.chunk();
234+
// TODO: Handle case when point references end of str.
204235
if (!chunk) return;
205236
if (chunk.del) {
206237
const prevId = this.prevId();
@@ -221,6 +252,58 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
221252
return new ChunkSlice(chunk, chunk.span - 1, 1);
222253
}
223254

255+
public isStartOfStr(): boolean {
256+
return equal(this.id, this.txt.str.id) && this.anchor === Anchor.After;
257+
}
258+
259+
public isEndOfStr(): boolean {
260+
return equal(this.id, this.txt.str.id) && this.anchor === Anchor.Before;
261+
}
262+
263+
/**
264+
* Modifies the location of the point, such that the spatial location remains
265+
* and anchor remains the same, but ensures that the point references a
266+
* visible (non-deleted) character.
267+
*/
268+
public refVisible(): void {
269+
if (this.anchor === Anchor.Before) this.refBefore();
270+
else this.refAfter();
271+
}
272+
273+
public refStart(): void {
274+
this.id = this.txt.str.id;
275+
this.anchor = Anchor.After;
276+
}
277+
278+
public refEnd(): void {
279+
this.id = this.txt.str.id;
280+
this.anchor = Anchor.Before;
281+
}
282+
283+
/**
284+
* Modifies the location of the point, such that the spatial location remains
285+
* the same, but ensures that it is anchored before a character.
286+
*/
287+
public refBefore(): void {
288+
const chunk = this.chunk();
289+
if (!chunk) return this.refEnd();
290+
if (!chunk.del || this.anchor === Anchor.Before) return;
291+
this.anchor = Anchor.Before;
292+
this.id = this.nextId() || this.txt.str.id;
293+
}
294+
295+
/**
296+
* Modifies the location of the point, such that the spatial location remains
297+
* the same, but ensures that it is anchored after a character.
298+
*/
299+
public refAfter(): void {
300+
const chunk = this.chunk();
301+
if (!chunk) return this.refStart();
302+
if (!chunk.del || this.anchor === Anchor.After) return;
303+
this.anchor = Anchor.After;
304+
this.id = this.prevId() || this.txt.str.id;
305+
}
306+
224307
/**
225308
* Moves point past given number of visible characters. Accepts positive
226309
* and negative distances.
@@ -237,30 +320,6 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
237320
}
238321
}
239322

240-
/**
241-
* Returns a point, which points at the same spatial location, but ensures
242-
* that it is anchored after a character.
243-
*/
244-
public anchorBefore(): Point {
245-
if (this.anchor === Anchor.Before) return this;
246-
const next = this.nextId();
247-
const txt = this.txt;
248-
if (!next) return new Point(txt, txt.str.id, Anchor.Before);
249-
return new Point(txt, next, Anchor.Before);
250-
}
251-
252-
/**
253-
* Returns a point, which points at the same spatial location, but ensures
254-
* that it is anchored after a character.
255-
*/
256-
public anchorAfter(): Point {
257-
if (this.anchor === Anchor.After) return this;
258-
const prev = this.prevId();
259-
const txt = this.txt;
260-
if (!prev) return new Point(txt, txt.str.id, Anchor.After);
261-
return new Point(txt, prev, Anchor.After);
262-
}
263-
264323
// ----------------------------------------------------------------- Stateful
265324

266325
public refresh(): number {

src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ describe('.nextId()', () => {
404404
expect(p2Before.nextId(visibleIDs.length)).toEqual(undefined);
405405
expect(p2After.nextId(visibleIDs.length)).toEqual(undefined);
406406
});
407+
408+
test('can move zero characters', () => {
409+
const {peritext, chunk2, chunkD1} = setupWithChunkedText();
410+
const p1 = peritext.point(chunk2.id, Anchor.Before);
411+
expect(p1.leftChar()!.view()).toBe('3');
412+
p1.prevId(0);
413+
expect(p1.leftChar()!.view()).toBe('3');
414+
const p2 = peritext.point(chunkD1.id, Anchor.Before);
415+
expect(p2.leftChar()!.view()).toBe('3');
416+
p2.prevId(0);
417+
expect(p2.leftChar()!.view()).toBe('3');
418+
});
407419
});
408420

409421
describe('.prevId()', () => {
@@ -496,6 +508,18 @@ describe('.prevId()', () => {
496508
expect(p2Before.prevId(8)).toEqual(undefined);
497509
expect(p2After.prevId(8)).toEqual(undefined);
498510
});
511+
512+
test('can move zero characters', () => {
513+
const {peritext, chunk2, chunkD1} = setupWithChunkedText();
514+
const p1 = peritext.point(chunk2.id, Anchor.Before);
515+
expect(p1.rightChar()!.view()).toBe('4');
516+
p1.nextId(0);
517+
expect(p1.rightChar()!.view()).toBe('4');
518+
const p2 = peritext.point(chunkD1.id, Anchor.Before);
519+
expect(p2.rightChar()!.view()).toBe('4');
520+
p2.nextId(0);
521+
expect(p2.rightChar()!.view()).toBe('4');
522+
});
499523
});
500524

501525
describe('.rightChar()', () => {
@@ -647,3 +671,19 @@ describe('.leftChar()', () => {
647671
expect(p4.leftChar()!.view()).toBe('6');
648672
});
649673
});
674+
675+
describe('.move()', () => {
676+
test('can move forward', () => {
677+
const {peritext, model} = setupWithChunkedText();
678+
model.api.str(['text']).del(4, 1);
679+
const txt = '12346789';
680+
for (let i = 0; i < txt.length - 1; i++) {
681+
const p = peritext.pointAt(i, Anchor.Before);
682+
for (let j = i + 1; j < txt.length - 1; j++) {
683+
const p2 = p.clone();
684+
p2.move(j - i);
685+
expect(p2.rightChar()!.view()).toBe(txt[j]);
686+
}
687+
}
688+
});
689+
});

0 commit comments

Comments
 (0)