A world-class math input for the web
at main 351 lines 9.9 kB view raw
1import { 2 CaretParser, 3 CaretNode, 4 CharToken, 5 EditorState, 6 TokensTag, 7 VNode, 8 h, 9 Transaction, 10 Cursor, 11 t, 12} from "@caret-js/core"; 13import { 14 defaultParselets, 15 FractionToken, 16 FunctionArgColorsSyntaxHighlighter, 17 FunctionDefinitionNode, 18 ItalicVariablesSyntaxHighlighter, 19 ParenthesesToken, 20 RadicalToken, 21 SubSupToken, 22 toText, 23 VariableNode, 24} from "@caret-js/math"; 25import { Strand } from "@caret-js/core"; 26 27import "./main.css"; 28 29const app = document.getElementById("app") as HTMLDivElement; 30const inputArea = document.getElementById("input-area") as HTMLDivElement; 31const tokensArea = document.getElementById("tokens-area") as HTMLDivElement; 32const parsedArea = document.getElementById("parsed-area") as HTMLDivElement; 33// const diagramArea = document.getElementById( 34// "parsed-diagram-area" 35// ) as HTMLDivElement; 36 37let editorState = new EditorState(); 38 39function updatePreview() { 40 let parseResult: CaretNode; 41 try { 42 const parser = new CaretParser( 43 editorState, 44 [], 45 [...defaultParselets({ funcNames: new Set(["f"]) })] 46 ); 47 parseResult = parser.parse(); 48 } catch (e) { 49 console.error("Error parsing document:", e); 50 return; 51 } 52 53 // Syntax highlighting 54 const syntaxHighlighters = [ 55 new ItalicVariablesSyntaxHighlighter(), 56 new FunctionArgColorsSyntaxHighlighter(), 57 ]; 58 for (const highlighter of syntaxHighlighters) { 59 highlighter.process(editorState, parseResult); 60 } 61 62 for (const [strand, strandPath] of editorState.content.traverseStrands()) { 63 for (const token of strand.tokens) { 64 let combinedStyles: Record<string, string> = {}; 65 for (const highlighter of syntaxHighlighters) { 66 const styles = highlighter.getStyles(token); 67 combinedStyles = { ...combinedStyles, ...styles }; 68 } 69 if (token instanceof CharToken) { 70 const styleString = Object.entries(combinedStyles) 71 .map(([key, value]) => `${key}: ${value}`) 72 .join("; "); 73 token.TEMPORARY_DEBUG_STYLE_STRING = styleString; 74 } 75 } 76 } 77 78 inputArea.innerHTML = ""; 79 inputArea.appendChild(renderVNode(editorState.renderToDebugHTML())); 80 // Token-based MathML 81 // inputArea.appendChild( 82 // renderVNode( 83 // h("div", {}, t("Token-based MathML:"), editorState.renderToDebugMathML()) 84 // ) 85 // ); 86 // Node-based MathML 87 // inputArea.appendChild( 88 // renderVNode( 89 // h( 90 // "div", 91 // {}, 92 // t("Node-based MathML:"), 93 // h("math", {}, parseResult.toDebugMathML()) 94 // ) 95 // ) 96 // ); 97 // Math to text 98 // const text = toText(parseResult); 99 // inputArea.appendChild( 100 // renderVNode(h("div", {}, t("Math to text: "), t(text ?? ""))) 101 // ); 102 // document.getElementById("math-to-text-readout")!.innerHTML = text ?? ""; 103 // document.getElementById("math-to-text-readout")!.innerHTML = 104 // text && parseResult instanceof CaretNode 105 // ? `${text} or in MathML ${renderVNodeToHTMLString( 106 // h("math", {}, parseResult.toDebugMathML()) 107 // )}` 108 // : ""; 109 110 tokensArea.innerHTML = ` 111 <!--<pre>${JSON.stringify(editorState.selection)}</pre>--> 112 <pre>${editorState.renderToDebugText()}</pre> 113 `; 114 parsedArea.innerHTML = `<pre>${parseResult.toDebugString()}</pre>`; 115 // diagramArea.innerHTML = `<div>${parseResult.toDebugHTML()}<div>`; 116} 117 118function renderVNode(node: VNode): Node { 119 if ("text" in node) { 120 return document.createTextNode((node as any).text); 121 } 122 123 const elem = node.namespaceURI 124 ? document.createElementNS(node.namespaceURI, node.type) 125 : document.createElement(node.type); 126 127 for (const [key, value] of Object.entries(node.attributes)) { 128 elem.setAttribute(key, value); 129 } 130 for (const child of node.children) { 131 elem.appendChild(renderVNode(child)); 132 } 133 134 return elem; 135} 136 137function renderVNodeToHTMLString(node: VNode): string { 138 if ("text" in node) { 139 return (node as any).text; 140 } 141 142 const attrs = Object.entries(node.attributes) 143 .map(([key, value]) => `${key}="${value}"`) 144 .join(" "); 145 const children = node.children 146 .map((child) => renderVNodeToHTMLString(child)) 147 .join(""); 148 149 return `<${node.type}${attrs ? " " + attrs : ""}>${children}</${node.type}>`; 150} 151 152document.addEventListener("keydown", (event) => { 153 if (event.key === "ArrowRight") { 154 event.preventDefault(); 155 editorState = Transaction.moveCursor( 156 editorState, 157 "right", 158 event.shiftKey 159 ).newState; 160 updatePreview(); 161 return; 162 } 163 if (event.key === "ArrowLeft") { 164 event.preventDefault(); 165 editorState = Transaction.moveCursor( 166 editorState, 167 "left", 168 event.shiftKey 169 ).newState; 170 updatePreview(); 171 return; 172 } 173 174 if (event.key === "Backspace") { 175 event.preventDefault(); 176 editorState = Transaction.deleteAtSelection( 177 editorState, 178 "backward" 179 ).newState; 180 updatePreview(); 181 return; 182 } 183 if (event.key === "Delete") { 184 event.preventDefault(); 185 editorState = Transaction.deleteAtSelection( 186 editorState, 187 "forward" 188 ).newState; 189 updatePreview(); 190 return; 191 } 192 if (event.key === "a" && (event.ctrlKey || event.metaKey)) { 193 event.preventDefault(); 194 const transaction = Transaction.selectAll(editorState); 195 editorState = transaction.newState; 196 updatePreview(); 197 return; 198 } 199 200 const typeableChars = 201 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-*=.,".split( 202 "" 203 ); 204 205 if (typeableChars.includes(event.key) && !event.ctrlKey && !event.metaKey) { 206 event.preventDefault(); 207 // Check for "sqrt" completion 208 if (event.key === "t") { 209 if (editorState.selection.isCollapsed()) { 210 const cursor = editorState.selection.head; 211 const strand = editorState.content.getStrand(cursor.strandPath); 212 if (strand) { 213 const priorThreeTokens = strand.tokens 214 .slice(Math.max(0, cursor.pos - 3), cursor.pos) 215 .map((t) => (t instanceof CharToken ? t.char : "")) 216 .join(""); 217 if (priorThreeTokens === "sqr") { 218 // Replace "sqr" with a square root token 219 const transaction = new Transaction( 220 editorState, 221 [ 222 { 223 strandPath: cursor.strandPath, 224 fromPos: cursor.pos - 3, 225 toPos: cursor.pos, 226 newTokens: [new RadicalToken(null, new Strand([]))], 227 }, 228 ], 229 new Cursor( 230 [ 231 ...cursor.strandPath, 232 { tokenIndex: cursor.pos - 3, childIndex: 0 }, 233 ], 234 0 235 ) 236 ); 237 238 editorState = transaction.newState; 239 updatePreview(); 240 return; 241 } 242 } 243 } 244 } 245 246 // Check for "cbrt" completion 247 if (event.key === "t") { 248 if (editorState.selection.isCollapsed()) { 249 const cursor = editorState.selection.head; 250 const strand = editorState.content.getStrand(cursor.strandPath); 251 if (strand) { 252 const priorThreeTokens = strand.tokens 253 .slice(Math.max(0, cursor.pos - 3), cursor.pos) 254 .map((t) => (t instanceof CharToken ? t.char : "")) 255 .join(""); 256 if (priorThreeTokens === "cbr") { 257 // Replace "cbr" with a cube root token 258 const transaction = new Transaction( 259 editorState, 260 [ 261 { 262 strandPath: cursor.strandPath, 263 fromPos: cursor.pos - 3, 264 toPos: cursor.pos, 265 newTokens: [ 266 new RadicalToken( 267 new Strand([new CharToken("3")]), 268 new Strand([]) 269 ), 270 ], 271 }, 272 ], 273 new Cursor( 274 [ 275 ...cursor.strandPath, 276 { tokenIndex: cursor.pos - 3, childIndex: 1 }, 277 ], 278 0 279 ) 280 ); 281 282 editorState = transaction.newState; 283 updatePreview(); 284 return; 285 } 286 } 287 } 288 } 289 290 editorState = Transaction.insertAtSelection(editorState, [ 291 new CharToken(event.key), 292 ]).newState; 293 updatePreview(); 294 return; 295 } 296 297 if (event.key === "/" && !event.ctrlKey && !event.metaKey) { 298 event.preventDefault(); 299 editorState = Transaction.insertAtSelection(editorState, [ 300 new FractionToken(new Strand([]), new Strand([])), 301 ]).setNewSelection( 302 new Cursor( 303 [ 304 ...editorState.selection.commonStrandPath, 305 { tokenIndex: editorState.selection.commonStartPos, childIndex: 0 }, 306 ], 307 0 308 ) 309 ).newState; 310 updatePreview(); 311 return; 312 } 313 314 if (event.key === "(" && !event.ctrlKey && !event.metaKey) { 315 event.preventDefault(); 316 editorState = Transaction.insertAtSelection(editorState, [ 317 new ParenthesesToken(new Strand([])), 318 ]).setNewSelection( 319 new Cursor( 320 [ 321 ...editorState.selection.head.strandPath, 322 { tokenIndex: editorState.selection.head.pos, childIndex: 0 }, 323 ], 324 0 325 ) 326 ).newState; 327 updatePreview(); 328 return; 329 } 330 331 if (event.key === "^" && !event.ctrlKey && !event.metaKey) { 332 event.preventDefault(); 333 editorState = Transaction.insertAtSelection( 334 editorState, 335 ({ setCursor }) => [new SubSupToken(null, new Strand([setCursor()]))] 336 ).setNewSelection(new Cursor()).newState; 337 updatePreview(); 338 return; 339 } 340 if (event.key === "_" && !event.ctrlKey && !event.metaKey) { 341 event.preventDefault(); 342 editorState = Transaction.insertAtSelection( 343 editorState, 344 ({ setCursor }) => [new SubSupToken(new Strand([setCursor()]), null)] 345 ).newState; 346 updatePreview(); 347 return; 348 } 349}); 350 351updatePreview();