Skip to content

Commit 75a1d51

Browse files
committed
feat(json-crdt-extensions): 🎸 improve Range.at() method
1 parent ce831fd commit 75a1d51

File tree

4 files changed

+207
-29
lines changed

4 files changed

+207
-29
lines changed

src/json-crdt-extensions/peritext/Peritext.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,24 +107,15 @@ export class Peritext implements Printable {
107107
}
108108

109109
/**
110-
* Creates a range from a view position and a length.
110+
* A convenience method for creating a range from a view position and a length.
111+
* See {@link Range.at} for more information.
111112
*
112113
* @param start Position in the text.
113114
* @param length Length of the range.
114115
* @returns A range from the given position with the given length.
115116
*/
116117
public rangeAt(start: number, length: number = 0): Range {
117-
const str = this.str;
118-
if (!length) {
119-
const startId = !start ? str.id : str.find(start - 1) || str.id;
120-
const point = this.point(startId, Anchor.After);
121-
return this.range(point, point.clone());
122-
}
123-
const startId = str.find(start) || str.id;
124-
const endId = str.find(start + length - 1) || startId;
125-
const startEndpoint = this.point(startId, Anchor.Before);
126-
const endEndpoint = this.point(endId, Anchor.After);
127-
return this.range(startEndpoint, endEndpoint);
118+
return Range.at(this, start, length);
128119
}
129120

130121
// --------------------------------------------------------------- Insertions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class Point implements Pick<Stateful, 'refresh'>, Printable {
6969
/**
7070
* Compares two points by their spatial (view) location in the string. Takes
7171
* into account not only the character position in the view, but also handles
72-
* deleted characters and absolute points.
72+
* deleted characters, attachment anchors, and absolute points.
7373
*
7474
* @param other The other point to compare to.
7575
* @returns Returns 0 if the two points are equal, negative if this point is

src/json-crdt-extensions/peritext/slice/Range.ts

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,56 @@ export class Range implements Printable {
2424
return p1.compareSpatial(p2) > 0 ? new Range(txt, p2, p1) : new Range(txt, p1, p2);
2525
}
2626

27+
/**
28+
* A convenience method for creating a range from a view position and a length.
29+
* The `start` argument specifies the position between characters, where
30+
* the range should start. The `size` argument specifies the number of
31+
* characters in the range. If `size` is zero or not specified, the range
32+
* will be collapsed to a single point.
33+
*
34+
* When the range is collapsed, the anchor position is set to "after" the
35+
* character. When the range is expanded, the anchor positions are set to
36+
* "before" for the start point and "after" for the end point.
37+
*
38+
* The `size` argument can be negative, in which case the range is selected
39+
* backwards.
40+
*
41+
* @param txt Peritext context.
42+
* @param start Position in the text between characters.
43+
* @param size Length of the range. Can be negative, in which case the range
44+
* is selected backwards.
45+
* @returns A range from the given position with the given length.
46+
*/
47+
public static at(txt: Peritext, start: number, size: number = 0): Range {
48+
const str = txt.str;
49+
const length = str.length();
50+
if (!size) {
51+
if (start > length) start = length;
52+
const startId = !start ? str.id : str.find(start - 1) || str.id;
53+
const point = txt.point(startId, Anchor.After);
54+
return new Range(txt, point, point.clone());
55+
}
56+
if (size < 0) {
57+
size = -size;
58+
start -= size;
59+
}
60+
if (start < 0) {
61+
size += start;
62+
start = 0;
63+
if (size < 0) return Range.at(txt, start, 0);
64+
}
65+
if (start >= length) {
66+
start = length;
67+
size = 0;
68+
}
69+
if (start + size > length) size = length - start;
70+
const startId = str.find(start) || str.id;
71+
const endId = str.find(start + size - 1) || startId;
72+
const startEndpoint = txt.point(startId, Anchor.Before);
73+
const endEndpoint = txt.point(endId, Anchor.After);
74+
return new Range(txt, startEndpoint, endEndpoint);
75+
}
76+
2777
/**
2878
* @param txt Peritext context.
2979
* @param start Start point of the range, must be before or equal to end.
@@ -81,17 +131,6 @@ export class Range implements Printable {
81131
this.start = this.end.clone();
82132
}
83133

84-
/**
85-
* Returns the range in the view coordinates as a position and length.
86-
*
87-
* @returns The range as a view position and length.
88-
*/
89-
public views(): [at: number, len: number] {
90-
const start = this.start.viewPos();
91-
const end = this.end.viewPos();
92-
return [start, end - start];
93-
}
94-
95134
public set(start: Point, end: Point = start): void {
96135
this.start = start;
97136
this.end = end === start ? end.clone() : end;
@@ -102,8 +141,7 @@ export class Range implements Printable {
102141
}
103142

104143
public setAt(start: number, length: number = 0): void {
105-
// TODO: move implementation to here
106-
const range = this.txt.rangeAt(start, length);
144+
const range = Range.at(this.txt, start, length);
107145
this.setRange(range);
108146
}
109147

@@ -197,9 +235,29 @@ export class Range implements Printable {
197235
}
198236
}
199237

