Skip to content

Commit 87483ee

Browse files
committed
Merge branch 'master' into next
2 parents 30e79c9 + c89a75b commit 87483ee

File tree

12 files changed

+1002
-3
lines changed

12 files changed

+1002
-3
lines changed

src/json-crdt-extensions/constants.ts

+4
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ export const enum ExtensionId {
44
peritext = 2,
55
quill = 3,
66
}
7+
8+
export const enum Chars {
9+
BlockSplitSentinel = '\n',
10+
}

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

+90-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1-
import {Anchor} from './constants';
1+
import {Anchor, SliceBehavior} from './constants';
22
import {Point} from './point/Point';
3+
import {Range} from './slice/Range';
4+
import {Editor} from './editor/Editor';
35
import {printTree} from '../../util/print/printTree';
46
import {ArrNode, StrNode} from '../../json-crdt/nodes';
7+
import {Slices} from './slice/Slices';
58
import {type ITimestampStruct} from '../../json-crdt-patch/clock';
69
import type {Model} from '../../json-crdt/model';
710
import type {Printable} from '../../util/print/types';
11+
import type {SliceType} from './types';
12+
import type {PersistedSlice} from './slice/PersistedSlice';
13+
import {CONST} from '../../json-hash';
814

915
export class Peritext implements Printable {
16+
public readonly slices: Slices;
17+
public readonly editor: Editor;
18+
1019
constructor(
1120
public readonly model: Model,
1221
public readonly str: StrNode,
1322
slices: ArrNode,
14-
) {}
23+
) {
24+
this.slices = new Slices(this, slices);
25+
this.editor = new Editor(this);
26+
}
1527

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

47+
public range(start: Point, end: Point): Range {
48+
return new Range(this, start, end);
49+
}
50+
51+
public rangeAt(start: number, length: number = 0): Range {
52+
const str = this.str;
53+
if (!length) {
54+
const startId = !start ? str.id : str.find(start - 1) || str.id;
55+
const point = this.point(startId, Anchor.After);
56+
return this.range(point, point);
57+
}
58+
const startId = str.find(start) || str.id;
59+
const endId = str.find(start + length - 1) || startId;
60+
const startEndpoint = this.point(startId, Anchor.Before);
61+
const endEndpoint = this.point(endId, Anchor.After);
62+
return this.range(startEndpoint, endEndpoint);
63+
}
64+
65+
public insAt(pos: number, text: string): void {
66+
const str = this.model.api.wrap(this.str);
67+
str.ins(pos, text);
68+
}
69+
70+
public ins(after: ITimestampStruct, text: string): ITimestampStruct {
71+
if (!text) throw new Error('NO_TEXT');
72+
const api = this.model.api;
73+
const textId = api.builder.insStr(this.str.id, after, text);
74+
api.apply();
75+
return textId;
76+
}
77+
78+
public insSlice(
79+
range: Range,
80+
behavior: SliceBehavior,
81+
type: SliceType,
82+
data?: unknown | ITimestampStruct,
83+
): PersistedSlice {
84+
// if (range.isCollapsed()) throw new Error('INVALID_RANGE');
85+
// TODO: If range is not collapsed, check if there are any visible characters in the range.
86+
const slice = this.slices.ins(range, behavior, type, data);
87+
return slice;
88+
}
89+
90+
public delSlice(sliceId: ITimestampStruct): void {
91+
this.slices.del(sliceId);
92+
}
93+
94+
/** Select a single character before a point. */
95+
public findCharBefore(point: Point): Range | undefined {
96+
if (point.anchor === Anchor.After) {
97+
const chunk = point.chunk();
98+
if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point);
99+
}
100+
const id = point.prevId();
101+
if (!id) return;
102+
return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After));
103+
}
104+
35105
// ---------------------------------------------------------------- Printable
36106

37107
public toString(tab: string = ''): string {
38108
const nl = () => '';
39-
return this.constructor.name + printTree(tab, [(tab) => this.str.toString(tab)]);
109+
return (
110+
this.constructor.name +
111+
printTree(tab, [
112+
(tab) => this.editor.cursor.toString(tab),
113+
nl,
114+
(tab) => this.str.toString(tab),
115+
nl,
116+
(tab) => this.slices.toString(tab),
117+
])
118+
);
119+
}
120+
121+
// ----------------------------------------------------------------- Stateful
122+
123+
public hash: number = 0;
124+
125+
public refresh(): number {
126+
return this.slices.refresh();
40127
}
41128
}

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

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ export const enum Anchor {
33
After = 1,
44
}
55

