diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 96afe78238..b0f4f84ebc 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -3,10 +3,17 @@ import {CursorAnchor, SliceBehavior} from '../slice/constants'; import {PersistedSlice} from '../slice/PersistedSlice'; import {EditorSlices} from './EditorSlices'; import {Chars} from '../constants'; +import {ChunkSlice} from '../util/ChunkSlice'; +import {contains, equal} from '../../../json-crdt-patch/clock'; +import {isLetter} from './util'; +import {Anchor} from '../rga/constants'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Peritext} from '../Peritext'; import type {SliceType} from '../slice/types'; import type {MarkerSlice} from '../slice/MarkerSlice'; +import type {Chunk} from '../../../json-crdt/nodes/rga'; +import type {CharIterator, CharPredicate} from './types'; +import type {Point} from '../rga/Point'; export class Editor { public readonly saved: EditorSlices; @@ -96,6 +103,171 @@ export class Editor { return true; } + /** + * Returns a forward iterator through visible text, one character at a time, + * starting from a given chunk and offset. + * + * @param chunk Chunk to start from. + * @param offset Offset in the chunk to start from. + * @returns The next visible character iterator. + */ + public fwd0(chunk: undefined | Chunk, offset: number): CharIterator { + const str = this.txt.str; + return () => { + if (!chunk) return; + const span = chunk.span; + const offsetToReturn = offset; + const chunkToReturn = chunk; + if (offset >= span) return; + offset++; + if (offset >= span) { + offset = 0; + chunk = str.next(chunk); + while (chunk && chunk.del) chunk = str.next(chunk); + } + return new ChunkSlice(chunkToReturn, offsetToReturn, 1); + }; + } + + /** + * Returns a forward iterator through visible text, one character at a time, + * starting from a given ID. + * + * @param id ID to start from. + * @param chunk Chunk to start from. + * @returns The next visible character iterator. + */ + public fwd1(id: ITimestampStruct, chunk?: Chunk): CharIterator { + const str = this.txt.str; + const startFromStrRoot = equal(id, str.id); + if (startFromStrRoot) { + chunk = str.first(); + while (chunk && chunk.del) chunk = str.next(chunk); + return this.fwd0(chunk, 0); + } + let offset: number = 0; + if (!chunk || !contains(chunk.id, chunk.span, id, 1)) { + chunk = str.findById(id); + if (!chunk) return () => undefined; + offset = id.time - chunk.id.time; + } else offset = id.time - chunk.id.time; + if (!chunk.del) return this.fwd0(chunk, offset); + while (chunk && chunk.del) chunk = str.next(chunk); + return this.fwd0(chunk, 0); + } + + public bwd0(chunk: undefined | Chunk, offset: number): CharIterator { + const txt = this.txt; + const str = txt.str; + return () => { + if (!chunk || offset < 0) return; + const offsetToReturn = offset; + const chunkToReturn = chunk; + offset--; + if (offset < 0) { + chunk = str.prev(chunk); + while (chunk && chunk.del) chunk = str.prev(chunk); + if (chunk) offset = chunk.span - 1; + } + return new ChunkSlice(chunkToReturn, offsetToReturn, 1); + }; + } + + public bwd1(id: ITimestampStruct, chunk?: Chunk): CharIterator { + const str = this.txt.str; + const startFromStrRoot = equal(id, str.id); + if (startFromStrRoot) { + chunk = str.last(); + while (chunk && chunk.del) chunk = str.prev(chunk); + return this.bwd0(chunk, chunk ? chunk.span - 1 : 0); + } + let offset: number = 0; + if (!chunk || !contains(chunk.id, chunk.span, id, 1)) { + chunk = str.findById(id); + if (!chunk) return () => undefined; + offset = id.time - chunk.id.time; + } else offset = id.time - chunk.id.time; + if (!chunk.del) return this.bwd0(chunk, offset); + while (chunk && chunk.del) chunk = str.prev(chunk); + return this.bwd0(chunk, chunk ? chunk.span - 1 : 0); + } + + /** + * Skips a word in an arbitrary direction. A word is defined by the `predicate` + * function, which returns `true` if the character is part of the word. + * + * @param iterator Character iterator. + * @param predicate Predicate function to match characters, returns `true` if + * the character is part of the word. + * @param firstLetterFound Whether the first letter has already been found. If + * not, will skip any characters until the first letter, which is matched + * by the `predicate` is found. + * @returns Point after the last character skipped. + */ + private skipWord( + iterator: CharIterator, + predicate: CharPredicate, + firstLetterFound: boolean, + ): Point | undefined { + let next: ChunkSlice | undefined; + let prev: ChunkSlice | undefined; + while ((next = iterator())) { + const char = (next.view() as string)[0]; + if (firstLetterFound) { + if (!predicate(char)) break; + } else if (predicate(char)) firstLetterFound = true; + prev = next; + } + if (!prev) return; + return this.txt.point(prev.id(), Anchor.After); + } + + /** + * Skips a word forward. A word is defined by the `predicate` function, which + * returns `true` if the character is part of the word. + * + * @param point Point from which to start skipping. + * @param predicate Character class to skip. + * @param firstLetterFound Whether the first letter has already been found. If + * not, will skip any characters until the first letter, which is + * matched by the `predicate` is found. + * @returns Point after the last character skipped. + */ + public fwdSkipWord( + point: Point, + predicate: CharPredicate = isLetter, + firstLetterFound: boolean = false, + ): Point { + const firstChar = point.rightChar(); + if (!firstChar) return point; + const fwd = this.fwd1(firstChar.id(), firstChar.chunk); + return this.skipWord(fwd, predicate, firstLetterFound) || point; + } + + /** + * Skips a word backward. A word is defined by the `predicate` function, which + * returns `true` if the character is part of the word. + * + * @param point Point from which to start skipping. + * @param predicate Character class to skip. + * @param firstLetterFound Whether the first letter has already been found. If + * not, will skip any characters until the first letter, which is + * matched by the `predicate` is found. + * @returns Point after the last character skipped. + */ + public bwdSkipWord( + point: Point, + predicate: CharPredicate = isLetter, + firstLetterFound: boolean = false, + ): Point { + const firstChar = point.leftChar(); + if (!firstChar) return point; + const bwd = this.bwd1(firstChar.id(), firstChar.chunk); + const endPoint = this.skipWord(bwd, predicate, firstLetterFound); + if (endPoint) endPoint.anchor = Anchor.Before; + return endPoint || point; + } + /** @deprecated use `.saved.insStack` */ public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { const range = this.cursor.range(); diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-cursor.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-cursor.spec.ts new file mode 100644 index 0000000000..d5df6ffde9 --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-cursor.spec.ts @@ -0,0 +1,109 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Point} from '../../rga/Point'; +import {Editor} from '../Editor'; + +const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => { + const model = Model.withLogicalClock(sid); + model.api.root({ + text: '', + slices: [], + }); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + const editor = peritext.editor; + insert(editor); + return {model, peritext, editor}; +}; + +describe('.fwdSkipWord()', () => { + test('can go to the end of a word', () => { + const {editor} = setup((editor) => editor.insert('Hello world!')); + editor.cursor.setAt(0); + const point = editor.fwdSkipWord(editor.cursor.end); + editor.cursor.end.set(point!); + expect(editor.cursor.text()).toBe('Hello'); + }); + + test('can skip whitespace between words', () => { + const {editor} = setup((editor) => editor.insert('Hello world!')); + editor.cursor.setAt(5); + const point = editor.fwdSkipWord(editor.cursor.end); + editor.cursor.end.set(point!); + expect(editor.cursor.text()).toBe(' world'); + }); + + test('skipping stops before exclamation mark', () => { + const {editor} = setup((editor) => editor.insert('Hello world!')); + editor.cursor.setAt(6); + const point = editor.fwdSkipWord(editor.cursor.end); + editor.cursor.end.set(point!); + expect(editor.cursor.text()).toBe('world'); + }); + + test('can skip to the end of string', () => { + const {editor} = setup((editor) => editor.insert('Hello world!')); + editor.cursor.setAt(11); + const point = editor.fwdSkipWord(editor.cursor.end); + expect(point instanceof Point).toBe(true); + editor.cursor.end.set(point!); + expect(editor.cursor.text()).toBe('!'); + }); + + test('can skip various character classes', () => { + const {editor} = setup((editor) => + editor.insert("const {editor} = setup(editor => editor.insert('Hello world!'));"), + ); + editor.cursor.setAt(0); + const move = (): string => { + const point = editor.fwdSkipWord(editor.cursor.end); + if (point) editor.cursor.end.set(point); + return editor.cursor.text(); + }; + expect(move()).toBe('const'); + expect(move()).toBe('const {editor'); + expect(move()).toBe('const {editor} = setup'); + expect(move()).toBe('const {editor} = setup(editor'); + expect(move()).toBe('const {editor} = setup(editor => editor'); + expect(move()).toBe('const {editor} = setup(editor => editor.insert'); + expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello"); + expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world"); + expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world!'));"); + }); +}); + +describe('.bwdSkipWord()', () => { + test('can skip over simple text.', () => { + const {editor} = setup((editor) => editor.insert('Hello world!\nfoo bar baz')); + editor.cursor.setAt(editor.txt.str.length()); + const move = (): string => { + const point = editor.bwdSkipWord(editor.cursor.start); + if (point) editor.cursor.start.set(point); + return editor.cursor.text(); + }; + expect(move()).toBe('baz'); + expect(move()).toBe('bar baz'); + expect(move()).toBe('foo bar baz'); + expect(move()).toBe('world!\nfoo bar baz'); + expect(move()).toBe('Hello world!\nfoo bar baz'); + }); + + test('can skip various character classes', () => { + const {editor} = setup((editor) => + editor.insert("const {editor} = setup(editor => editor.insert('Hello world!'));"), + ); + editor.cursor.setAt(editor.txt.str.length()); + const move = (): string => { + const point = editor.bwdSkipWord(editor.cursor.start); + if (point) editor.cursor.start.set(point); + return editor.cursor.text(); + }; + expect(move()).toBe("world!'));"); + expect(move()).toBe("Hello world!'));"); + expect(move()).toBe("insert('Hello world!'));"); + expect(move()).toBe("editor.insert('Hello world!'));"); + expect(move()).toBe("editor => editor.insert('Hello world!'));"); + expect(move()).toBe("setup(editor => editor.insert('Hello world!'));"); + expect(move()).toBe("editor} = setup(editor => editor.insert('Hello world!'));"); + expect(move()).toBe("const {editor} = setup(editor => editor.insert('Hello world!'));"); + }); +}); diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-iterators.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-iterators.spec.ts new file mode 100644 index 0000000000..bd953c0c6e --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-iterators.spec.ts @@ -0,0 +1,281 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Editor} from '../Editor'; + +const setup = ( + insertText = (editor: Editor) => { + editor.insert('abcd'); + editor.cursor.setAt(2); + editor.insert('0123'); + editor.cursor.setAt(7); + editor.insert('4567'); + editor.cursor.setAt(0, 2); + editor.delBwd(); + editor.cursor.setAt(9, 1); + editor.delBwd(); + editor.cursor.setAt(4, 1); + editor.delBwd(); + }, +) => { + const model = Model.create(void 0, 858549494849333); + model.api.root({ + text: '', + slices: [], + }); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + const editor = peritext.editor; + insertText(editor); + return {model, peritext, editor}; +}; + +describe('.fwd1()', () => { + test('can use string root as initial point', () => { + const {peritext, editor} = setup(); + const iterator = editor.fwd1(peritext.str.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('01234567'); + }); + + test('can iterate through the entire string', () => { + const {peritext, editor} = setup(); + const start = peritext.pointStart()!; + const iterator = editor.fwd1(start.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('01234567'); + }); + + test('can iterate through the entire string, starting from ABS start', () => { + const {peritext, editor} = setup(); + const start = peritext.pointAbsStart()!; + const iterator = editor.fwd1(start.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('01234567'); + }); + + test('can iterate through the entire string, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const start = peritext.pointStart()!; + const iterator = editor.fwd1(start.id, start.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('01234567'); + }); + + test('can iterate starting in the middle of first chunk', () => { + const {peritext, editor} = setup(); + const start = peritext.pointAt(2); + const iterator = editor.fwd1(start.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('234567'); + }); + + test('can iterate starting in the middle of first chunk, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const start = peritext.pointAt(2); + const iterator = editor.fwd1(start.id, start.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('234567'); + }); + + test('can iterate starting in the middle of second chunk', () => { + const {peritext, editor} = setup(); + const start = peritext.pointAt(6); + const iterator = editor.fwd1(start.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('67'); + }); + + test('can iterate starting in the middle of second chunk, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const start = peritext.pointAt(6); + const iterator = editor.fwd1(start.id, start.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('67'); + }); + + test('returns true for block split chars', () => { + const {peritext, editor} = setup((editor) => { + editor.insert('ab'); + editor.cursor.setAt(1); + editor.saved.insMarker('p'); + }); + peritext.overlay.refresh(); + const start = peritext.pointAt(0); + const iterator = editor.fwd1(start.id, start.chunk()); + let str = ''; + const bools: boolean[] = []; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + bools.push(peritext.overlay.isMarker(res.id())); + } + expect(str).toBe('a\nb'); + expect(bools).toStrictEqual([false, true, false]); + }); +}); + +describe('.bwd1()', () => { + test('can use string root as initial point', () => { + const {peritext, editor} = setup(); + const iterator = editor.bwd1(peritext.str.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('76543210'); + }); + + test('can iterate through the entire string', () => { + const {peritext, editor} = setup(); + const end = peritext.pointEnd()!; + const iterator = editor.bwd1(end.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('76543210'); + }); + + test('can iterate through the entire string, starting from ABS end', () => { + const {peritext, editor} = setup(); + const end = peritext.pointAbsEnd()!; + const iterator = editor.bwd1(end.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('76543210'); + }); + + test('can iterate through the entire string, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const end = peritext.pointEnd()!; + const iterator = editor.bwd1(end.id, end.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('76543210'); + }); + + test('can iterate starting in the middle of first chunk', () => { + const {peritext, editor} = setup(); + const end = peritext.pointAt(2); + const iterator = editor.bwd1(end.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('210'); + }); + + test('can iterate starting in the middle of first chunk, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const end = peritext.pointAt(2); + const iterator = editor.bwd1(end.id, end.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('210'); + }); + + test('can iterate starting in the middle of second chunk', () => { + const {peritext, editor} = setup(); + const end = peritext.pointAt(6); + const iterator = editor.bwd1(end.id); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('6543210'); + }); + + test('can iterate starting in the middle of second chunk, with initial chunk provided', () => { + const {peritext, editor} = setup(); + const end = peritext.pointAt(6); + const iterator = editor.bwd1(end.id, end.chunk()); + let str = ''; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + } + expect(str).toBe('6543210'); + }); + + test('returns true for block split chars', () => { + const {peritext, editor} = setup((editor) => { + editor.insert('ab'); + editor.cursor.setAt(1); + editor.saved.insMarker('p'); + }); + peritext.overlay.refresh(); + const start = peritext.pointAt(3); + const iterator = editor.bwd1(start.id, start.chunk()); + let str = ''; + const bools: boolean[] = []; + while (1) { + const res = iterator(); + if (!res) break; + str += res.view(); + bools.push(peritext.overlay.isMarker(res.id())); + } + expect(str).toBe('b\na'); + expect(bools).toStrictEqual([false, true, false]); + }); +}); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts new file mode 100644 index 0000000000..891f2d18e0 --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -0,0 +1,5 @@ +import type {UndefIterator} from '../../../util/iterator'; +import type {ChunkSlice} from '../util/ChunkSlice'; + +export type CharIterator = UndefIterator>; +export type CharPredicate = (char: T) => boolean; diff --git a/src/json-crdt-extensions/peritext/editor/util.ts b/src/json-crdt-extensions/peritext/editor/util.ts new file mode 100644 index 0000000000..8010ba888f --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/util.ts @@ -0,0 +1,8 @@ +import {CharPredicate} from './types'; + +const LETTER_REGEX = /(\p{Letter}|\d)/u; +const WHITESPACE_REGEX = /\s/; + +export const isLetter: CharPredicate = (char: string) => LETTER_REGEX.test(char[0]); +export const isWhitespace: CharPredicate = (char: string) => WHITESPACE_REGEX.test(char[0]); +export const isPunctuation: CharPredicate = (char: string) => !isLetter(char) && !isWhitespace(char);