A world-class math input for the web
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();