Skip to content

Commit

Permalink
Merge pull request #577 from streamich/slices-2
Browse files Browse the repository at this point in the history
JSON CRDT Peritext slices followup
  • Loading branch information
streamich authored Apr 15, 2024
2 parents 97c213c + 7152fe9 commit c89a75b
Show file tree
Hide file tree
Showing 12 changed files with 1,002 additions and 3 deletions.
4 changes: 4 additions & 0 deletions src/json-crdt-extensions/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ export const enum ExtensionId {
peritext = 2,
quill = 3,
}

export const enum Chars {
BlockSplitSentinel = '\n',
}
93 changes: 90 additions & 3 deletions src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import {Anchor} from './constants';
import {Anchor, SliceBehavior} from './constants';
import {Point} from './point/Point';
import {Range} from './slice/Range';
import {Editor} from './editor/Editor';
import {printTree} from '../../util/print/printTree';
import {ArrNode, StrNode} from '../../json-crdt/nodes';
import {Slices} from './slice/Slices';
import {type ITimestampStruct} from '../../json-crdt-patch/clock';
import type {Model} from '../../json-crdt/model';
import type {Printable} from '../../util/print/types';
import type {SliceType} from './types';
import type {PersistedSlice} from './slice/PersistedSlice';
import {CONST} from '../../json-hash';

export class Peritext implements Printable {
public readonly slices: Slices;
public readonly editor: Editor;

constructor(
public readonly model: Model,
public readonly str: StrNode,
slices: ArrNode,
) {}
) {
this.slices = new Slices(this, slices);
this.editor = new Editor(this);
}

public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point {
return new Point(this, id, anchor);
Expand All @@ -32,10 +44,85 @@ export class Peritext implements Printable {
return this.point(this.str.id, Anchor.Before);
}

public range(start: Point, end: Point): Range {
return new Range(this, start, end);
}

public rangeAt(start: number, length: number = 0): Range {
const str = this.str;
if (!length) {
const startId = !start ? str.id : str.find(start - 1) || str.id;
const point = this.point(startId, Anchor.After);
return this.range(point, point);
}
const startId = str.find(start) || str.id;
const endId = str.find(start + length - 1) || startId;
const startEndpoint = this.point(startId, Anchor.Before);
const endEndpoint = this.point(endId, Anchor.After);
return this.range(startEndpoint, endEndpoint);
}

public insAt(pos: number, text: string): void {
const str = this.model.api.wrap(this.str);
str.ins(pos, text);
}

public ins(after: ITimestampStruct, text: string): ITimestampStruct {
if (!text) throw new Error('NO_TEXT');
const api = this.model.api;
const textId = api.builder.insStr(this.str.id, after, text);
api.apply();
return textId;
}

public insSlice(
range: Range,
behavior: SliceBehavior,
type: SliceType,
data?: unknown | ITimestampStruct,
): PersistedSlice {
// if (range.isCollapsed()) throw new Error('INVALID_RANGE');
// TODO: If range is not collapsed, check if there are any visible characters in the range.
const slice = this.slices.ins(range, behavior, type, data);
return slice;
}

public delSlice(sliceId: ITimestampStruct): void {
this.slices.del(sliceId);
}

/** Select a single character before a point. */
public findCharBefore(point: Point): Range | undefined {
if (point.anchor === Anchor.After) {
const chunk = point.chunk();
if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point);
}
const id = point.prevId();
if (!id) return;
return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After));
}

// ---------------------------------------------------------------- Printable

public toString(tab: string = ''): string {
const nl = () => '';
return this.constructor.name + printTree(tab, [(tab) => this.str.toString(tab)]);
return (
this.constructor.name +
printTree(tab, [
(tab) => this.editor.cursor.toString(tab),
nl,
(tab) => this.str.toString(tab),
nl,
(tab) => this.slices.toString(tab),
])
);
}

// ----------------------------------------------------------------- Stateful

public hash: number = 0;

public refresh(): number {
return this.slices.refresh();
}
}
4 changes: 4 additions & 0 deletions src/json-crdt-extensions/peritext/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ export const enum Anchor {
After = 1,
}

export const enum Tags {
Cursor = 0,
}

