A world-class math input for the web
1import type { EditorState } from "./editorState";
2import {
3 StrandPath,
4 strandPathsEqual,
5 strandPathIsAncestor,
6 TokenPath,
7} from "./path";
8
9export type RawCursor = {
10 strandPath: StrandPath;
11 pos: number;
12};
13
14export class Cursor {
15 strandPath: StrandPath;
16 pos: number;
17
18 constructor(strandPath: StrandPath = [], pos: number = 0) {
19 this.strandPath = strandPath;
20 this.pos = pos;
21 }
22
23 equals(other: Cursor): boolean {
24 return (
25 this.pos === other.pos &&
26 strandPathsEqual(this.strandPath, other.strandPath)
27 );
28 }
29
30 toRaw(): RawCursor {
31 return {
32 strandPath: this.strandPath,
33 pos: this.pos,
34 };
35 }
36
37 static fromRaw(raw: RawCursor): Cursor {
38 return new Cursor(raw.strandPath, raw.pos);
39 }
40
41 clone(): Cursor {
42 return new Cursor(
43 this.strandPath.map((level) => ({ ...level })),
44 this.pos
45 );
46 }
47
48 moveRight(editorState: EditorState) {
49 let currentStrand = editorState.content;
50 for (const level of this.strandPath) {
51 const token = currentStrand.tokens[level.tokenIndex];
52 currentStrand = token.children[level.childIndex];
53 }
54
55 // If the next token has children, move into its first child strand
56 const nextToken = currentStrand.tokens[this.pos];
57 if (nextToken && nextToken.children.length > 0) {
58 this.strandPath.push({
59 tokenIndex: this.pos,
60 childIndex: 0,
61 });
62 this.pos = 0;
63 return;
64 }
65
66 if (this.pos < currentStrand.tokens.length) {
67 this.pos += 1;
68 return;
69 }
70
71 // We are at the end of the current strand. Try to move into a sibling strand or up to the parent.
72 let parentStrand = editorState.content;
73 for (let i = 0; i < this.strandPath.length - 1; i++) {
74 const level = this.strandPath[i];
75 const token = parentStrand.tokens[level.tokenIndex];
76 parentStrand = token.children[level.childIndex];
77 }
78
79 const lastStrandPathPart = this.strandPath[this.strandPath.length - 1];
80 if (!lastStrandPathPart) {
81 // Already at the top-level strand; cannot move right
82 return;
83 }
84
85 const { tokenIndex, childIndex } = lastStrandPathPart;
86 const parentToken = parentStrand.tokens[tokenIndex];
87 const parentChildIndex = childIndex;
88
89 if (parentChildIndex + 1 < parentToken.children.length) {
90 // Move to the next sibling child strand
91 this.strandPath[this.strandPath.length - 1].childIndex += 1;
92 this.pos = 0;
93 return;
94 } else {
95 // Move up to the parent strand
96 this.strandPath.pop();
97 this.pos = tokenIndex + 1; // Move to the position after the parent token
98 return;
99 }
100 }
101
102 moveLeft(editorState: EditorState) {
103 let currentStrand = editorState.content;
104 for (const level of this.strandPath) {
105 const token = currentStrand.tokens[level.tokenIndex];
106 currentStrand = token.children[level.childIndex];
107 }
108
109 // If the previous token has children, move into its last child strand
110 if (this.pos > 0) {
111 const prevToken = currentStrand.tokens[this.pos - 1];
112 if (prevToken && prevToken.children.length > 0) {
113 this.strandPath.push({
114 tokenIndex: this.pos - 1,
115 childIndex: prevToken.children.length - 1,
116 });
117 const lastChildStrand =
118 prevToken.children[prevToken.children.length - 1];
119 this.pos = lastChildStrand.tokens.length;
120 return;
121 }
122 }
123
124 if (this.pos > 0) {
125 this.pos -= 1;
126 return;
127 }
128
129 // We are at the start of the current strand. Try to move into a sibling strand or up to the parent.
130 let parentStrand = editorState.content;
131 for (let i = 0; i < this.strandPath.length - 1; i++) {
132 const level = this.strandPath[i];
133 const token = parentStrand.tokens[level.tokenIndex];
134 parentStrand = token.children[level.childIndex];
135 }
136
137 const lastStrandPathPart = this.strandPath[this.strandPath.length - 1];
138 if (!lastStrandPathPart) {
139 // Already at the top-level strand; cannot move left
140 return;
141 }
142
143 const { tokenIndex, childIndex } = lastStrandPathPart;
144 const parentToken = parentStrand.tokens[tokenIndex];
145 const parentChildIndex = childIndex;
146
147 if (parentChildIndex > 0) {
148 // Move to the previous sibling child strand
149 this.strandPath[this.strandPath.length - 1].childIndex -= 1;
150 const siblingStrand =
151 parentToken.children[
152 this.strandPath[this.strandPath.length - 1].childIndex
153 ];
154 this.pos = siblingStrand.tokens.length;
155 return;
156 } else {
157 // Move up to the parent strand
158 this.strandPath.pop();
159 this.pos = tokenIndex; // Move to the position before the parent token
160 return;
161 }
162 }
163
164 /**
165 * Adjust the cursor to account for an insertion at a given location.
166 * @param insertCursor The location where the insertion occurred (referencing a cursor in the old document before insertion).
167 * @param insertCount The number of tokens inserted.
168 * @returns void The cursor is updated in place.
169 */
170 updateAfterInsertion(insertCursor: Cursor, insertCount: number) {
171 if (strandPathsEqual(this.strandPath, insertCursor.strandPath)) {
172 if (insertCursor.pos <= this.pos) {
173 this.pos += insertCount;
174 }
175 return;
176 }
177
178 if (strandPathIsAncestor(insertCursor.strandPath, this.strandPath)) {
179 const relativeLevel = this.strandPath[insertCursor.strandPath.length];
180 if (relativeLevel.tokenIndex >= insertCursor.pos) {
181 relativeLevel.tokenIndex += insertCount;
182 }
183 return;
184 }
185 }
186
187 /**
188 * Adjust the cursor to account for a token deletion at a given path.
189 * @param deletedTokenPath The path of the token that was deleted.
190 * @returns void The cursor is updated in place.
191 */
192 updateAfterTokenDeletion(deletedTokenPath: TokenPath) {
193 if (strandPathsEqual(this.strandPath, deletedTokenPath.strandPath)) {
194 if (deletedTokenPath.tokenIndex < this.pos) {
195 this.pos -= 1;
196 }
197 return;
198 }
199
200 if (strandPathIsAncestor(deletedTokenPath.strandPath, this.strandPath)) {
201 const relativeLevel = this.strandPath[deletedTokenPath.strandPath.length];
202 if (relativeLevel.tokenIndex > deletedTokenPath.tokenIndex) {
203 relativeLevel.tokenIndex -= 1;
204 } else if (relativeLevel.tokenIndex === deletedTokenPath.tokenIndex) {
205 this.strandPath = this.strandPath.slice(
206 0,
207 deletedTokenPath.strandPath.length
208 );
209 this.pos = deletedTokenPath.tokenIndex;
210 }
211 return;
212 }
213 }
214}