238+
// -------------------------------------------------- View coordinate methods
239+
240+
/**
241+
* Returns the range in the view coordinates as a position and length.
242+
*
243+
* @returns The range as a view position and length.
244+
*/
245+
public view(): [start: number, size: number] {
246+
const start = this.start.viewPos();
247+
const end = this.end.viewPos();
248+
return [start, end - start];
249+
}
250+
251+
/**
252+
* @returns The length of the range in view coordinates.
253+
*/
254+
public length(): number {
255+
return this.end.viewPos() - this.start.viewPos();
256+
}
257+
200258
/**
201-
* Concatenates all text chunks in the range ignoring tombstones and returns
202-
* the result.
259+
* Returns plain text view of the range. Concatenates all text chunks in the
260+
* range ignoring tombstones and returns the result.
203261
*
204262
* @returns The text content of the range.
205263
*/

src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,135 @@ describe('.from()', () => {
5353
});
5454
});
5555

56+
describe('.at()', () => {
57+
describe('collapsed', () => {
58+
test('can set caret to absolute start', () => {
59+
const {peritext} = setup();
60+
const range = peritext.rangeAt(0);
61+
expect(range.start.isAbsStart()).toBe(true);
62+
expect(range.end.isAbsStart()).toBe(true);
63+
expect(range.start).not.toBe(range.end);
64+
});
65+
66+
test('can set caret to various text positions', () => {
67+
const {peritext} = setup();
68+
const length = peritext.str.length();
69+
for (let i = 1; i <= length; i++) {
70+
const range = peritext.rangeAt(i);
71+
expect(range.start.viewPos()).toBe(i);
72+
expect(range.end.viewPos()).toBe(i);
73+
expect(range.start).not.toBe(range.end);
74+
}
75+
});
76+
77+
test('truncates lower bound', () => {
78+
const {peritext} = setup();
79+
const range = peritext.rangeAt(-123);
80+
expect(range.start.isAbsStart()).toBe(true);
81+
});
82+
83+
test('truncates upper bound', () => {
84+
const {peritext} = setup();
85+
const range = peritext.rangeAt(123);
86+
expect(range.start.viewPos()).toBe(peritext.str.length());
87+
});
88+
});
89+
90+
describe('expanded', () => {
91+
test('can select first character', () => {
92+
const {peritext} = setup();
93+
const range = peritext.rangeAt(0, 1);
94+
expect(range.length()).toBe(1);
95+
expect(range.text()).toBe('H');
96+
expect(range.start.anchor).toBe(Anchor.Before);
97+
expect(range.end.anchor).toBe(Anchor.After);
98+
expect(range.start.id.time).toBe(range.end.id.time);
99+
});
100+
101+
test('can select any combination of characters', () => {
102+
const {peritext} = setupEvenDeleted();
103+
const length = peritext.str.length();
104+
for (let i = 0; i < length; i++) {
105+
for (let j = 1; j <= length - i; j++) {
106+
const range = peritext.rangeAt(i, j);
107+
expect(range.length()).toBe(j);
108+
expect(range.text()).toBe(peritext.str.view().slice(i, i + j));
109+
expect(range.start.anchor).toBe(Anchor.Before);
110+
expect(range.end.anchor).toBe(Anchor.After);
111+
}
112+
}
113+
});
114+
115+
test('truncates lower bound', () => {
116+
const {peritext} = setup();
117+
const range = peritext.rangeAt(-2, 5);
118+
expect(range.text()).toBe('Hel');
119+
expect(range.start.isRelStart()).toBe(true);
120+
expect(range.end.anchor).toBe(Anchor.After);
121+
});
122+
123+
test('truncates upper bound', () => {
124+
const {peritext} = setup();
125+
const range = peritext.rangeAt(2, peritext.str.length() + 10);
126+
expect(range.text()).toBe('llo world!');
127+
expect(range.start.anchor).toBe(Anchor.Before);
128+
expect(range.end.isRelEnd()).toBe(true);
129+
});
130+
131+
test('truncates lower and upper bounds to select all text', () => {
132+
const {peritext} = setup();
133+
const range = peritext.rangeAt(-123, 256);
134+
expect(range.text()).toBe('Hello world!');
135+
expect(range.start.isRelStart()).toBe(true);
136+
expect(range.end.isRelEnd()).toBe(true);
137+
});
138+
139+
describe('when negative size', () => {
140+
test('can select range backwards', () => {
141+
const {peritext} = setup();
142+
const range = peritext.rangeAt(2, -1);
143+
expect(range.text()).toBe('e');
144+
expect(range.start.anchor).toBe(Anchor.Before);
145+
expect(range.end.anchor).toBe(Anchor.After);
146+
});
147+
148+
test('can select range backwards, all combinations', () => {
149+
const {peritext} = setupEvenDeleted();
150+
const length = peritext.str.length();
151+
for (let i = 1; i < length; i++) {
152+
for (let j = 1; j <= i; j++) {
153+
const range = peritext.rangeAt(i, -j);
154+
expect(range.length()).toBe(j);
155+
expect(range.text()).toBe(peritext.str.view().slice(i - j, i));
156+
expect(range.start.anchor).toBe(Anchor.Before);
157+
expect(range.end.anchor).toBe(Anchor.After);
158+
}
159+
}
160+
});
161+
162+
test('truncates lower bound', () => {
163+
const {peritext} = setupEvenDeleted();
164+
const range = peritext.rangeAt(2, -5);
165+
expect(range.text()).toBe('13');
166+
});
167+
168+
test('truncates upper bound', () => {
169+
const {peritext} = setupEvenDeleted();
170+
const range = peritext.rangeAt(7, -4);
171+
expect(range.text()).toBe('79');
172+
});
173+
174+
test('truncates upper and lower bounds, can select all text', () => {
175+
const {peritext} = setupEvenDeleted();
176+
const range = peritext.rangeAt(10, -20);
177+
expect(range.text()).toBe('13579');
178+
expect(range.start.isRelStart()).toBe(true);
179+
expect(range.end.isRelEnd()).toBe(true);
180+
});
181+
});
182+
});
183+
});
184+
56185
describe('.clone()', () => {
57186
test('can clone a range', () => {
58187
const {peritext} = setup();
@@ -185,7 +314,7 @@ describe('.view()', () => {
185314
test('returns correct view', () => {
186315
const {peritext} = setup();
187316
const range = peritext.rangeAt(2, 3);
188-
expect(range.views()).toEqual([2, 3]);
317+
expect(range.view()).toEqual([2, 3]);
189318
});
190319
});
191320

0 commit comments

Comments
 (0)