export const enum SliceHeaderMask {
X1Anchor = 0b1,
X2Anchor = 0b10,
Expand Down
125 changes: 125 additions & 0 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {Cursor} from '../slice/Cursor';
import {Anchor, SliceBehavior} from '../constants';
import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock';
import {PersistedSlice} from '../slice/PersistedSlice';
import type {Range} from '../slice/Range';
import type {Peritext} from '../Peritext';
import type {Printable} from '../../../util/print/types';
import type {Point} from '../point/Point';
import type {SliceType} from '../types';

export class Editor implements Printable {
/**
* Cursor is the the current user selection. It can be a caret or a
* range. If range is collapsed to a single point, it is a caret.
*/
public readonly cursor: Cursor;

constructor(public readonly txt: Peritext) {
const point = txt.point(txt.str.id, Anchor.After);
const cursorId = txt.str.id; // TODO: should be autogenerated to something else
this.cursor = new Cursor(cursorId, txt, point, point.clone());
}

/** @deprecated */
public setCursor(start: number, length: number = 0): void {
this.cursor.setAt(start, length);
}

/** @deprecated */
public getCursorText(): string {
return this.cursor.text();
}

/**
* Ensures there is no range selection. If user has selected a range,
* the contents is removed and the cursor is set at the start of the range as cursor.
*
* @todo If block boundaries are withing the range, remove the blocks.
*
* @returns Returns the cursor position after the operation.
*/
public collapseSelection(): ITimestampStruct {
const cursor = this.cursor;
const isCaret = cursor.isCollapsed();
if (!isCaret) {
const {start, end} = cursor;
const txt = this.txt;
const deleteStartId = start.anchor === Anchor.Before ? start.id : start.nextId();
const deleteEndId = end.anchor === Anchor.After ? end.id : end.prevId();
const str = txt.str;
if (!deleteStartId || !deleteEndId) throw new Error('INVALID_RANGE');
const range = str.findInterval2(deleteStartId, deleteEndId);
const model = txt.model;
const api = model.api;
api.builder.del(str.id, range);
api.apply();
if (start.anchor === Anchor.After) cursor.setCaret(start.id);
else cursor.setCaret(start.prevId() || str.id);
}
return cursor.start.id;
}

/**
* Insert inline text at current cursor position. If cursor selects a range,
* the range is removed and the text is inserted at the start of the range.
*/
public insert(text: string): void {
if (!text) return;
const after = this.collapseSelection();
const textId = this.txt.ins(after, text);
this.cursor.setCaret(textId, text.length - 1);
}

/**
* Deletes the previous character at current cursor position. If cursor
* selects a range, deletes the whole range.
*/
public delete(): void {
const isCollapsed = this.cursor.isCollapsed();
if (isCollapsed) {
const range = this.txt.findCharBefore(this.cursor.start);
if (!range) return;
this.cursor.set(range.start, range.end);
}
this.collapseSelection();
}

public start(): Point | undefined {
const txt = this.txt;
const str = txt.str;
if (!str.length()) return;
const firstChunk = str.first();
if (!firstChunk) return;
const firstId = firstChunk.id;
const start = txt.point(firstId, Anchor.Before);
return start;
}

public end(): Point | undefined {
const txt = this.txt;
const str = txt.str;
if (!str.length()) return;
const lastChunk = str.last();
if (!lastChunk) return;
const lastId = lastChunk.span > 1 ? tick(lastChunk.id, lastChunk.span - 1) : lastChunk.id;
const end = txt.point(lastId, Anchor.After);
return end;
}

public all(): Range | undefined {
const start = this.start();
const end = this.end();
if (!start || !end) return;
return this.txt.range(start, end);
}

public selectAll(): void {
const range = this.all();
if (range) this.cursor.setRange(range);
}

public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
return this.txt.insSlice(this.cursor, SliceBehavior.Stack, type, data);
}
}
109 changes: 109 additions & 0 deletions src/json-crdt-extensions/peritext/slice/Cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {Point} from '../point/Point';
import {Anchor, SliceBehavior, Tags} from '../constants';
import {Range} from './Range';
import {printTree} from '../../../util/print/printTree';
import type {ITimestampStruct} from '../../../json-crdt-patch/clock';
import type {Peritext} from '../Peritext';
import type {Slice} from './types';

export class Cursor extends Range implements Slice {
public readonly behavior = SliceBehavior.Overwrite;
public readonly type = Tags.Cursor;

/**
* Specifies whether the start or the end of the cursor is the "anchor", e.g.
* the end which does not move when user changes selection. The other
* end is free to move, the moving end of the cursor is "focus". By default
* "anchor" is the start of the cursor.
*/
public base: Anchor = Anchor.Before;

constructor(
public readonly id: ITimestampStruct,
protected readonly txt: Peritext,
public start: Point,
public end: Point,
) {
super(txt, start, end);
}

public anchor(): Point {
return this.base === Anchor.Before ? this.start : this.end;
}

public focus(): Point {
return this.base === Anchor.Before ? this.end : this.start;
}

public set(start: Point, end?: Point, anchor: Anchor = Anchor.Before): void {
if (!end || end === start) end = start.clone();
super.set(start, end);
this.base = anchor;
}

public setAt(start: number, length: number = 0): void {
let at = start;
let len = length;
if (len < 0) {
at += len;
len = -len;
}
super.setAt(at, len);
this.base = length < 0 ? Anchor.After : Anchor.Before;
}

/**
* Move one of the edges of the cursor to a new point.
*
* @param point Point to set the edge to.
* @param edge 0 for "focus", 1 for "anchor."
*/
public setEdge(point: Point, edge: 0 | 1 = 0): void {
if (this.start === this.end) this.end = this.end.clone();
let anchor = this.anchor();
let focus = this.focus();
if (edge === 0) focus = point;
else anchor = point;
if (focus.compareSpatial(anchor) < 0) {
this.base = Anchor.After;
this.start = focus;
this.end = anchor;
} else {
this.base = Anchor.Before;
this.start = anchor;
this.end = focus;
}
}

/** @deprecated What is this method for? */
public del(): boolean {
return false;
}

public data(): unknown {
return 1;
}

public move(move: number): void {
const {start, end} = this;
start.move(move);
if (start === end) return;
end.move(move);
}

public toString(tab: string = ''): string {
const text = JSON.stringify(this.text());
const focusIcon = this.base === Anchor.Before ? '.⇨|' : '|⇦.';
const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`;
return main + printTree(tab, [() => text]);
}

// ----------------------------------------------------------------- Stateful

public hash: number = 0;

public refresh(): number {
// TODO: implement this ...
return this.hash;
}
}
Loading

0 comments on commit c89a75b

Please sign in to comment.