A world-class math input for the web
1import { EditorState } from "./editorState";
2import { StrandPath, strandPathsEqual, TokenPath } from "./path";
3import type { Token } from "./token";
4import { indent } from "./utils/indent";
5import { h, t, VNode } from "./vdom";
6
7export class Strand {
8 public readonly tokens: readonly Token[];
9
10 private _parent: Token | EditorState | undefined;
11 set parent(newParent: Token | EditorState) {
12 if (this._parent !== undefined) {
13 throw new Error("Parent is already set");
14 }
15 this._parent = newParent;
16 }
17 get parent(): Token | EditorState | undefined {
18 return this._parent;
19 }
20
21 get editorState(): EditorState | undefined {
22 if (this._parent instanceof EditorState) {
23 return this._parent;
24 }
25 return this._parent?.editorState;
26 }
27
28 constructor(tokens: readonly Token[]) {
29 this.tokens = tokens;
30
31 for (const token of this.tokens) {
32 token.parentStrand = this;
33 }
34 }
35
36 getStrand(path: StrandPath): Strand | null {
37 if (path.length === 0) return this;
38
39 const [level, ...restOfPath] = path;
40 const token = this.tokens[level.tokenIndex];
41 if (!token) return null;
42
43 const childStrand = token.children[level.childIndex];
44 if (!childStrand) return null;
45
46 return childStrand.getStrand(restOfPath);
47 }
48
49 getToken(path: TokenPath): Token | null {
50 const strand = this.getStrand(path.strandPath);
51 if (!strand) return null;
52
53 return strand.tokens[path.tokenIndex] ?? null;
54 }
55
56 findStrandPath(targetStrand: Strand): StrandPath | null {
57 const path: StrandPath = [];
58 let found = false;
59
60 const searchStrand = (currentStrand: Strand, currentPath: StrandPath) => {
61 if (currentStrand === targetStrand) {
62 path.push(...currentPath);
63 found = true;
64 return;
65 }
66
67 for (let i = 0; i < currentStrand.tokens.length; i++) {
68 const currentToken = currentStrand.tokens[i];
69 for (let j = 0; j < currentToken.children.length; j++) {
70 const childStrand = currentToken.children[j];
71 searchStrand(childStrand, [
72 ...currentPath,
73 { tokenIndex: i, childIndex: j },
74 ]);
75 if (found) return;
76 }
77 }
78 };
79
80 searchStrand(this, []);
81 return found ? path : null;
82 }
83
84 findTokenPath(token: Token): TokenPath | null {
85 const path: StrandPath = [];
86 let found = false;
87
88 const searchStrand = (currentStrand: Strand, currentPath: StrandPath) => {
89 for (let i = 0; i < currentStrand.tokens.length; i++) {
90 const currentToken = currentStrand.tokens[i];
91 if (currentToken === token) {
92 path.push(...currentPath, { tokenIndex: i, childIndex: 0 });
93 found = true;
94 return;
95 }
96
97 for (let j = 0; j < currentToken.children.length; j++) {
98 const childStrand = currentToken.children[j];
99 searchStrand(childStrand, [
100 ...currentPath,
101 { tokenIndex: i, childIndex: j },
102 ]);
103 if (found) return;
104 }
105 }
106 };
107
108 searchStrand(this, []);
109
110 if (found) {
111 const lastLevel = path.pop()!;
112 return { strandPath: path, tokenIndex: lastLevel.tokenIndex };
113 } else {
114 return null;
115 }
116 }
117
118 splice(start: number, deleteCount: number, ...newTokens: Token[]): Strand {
119 const beforeTokens = this.tokens.slice(0, start);
120 const afterTokens = this.tokens.slice(start + deleteCount);
121 return new Strand([...beforeTokens, ...newTokens, ...afterTokens]);
122 }
123
124 *traverseStrands(
125 startingStrandPath: StrandPath = []
126 ): Generator<[Strand, StrandPath]> {
127 yield [this, startingStrandPath];
128
129 for (let i = 0; i < this.tokens.length; i++) {
130 const token = this.tokens[i];
131 for (let j = 0; j < token.children.length; j++) {
132 const childStrand = token.children[j];
133 yield* childStrand.traverseStrands([
134 ...startingStrandPath,
135 { tokenIndex: i, childIndex: j },
136 ]);
137 }
138 }
139 }
140
141 /**
142 * @deprecated This function is only for debugging purposes. Actual rendering of tokens should be done via a separate rendering system.
143 */
144 renderToDebugText(): string {
145 const headPosition = this.editorState
146 ? this.editorState.getPositionOfCursorInStrand(
147 this.editorState.content.findStrandPath(this)!,
148 this.editorState.selection.head
149 )
150 : null;
151
152 const anchorPosition = this.editorState
153 ? this.editorState.getPositionOfCursorInStrand(
154 this.editorState.content.findStrandPath(this)!,
155 this.editorState.selection.anchor
156 )
157 : null;
158
159 const drawCursor = (index: number): string => {
160 if (headPosition === index) {
161 return "▮";
162 }
163 if (anchorPosition === index) {
164 return "▯";
165 }
166 return "";
167 };
168
169 return `[\n${indent(
170 this.tokens
171 .map((token) => token.renderToDebugText())
172 .map((str, index) => `${drawCursor(index)}${str}`)
173 .join(",\n") + drawCursor(this.tokens.length)
174 )}\n]`;
175 }
176
177 /**
178 * @deprecated This function is only for debugging purposes. Actual rendering of tokens should be done via a separate rendering system.
179 */
180 renderToDebugHTML(): VNode {
181 const { commonStrandPath, commonStartPos, commonEndPos } =
182 this.editorState!.selection;
183
184 const headPosition = this.editorState
185 ? this.editorState.getPositionOfCursorInStrand(
186 this.editorState.content.findStrandPath(this)!,
187 this.editorState.selection.head
188 )
189 : null;
190
191 const cursorSpot = (index: number) => {
192 if (headPosition === index) {
193 return h("span", { class: "cursor" }, t("\u{200B}"));
194 }
195 return null;
196 };
197
198 const thisPath = this.editorState?.content.findStrandPath(this);
199 const isCommonStrand: boolean =
200 !!thisPath && strandPathsEqual(thisPath, commonStrandPath);
201 const isSelected = (index: number): boolean => {
202 if (!isCommonStrand) return false;
203 return index >= commonStartPos && index < commonEndPos;
204 };
205
206 return h(
207 "span",
208 { class: "strand" },
209 ...this.tokens.flatMap((token, index) => {
210 let renderedToken = token.renderToDebugHTML();
211 if (isSelected(index)) {
212 renderedToken = h("span", { class: "selected" }, renderedToken);
213 }
214 return [cursorSpot(index), renderedToken];
215 }),
216 cursorSpot(this.tokens.length)
217 );
218 }
219
220 /**
221 * @deprecated This function is only for debugging purposes. Actual rendering of tokens should be done via a separate rendering system.
222 */
223 renderToDebugMathML(): VNode {
224 if (this.tokens.length === 1) {
225 return this.tokens[0].renderToDebugMathML();
226 }
227
228 return h(
229 "mrow",
230 {},
231 ...this.tokens.map((token) => token.renderToDebugMathML())
232 );
233 }
234}