Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = string> {
public readonly saved: EditorSlices<T>;
Expand Down Expand Up @@ -96,6 +103,171 @@ export class Editor<T = string> {
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<T>, offset: number): CharIterator<T> {
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<T>(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<T>): CharIterator<T> {
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<T>, offset: number): CharIterator<T> {
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<T>): CharIterator<T> {
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<T>,
predicate: CharPredicate<string>,
firstLetterFound: boolean,
): Point<T> | undefined {
let next: ChunkSlice<T> | undefined;
let prev: ChunkSlice<T> | 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<T>,
predicate: CharPredicate<string> = isLetter,
firstLetterFound: boolean = false,
): Point<T> {
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<T>,
predicate: CharPredicate<string> = isLetter,
firstLetterFound: boolean = false,
): Point<T> {
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<T> {
const range = this.cursor.range();
Expand Down
Original file line number Diff line number Diff line change
@@ -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!'));");
});
});
Loading
Loading