6+
export const enum Tags {
7+
Cursor = 0,
8+
}
9+
610
export const enum SliceHeaderMask {
711
X1Anchor = 0b1,
812
X2Anchor = 0b10,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {Cursor} from '../slice/Cursor';
2+
import {Anchor, SliceBehavior} from '../constants';
3+
import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock';
4+
import {PersistedSlice} from '../slice/PersistedSlice';
5+
import type {Range} from '../slice/Range';
6+
import type {Peritext} from '../Peritext';
7+
import type {Printable} from '../../../util/print/types';
8+
import type {Point} from '../point/Point';
9+
import type {SliceType} from '../types';
10+
11+
export class Editor implements Printable {
12+
/**
13+
* Cursor is the the current user selection. It can be a caret or a
14+
* range. If range is collapsed to a single point, it is a caret.
15+
*/
16+
public readonly cursor: Cursor;
17+
18+
constructor(public readonly txt: Peritext) {
19+
const point = txt.point(txt.str.id, Anchor.After);
20+
const cursorId = txt.str.id; // TODO: should be autogenerated to something else
21+
this.cursor = new Cursor(cursorId, txt, point, point.clone());
22+
}
23+
24+
/** @deprecated */
25+
public setCursor(start: number, length: number = 0): void {
26+
this.cursor.setAt(start, length);
27+
}
28+
29+
/** @deprecated */
30+
public getCursorText(): string {
31+
return this.cursor.text();
32+
}
33+
34+
/**
35+
* Ensures there is no range selection. If user has selected a range,
36+
* the contents is removed and the cursor is set at the start of the range as cursor.
37+
*
38+
* @todo If block boundaries are withing the range, remove the blocks.
39+
*
40+
* @returns Returns the cursor position after the operation.
41+
*/
42+
public collapseSelection(): ITimestampStruct {
43+
const cursor = this.cursor;
44+
const isCaret = cursor.isCollapsed();
45+
if (!isCaret) {
46+
const {start, end} = cursor;
47+
const txt = this.txt;
48+
const deleteStartId = start.anchor === Anchor.Before ? start.id : start.nextId();
49+
const deleteEndId = end.anchor === Anchor.After ? end.id : end.prevId();
50+
const str = txt.str;
51+
if (!deleteStartId || !deleteEndId) throw new Error('INVALID_RANGE');
52+
const range = str.findInterval2(deleteStartId, deleteEndId);
53+
const model = txt.model;
54+
const api = model.api;
55+
api.builder.del(str.id, range);
56+
api.apply();
57+
if (start.anchor === Anchor.After) cursor.setCaret(start.id);
58+
else cursor.setCaret(start.prevId() || str.id);
59+
}
60+
return cursor.start.id;
61+
}
62+
63+
/**
64+
* Insert inline text at current cursor position. If cursor selects a range,
65+
* the range is removed and the text is inserted at the start of the range.
66+
*/
67+
public insert(text: string): void {
68+
if (!text) return;
69+
const after = this.collapseSelection();
70+
const textId = this.txt.ins(after, text);
71+
this.cursor.setCaret(textId, text.length - 1);
72+
}
73+
74+
/**
75+
* Deletes the previous character at current cursor position. If cursor
76+
* selects a range, deletes the whole range.
77+
*/
78+
public delete(): void {
79+
const isCollapsed = this.cursor.isCollapsed();
80+
if (isCollapsed) {
81+
const range = this.txt.findCharBefore(this.cursor.start);
82+
if (!range) return;
83+
this.cursor.set(range.start, range.end);
84+
}
85+
this.collapseSelection();
86+
}
87+
88+
public start(): Point | undefined {
89+
const txt = this.txt;
90+
const str = txt.str;
91+
if (!str.length()) return;
92+
const firstChunk = str.first();
93+
if (!firstChunk) return;
94+
const firstId = firstChunk.id;
95+
const start = txt.point(firstId, Anchor.Before);
96+
return start;
97+
}
98+
99+
public end(): Point | undefined {
100+
const txt = this.txt;
101+
const str = txt.str;
102+
if (!str.length()) return;
103+
const lastChunk = str.last();
104+
if (!lastChunk) return;
105+
const lastId = lastChunk.span > 1 ? tick(lastChunk.id, lastChunk.span - 1) : lastChunk.id;
106+
const end = txt.point(lastId, Anchor.After);
107+
return end;
108+
}
109+
110+
public all(): Range | undefined {
111+
const start = this.start();
112+
const end = this.end();
113+
if (!start || !end) return;
114+
return this.txt.range(start, end);
115+
}
116+
117+
public selectAll(): void {
118+
const range = this.all();
119+
if (range) this.cursor.setRange(range);
120+
}
121+
122+
public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
123+
return this.txt.insSlice(this.cursor, SliceBehavior.Stack, type, data);
124+
}
125+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {Point} from '../point/Point';
2+
import {Anchor, SliceBehavior, Tags} from '../constants';
3+
import {Range} from './Range';
4+
import {printTree} from '../../../util/print/printTree';
5+
import type {ITimestampStruct} from '../../../json-crdt-patch/clock';
6+
import type {Peritext} from '../Peritext';
7+
import type {Slice} from './types';
8+
9+
export class Cursor extends Range implements Slice {
10+
public readonly behavior = SliceBehavior.Overwrite;
11+
public readonly type = Tags.Cursor;
12+
13+
/**
14+
* Specifies whether the start or the end of the cursor is the "anchor", e.g.
15+
* the end which does not move when user changes selection. The other
16+
* end is free to move, the moving end of the cursor is "focus". By default
17+
* "anchor" is the start of the cursor.
18+
*/
19+
public base: Anchor = Anchor.Before;
20+
21+
constructor(
22+
public readonly id: ITimestampStruct,
23+
protected readonly txt: Peritext,
24+
public start: Point,
25+
public end: Point,
26+
) {
27+
super(txt, start, end);
28+
}
29+
30+
public anchor(): Point {
31+
return this.base === Anchor.Before ? this.start : this.end;
32+
}
33+
34+
public focus(): Point {
35+
return this.base === Anchor.Before ? this.end : this.start;
36+
}
37+
38+
public set(start: Point, end?: Point, anchor: Anchor = Anchor.Before): void {
39+
if (!end || end === start) end = start.clone();
40+
super.set(start, end);
41+
this.base = anchor;
42+
}
43+
44+
public setAt(start: number, length: number = 0): void {
45+
let at = start;
46+
let len = length;
47+
if (len < 0) {
48+
at += len;
49+
len = -len;
50+
}
51+
super.setAt(at, len);
52+
this.base = length < 0 ? Anchor.After : Anchor.Before;
53+
}
54+
55+
/**
56+
* Move one of the edges of the cursor to a new point.
57+
*
58+
* @param point Point to set the edge to.
59+
* @param edge 0 for "focus", 1 for "anchor."
60+
*/
61+
public setEdge(point: Point, edge: 0 | 1 = 0): void {
62+
if (this.start === this.end) this.end = this.end.clone();
63+
let anchor = this.anchor();
64+
let focus = this.focus();
65+
if (edge === 0) focus = point;
66+
else anchor = point;
67+
if (focus.compareSpatial(anchor) < 0) {
68+
this.base = Anchor.After;
69+
this.start = focus;
70+
this.end = anchor;
71+
} else {
72+
this.base = Anchor.Before;
73+
this.start = anchor;
74+
this.end = focus;
75+
}
76+
}
77+
78+
/** @deprecated What is this method for? */
79+
public del(): boolean {
80+
return false;
81+
}
82+
83+
public data(): unknown {
84+
return 1;
85+
}
86+
87+
public move(move: number): void {
88+
const {start, end} = this;
89+
start.move(move);
90+
if (start === end) return;
91+
end.move(move);
92+
}
93+
94+
public toString(tab: string = ''): string {
95+
const text = JSON.stringify(this.text());
96+
const focusIcon = this.base === Anchor.Before ? '.⇨|' : '|⇦.';
97+
const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`;
98+
return main + printTree(tab, [() => text]);
99+
}
100+
101+
// ----------------------------------------------------------------- Stateful
102+
103+
public hash: number = 0;
104+
105+
public refresh(): number {
106+
// TODO: implement this ...
107+
return this.hash;
108+
}
109+
}

0 commit comments

Comments
 (0)