A world-class math input for the web
at main 234 lines 6.8 kB view raw
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}