Skip to content

Commit 90497c7

Browse files
authored
Merge pull request #718 from streamich/editor-iterators
Editor iterators
2 parents d1c9c33 + 05af3eb commit 90497c7

File tree

5 files changed

+575
-0
lines changed

5 files changed

+575
-0
lines changed

src/json-crdt-extensions/peritext/editor/Editor.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import {CursorAnchor, SliceBehavior} from '../slice/constants';
33
import {PersistedSlice} from '../slice/PersistedSlice';
44
import {EditorSlices} from './EditorSlices';
55
import {Chars} from '../constants';
6+
import {ChunkSlice} from '../util/ChunkSlice';
7+
import {contains, equal} from '../../../json-crdt-patch/clock';
8+
import {isLetter} from './util';
9+
import {Anchor} from '../rga/constants';
610
import type {ITimestampStruct} from '../../../json-crdt-patch/clock';
711
import type {Peritext} from '../Peritext';
812
import type {SliceType} from '../slice/types';
913
import type {MarkerSlice} from '../slice/MarkerSlice';
14+
import type {Chunk} from '../../../json-crdt/nodes/rga';
15+
import type {CharIterator, CharPredicate} from './types';
16+
import type {Point} from '../rga/Point';
1017

1118
export class Editor<T = string> {
1219
public readonly saved: EditorSlices<T>;
@@ -96,6 +103,171 @@ export class Editor<T = string> {
96103
return true;
97104
}
98105

106+
/**
107+
* Returns a forward iterator through visible text, one character at a time,
108+
* starting from a given chunk and offset.
109+
*
110+
* @param chunk Chunk to start from.
111+
* @param offset Offset in the chunk to start from.
112+
* @returns The next visible character iterator.
113+
*/
114+
public fwd0(chunk: undefined | Chunk<T>, offset: number): CharIterator<T> {
115+
const str = this.txt.str;
116+
return () => {
117+
if (!chunk) return;
118+
const span = chunk.span;
119+
const offsetToReturn = offset;
120+
const chunkToReturn = chunk;
121+
if (offset >= span) return;
122+
offset++;
123+
if (offset >= span) {
124+
offset = 0;
125+
chunk = str.next(chunk);
126+
while (chunk && chunk.del) chunk = str.next(chunk);
127+
}
128+
return new ChunkSlice<T>(chunkToReturn, offsetToReturn, 1);
129+
};
130+
}
131+
132+
/**
133+
* Returns a forward iterator through visible text, one character at a time,
134+
* starting from a given ID.
135+
*
136+
* @param id ID to start from.
137+
* @param chunk Chunk to start from.
138+
* @returns The next visible character iterator.
139+
*/
140+
public fwd1(id: ITimestampStruct, chunk?: Chunk<T>): CharIterator<T> {
141+
const str = this.txt.str;
142+
const startFromStrRoot = equal(id, str.id);
143+
if (startFromStrRoot) {
144+
chunk = str.first();
145+
while (chunk && chunk.del) chunk = str.next(chunk);
146+
return this.fwd0(chunk, 0);
147+
}
148+
let offset: number = 0;
149+
if (!chunk || !contains(chunk.id, chunk.span, id, 1)) {
150+
chunk = str.findById(id);
151+
if (!chunk) return () => undefined;
152+
offset = id.time - chunk.id.time;
153+
} else offset = id.time - chunk.id.time;
154+
if (!chunk.del) return this.fwd0(chunk, offset);
155+
while (chunk && chunk.del) chunk = str.next(chunk);
156+
return this.fwd0(chunk, 0);
157+
}
158+
159+
public bwd0(chunk: undefined | Chunk<T>, offset: number): CharIterator<T> {
160+
const txt = this.txt;
161+
const str = txt.str;
162+
return () => {
163+
if (!chunk || offset < 0) return;
164+
const offsetToReturn = offset;
165+
const chunkToReturn = chunk;
166+
offset--;
167+
if (offset < 0) {
168+
chunk = str.prev(chunk);
169+
while (chunk && chunk.del) chunk = str.prev(chunk);
170+
if (chunk) offset = chunk.span - 1;
171+
}
172+
return new ChunkSlice(chunkToReturn, offsetToReturn, 1);
173+
};
174+
}
175+
176+
public bwd1(id: ITimestampStruct, chunk?: Chunk<T>): CharIterator<T> {
177+
const str = this.txt.str;
178+
const startFromStrRoot = equal(id, str.id);
179+
if (startFromStrRoot) {
180+
chunk = str.last();
181+
while (chunk && chunk.del) chunk = str.prev(chunk);
182+
return this.bwd0(chunk, chunk ? chunk.span - 1 : 0);
183+
}
184+
let offset: number = 0;
185+
if (!chunk || !contains(chunk.id, chunk.span, id, 1)) {
186+
chunk = str.findById(id);
187+
if (!chunk) return () => undefined;
188+
offset = id.time - chunk.id.time;
189+
} else offset = id.time - chunk.id.time;
190+
if (!chunk.del) return this.bwd0(chunk, offset);
191+
while (chunk && chunk.del) chunk = str.prev(chunk);
192+
return this.bwd0(chunk, chunk ? chunk.span - 1 : 0);
193+
}
194+
195+
/**
196+
* Skips a word in an arbitrary direction. A word is defined by the `predicate`
197+
* function, which returns `true` if the character is part of the word.
198+
*
199+
* @param iterator Character iterator.
200+
* @param predicate Predicate function to match characters, returns `true` if
201+
* the character is part of the word.
202+
* @param firstLetterFound Whether the first letter has already been found. If
203+
* not, will skip any characters until the first letter, which is matched
204+
* by the `predicate` is found.
205+
* @returns Point after the last character skipped.
206+
*/
207+
private skipWord(
208+
iterator: CharIterator<T>,
209+
predicate: CharPredicate<string>,
210+
firstLetterFound: boolean,
211+
): Point<T> | undefined {
212+
let next: ChunkSlice<T> | undefined;
213+
let prev: ChunkSlice<T> | undefined;
214+
while ((next = iterator())) {
215+
const char = (next.view() as string)[0];
216+
if (firstLetterFound) {
217+
if (!predicate(char)) break;
218+
} else if (predicate(char)) firstLetterFound = true;
219+
prev = next;
220+
}
221+
if (!prev) return;
222+
return this.txt.point(prev.id(), Anchor.After);
223+
}
224+
225+
/**
226+
* Skips a word forward. A word is defined by the `predicate` function, which
227+
* returns `true` if the character is part of the word.
228+
*
229+
* @param point Point from which to start skipping.
230+
* @param predicate Character class to skip.
231+
* @param firstLetterFound Whether the first letter has already been found. If
232+
* not, will skip any characters until the first letter, which is
233+
* matched by the `predicate` is found.
234+
* @returns Point after the last character skipped.
235+
*/
236+
public fwdSkipWord(
237+
point: Point<T>,
238+
predicate: CharPredicate<string> = isLetter,
239+
firstLetterFound: boolean = false,
240+
): Point<T> {
241+
const firstChar = point.rightChar();
242+
if (!firstChar) return point;
243+
const fwd = this.fwd1(firstChar.id(), firstChar.chunk);
244+
return this.skipWord(fwd, predicate, firstLetterFound) || point;
245+
}
246+
247+
/**
248+
* Skips a word backward. A word is defined by the `predicate` function, which
249+
* returns `true` if the character is part of the word.
250+
*
251+
* @param point Point from which to start skipping.
252+
* @param predicate Character class to skip.
253+
* @param firstLetterFound Whether the first letter has already been found. If
254+
* not, will skip any characters until the first letter, which is
255+
* matched by the `predicate` is found.
256+
* @returns Point after the last character skipped.
257+
*/
258+
public bwdSkipWord(
259+
point: Point<T>,
260+
predicate: CharPredicate<string> = isLetter,
261+
firstLetterFound: boolean = false,
262+
): Point<T> {
263+
const firstChar = point.leftChar();
264+
if (!firstChar) return point;
265+
const bwd = this.bwd1(firstChar.id(), firstChar.chunk);
266+
const endPoint = this.skipWord(bwd, predicate, firstLetterFound);
267+
if (endPoint) endPoint.anchor = Anchor.Before;
268+
return endPoint || point;
269+
}
270+
99271
/** @deprecated use `.saved.insStack` */
100272
public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice<T> {
101273
const range = this.cursor.range();
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {Model} from '../../../../json-crdt/model';
2+
import {Peritext} from '../../Peritext';
3+
import {Point} from '../../rga/Point';
4+
import {Editor} from '../Editor';
5+
6+
const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
7+
const model = Model.withLogicalClock(sid);
8+
model.api.root({
9+
text: '',
10+
slices: [],
11+
});
12+
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
13+
const editor = peritext.editor;
14+
insert(editor);
15+
return {model, peritext, editor};
16+
};
17+
18+
describe('.fwdSkipWord()', () => {
19+
test('can go to the end of a word', () => {
20+
const {editor} = setup((editor) => editor.insert('Hello world!'));
21+
editor.cursor.setAt(0);
22+
const point = editor.fwdSkipWord(editor.cursor.end);
23+
editor.cursor.end.set(point!);
24+
expect(editor.cursor.text()).toBe('Hello');
25+
});
26+
27+
test('can skip whitespace between words', () => {
28+
const {editor} = setup((editor) => editor.insert('Hello world!'));
29+
editor.cursor.setAt(5);
30+
const point = editor.fwdSkipWord(editor.cursor.end);
31+
editor.cursor.end.set(point!);
32+
expect(editor.cursor.text()).toBe(' world');
33+
});
34+
35+
test('skipping stops before exclamation mark', () => {
36+
const {editor} = setup((editor) => editor.insert('Hello world!'));
37+
editor.cursor.setAt(6);
38+
const point = editor.fwdSkipWord(editor.cursor.end);
39+
editor.cursor.end.set(point!);
40+
expect(editor.cursor.text()).toBe('world');
41+
});
42+
43+
test('can skip to the end of string', () => {
44+
const {editor} = setup((editor) => editor.insert('Hello world!'));
45+
editor.cursor.setAt(11);
46+
const point = editor.fwdSkipWord(editor.cursor.end);
47+
expect(point instanceof Point).toBe(true);
48+
editor.cursor.end.set(point!);
49+
expect(editor.cursor.text()).toBe('!');
50+
});
51+
52+
test('can skip various character classes', () => {
53+
const {editor} = setup((editor) =>
54+
editor.insert("const {editor} = setup(editor => editor.insert('Hello world!'));"),
55+
);
56+
editor.cursor.setAt(0);
57+
const move = (): string => {
58+
const point = editor.fwdSkipWord(editor.cursor.end);
59+
if (point) editor.cursor.end.set(point);
60+
return editor.cursor.text();
61+
};
62+
expect(move()).toBe('const');
63+
expect(move()).toBe('const {editor');
64+
expect(move()).toBe('const {editor} = setup');
65+
expect(move()).toBe('const {editor} = setup(editor');
66+
expect(move()).toBe('const {editor} = setup(editor => editor');
67+
expect(move()).toBe('const {editor} = setup(editor => editor.insert');
68+
expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello");
69+
expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world");
70+
expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world!'));");
71+
});
72+
});
73+
74+
describe('.bwdSkipWord()', () => {
75+
test('can skip over simple text.', () => {
76+
const {editor} = setup((editor) => editor.insert('Hello world!\nfoo bar baz'));
77+
editor.cursor.setAt(editor.txt.str.length());
78+
const move = (): string => {
79+
const point = editor.bwdSkipWord(editor.cursor.start);
80+
if (point) editor.cursor.start.set(point);
81+
return editor.cursor.text();
82+
};
83+
expect(move()).toBe('baz');
84+
expect(move()).toBe('bar baz');
85+
expect(move()).toBe('foo bar baz');
86+
expect(move()).toBe('world!\nfoo bar baz');
87+
expect(move()).toBe('Hello world!\nfoo bar baz');
88+
});
89+
90+
test('can skip various character classes', () => {
91+
const {editor} = setup((editor) =>
92+
editor.insert("const {editor} = setup(editor => editor.insert('Hello world!'));"),
93+
);
94+
editor.cursor.setAt(editor.txt.str.length());
95+
const move = (): string => {
96+
const point = editor.bwdSkipWord(editor.cursor.start);
97+
if (point) editor.cursor.start.set(point);
98+
return editor.cursor.text();
99+
};
100+
expect(move()).toBe("world!'));");
101+
expect(move()).toBe("Hello world!'));");
102+
expect(move()).toBe("insert('Hello world!'));");
103+
expect(move()).toBe("editor.insert('Hello world!'));");
104+
expect(move()).toBe("editor => editor.insert('Hello world!'));");
105+
expect(move()).toBe("setup(editor => editor.insert('Hello world!'));");
106+
expect(move()).toBe("editor} = setup(editor => editor.insert('Hello world!'));");
107+
expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world!'));");
108+
});
109+
});

0 commit comments

Comments
 (0)