@zzish/math-rich-input 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ import React from 'react'
2
+ import { addStyles, EditableMathField } from 'react-mathquill'
3
+ import SymbolButton from './SymbolButton'
4
+ import { getSymbolsForButtonArea, getLatexInsertionParams} from './equationEditorButtonsHelper.js';
5
+
6
+ import './EquationEditor.css';
7
+
8
+ // Katex setup
9
+ import katex from "katex";
10
+ const MARK = "_M_A_R_K_"
11
+
12
+ // MathQuill setup
13
+ addStyles()
14
+ const AUTO_OPERATOR_NAMES = 'sin cos tan sec cosec cot log ln lim min max'
15
+
16
+
17
+ // Recent button bar
18
+ const RECENT_WEIGHT = 15
19
+ const RECENT_DECAY_FACTOR = 0.95
20
+ const RECENT_MAX = 11
21
+ let GLOBAL_recentSymbols = []
22
+ let GLOBAL_lastSymbols = ["","",""]
23
+
24
+ // Genreal constants
25
+ const MAX_BUTTON_ROWS = 5
26
+ const TAB_NAMES = ["Common", "More", "Greek", "Chemistry", "Physics"]
27
+ const BUTTON_AREA_NAMES = ["Common", "Operators", "Angles",
28
+ "Misc", "Sets",
29
+ "GreekLower", "GreekUpper",
30
+ "Chemistry","Elements",
31
+ "Physics1", "Physics2", "Physics3"]
32
+ const TAB_NAMES_EXPERT = ["Common", "More", "Greek", "Expert"]
33
+ const BUTTON_AREA_NAMES_EXPERT = ["Common", "Operators", "Angles",
34
+ "Misc", "Sets",
35
+ "GreekLower", "GreekUpper",
36
+ "Expert","Matrices"]
37
+ const TAB_STARTS_AT_AREA_NAMED = {"Common":"Common",
38
+ "More":"Misc",
39
+ "Greek":"GreekLower",
40
+ "Chemistry":"Chemistry",
41
+ "Physics":"Physics1",
42
+ "Expert":"Expert"}
43
+
44
+ let GLOBAL_buttonAreasWidths = null;
45
+ let GLOBAL_buttonAreasWidthsExpert = null;
46
+ let GLOBAL_last_selected_tab = TAB_NAMES[0]
47
+
48
+ export default class EquationEditor extends React.Component {
49
+
50
+ constructor(props) {
51
+ super(props);
52
+ this.state = {
53
+ latex: props.latex,
54
+ pretty: false,
55
+ selectedTab: GLOBAL_last_selected_tab,
56
+ recentSymbolsCount: 0,
57
+ };
58
+
59
+ this.mathQuill = null
60
+ this.handleInsertCallback = props.handleInsert
61
+ this.handleCancelCallback = props.handleCancel
62
+ }
63
+
64
+ handleInsert = () => {
65
+ this.handleInsertCallback(this.state.latex)
66
+ }
67
+
68
+ handleCancel = () => {
69
+ this.handleCancelCallback()
70
+ }
71
+
72
+ prettifyLatex(latex) {
73
+ // NB: replaceAll not suported on safari
74
+ // console.log("Before: "+latex)
75
+ // latex = latex.replaceAll(/\\\\/g,"_DOOUBLE_BCACKSLASH_")
76
+ // latex = latex.replace(/\\/g," \\")
77
+ // latex = latex.replaceAll("_DOOUBLE_BCACKSLASH_","\\\\")
78
+ // latex = latex.replaceAll("="," = ")
79
+ // latex = latex.replaceAll("+"," + ")
80
+ // latex = latex.replaceAll("-"," - ")
81
+ // latex = latex.replaceAll("{ ","{")
82
+ // latex = latex.replaceAll("( ","(")
83
+ // latex = latex.replaceAll("[ ","[")
84
+ // latex = latex.replaceAll(" "," ")
85
+ // latex = latex.trim()
86
+ // console.log("Prettified: "+latex)
87
+ return latex
88
+ }
89
+
90
+ handleChange = (mathQuill) => {
91
+ this.setState({latex:mathQuill.latex(),
92
+ pretty:false})
93
+ }
94
+
95
+ handleKeyDown = (e) => {
96
+ // console.log(e.key)
97
+ try {
98
+ if (!this.props.useExpertMode && e.key === "Enter") {
99
+ this.handleInsertCallback(this.state.latex)
100
+ e.preventDefault()
101
+ }
102
+ } catch (error) {
103
+ console.error(error)
104
+ }
105
+ }
106
+
107
+ updateRecentSymbols(symbol) {
108
+ let recentSymbols = GLOBAL_recentSymbols
109
+ let symbolIndex = -1
110
+ let minIndex = -1
111
+ let min = 999999
112
+ for (let i = 0; i < recentSymbols.length; i++) {
113
+ if (recentSymbols[i].name === symbol)
114
+ symbolIndex = i
115
+ if (recentSymbols[i].count > 0)
116
+ recentSymbols[i].count = recentSymbols[i].count * RECENT_DECAY_FACTOR
117
+ if (recentSymbols[i].count <= min) {
118
+ min = recentSymbols[i].count
119
+ minIndex = i
120
+ }
121
+ }
122
+ if (symbolIndex >= 0) {
123
+ recentSymbols[symbolIndex].count = recentSymbols[symbolIndex].count + RECENT_WEIGHT
124
+ } else {
125
+ if (recentSymbols.length >= RECENT_MAX) {
126
+ recentSymbols.splice(minIndex, 1)
127
+ }
128
+ recentSymbols.push({name:symbol, count:RECENT_WEIGHT})
129
+ }
130
+
131
+ // recentSymbols.sort((a, b) => b.count - a.count) // Order max to min
132
+
133
+ GLOBAL_recentSymbols = recentSymbols
134
+ GLOBAL_lastSymbols.pop()
135
+ GLOBAL_lastSymbols.unshift(symbol)
136
+
137
+
138
+ this.setState({recentSymbolsCount:this.state.recentSymbolsCount + 1})
139
+ }
140
+
141
+ handleInsertSymbol = (symbol) => {
142
+ if (this.props.useExpertMode)
143
+ this.handleInsertSymbolKatex(symbol)
144
+ else
145
+ this.handleInsertSymbolMathQuill(symbol)
146
+ }
147
+
148
+ isLetter = (char) => {
149
+ return ('a' <= char && char <= 'z') || ('A' <= char && char <= 'Z')
150
+ }
151
+
152
+ // Returns:
153
+ // 1. the provided pos if pos is not inside a latex tag
154
+ // 2. a new pos if pos is close to the start or end of a tag (within 2 characters)
155
+ // 3. -1 if pos is not close to the start or end of a tag
156
+ movePosIfInsideTag(latex, pos) {
157
+ if (pos === 0 || pos === latex.length)
158
+ return pos
159
+
160
+ let c = latex.charAt(pos)
161
+ if (!this.isLetter(c))
162
+ return pos
163
+ let cm1 = latex.charAt(pos-1)
164
+ if (!this.isLetter(cm1) && cm1 !== "\\")
165
+ return pos-1
166
+
167
+ // We are inside a latex tag, let's move out of it
168
+ if (pos === 1)
169
+ return 0
170
+ if (pos === latex.length-1)
171
+ return latex.length
172
+
173
+ if (latex.charAt(pos-1) === "\\")
174
+ return pos-1
175
+ if (!this.isLetter(latex.charAt(pos+1)))
176
+ return pos+1
177
+ if (latex.charAt(pos-2) === "\\")
178
+ return pos-2
179
+ if (!this.isLetter(latex.charAt(pos+2)))
180
+ return pos+2
181
+
182
+ return -1
183
+ }
184
+
185
+ handleInsertSymbolKatex = (symbol) => {
186
+ let oldLatex = null
187
+ let start = -1
188
+ let end = -1
189
+ try {
190
+ this.updateRecentSymbols(symbol)
191
+ oldLatex = this.latexInput.value
192
+ start = this.latexInput.selectionStart
193
+ end = this.latexInput.selectionEnd
194
+
195
+ start = this.movePosIfInsideTag(oldLatex, start)
196
+ end = this.movePosIfInsideTag(oldLatex, end)
197
+ if (start === -1 || end === -1 ) {
198
+ console.log("Can't insert as cursor position or start or end of selection is inside a tag")
199
+ return
200
+ }
201
+
202
+ // if (this.latexInput !== document.activeElement)
203
+ this.latexInput.focus()
204
+
205
+ let insertParams = getLatexInsertionParams(symbol)
206
+ let insert = insertParams.latexWithMarks
207
+ console.log("Insert params: ")
208
+ console.log(insertParams)
209
+ console.log(start+":"+end)
210
+ console.log(oldLatex)
211
+
212
+ // Move text before cursor into latex if double mark exists in latex
213
+ let pos = insert.indexOf("##")
214
+ if (pos >= 0) {
215
+ if (start !== end) {
216
+ insert = insert.replace("##", oldLatex.substring(start, end))
217
+ console.log("insert: "+insert)
218
+ oldLatex = oldLatex.substring(0,start) + oldLatex.substring(end)
219
+ end = start
220
+ } else if (start > 0) {
221
+ insert = insert.replace("##", oldLatex.charAt(start-1))
222
+ oldLatex = oldLatex.substring(0,start-1) + oldLatex.substring(start)
223
+ start = start -1
224
+ end = start
225
+ } else {
226
+ insert = insert.replace("##", "#")
227
+ }
228
+ }
229
+ // Move text before cursor into latex if selection made and single mark exists in latex
230
+ // or otherwise delete selected text
231
+ if (start !== end) {
232
+ pos = insert.indexOf("#")
233
+ if (pos >=0)
234
+ insert = insert.replace("#", oldLatex.substring(start, end))
235
+
236
+ oldLatex = oldLatex.substring(0,start) + oldLatex.substring(end)
237
+ end = start
238
+ }
239
+
240
+ // Remove any extra marks after the first mark
241
+ insert = insert.replace("#",MARK)
242
+ insert = insert.replaceAll("#","")
243
+ insert = insert.replace(MARK,"#")
244
+
245
+ // Construct new latex
246
+ let newLatex =
247
+ oldLatex.substring(0,start) +
248
+ insert +
249
+ MARK +
250
+ oldLatex.substring(start)
251
+ let newSelectionStart = newLatex.indexOf(MARK)
252
+ newLatex = newLatex.substring(0,newSelectionStart) +
253
+ newLatex.substring(newSelectionStart+MARK.length)
254
+
255
+ // Move cursor position back to mark in inserted text if applicable
256
+ pos = insert.indexOf("#")
257
+ if (pos > 0) {
258
+ newSelectionStart = newLatex.lastIndexOf("#",newSelectionStart)
259
+ newLatex = newLatex.substring(0,newSelectionStart)+newLatex.substring(newSelectionStart+1)
260
+ }
261
+
262
+ this.setState({latex:newLatex},
263
+ () => {
264
+ this.latexInput.selectionStart = newSelectionStart
265
+ this.latexInput.selectionEnd = newSelectionStart
266
+ this.latexInput.focus()
267
+ })
268
+ } catch (error) {
269
+ console.error(error)
270
+ console.log("Inserting: "+symbol)
271
+ console.log("into: "+this.latexInput.value)
272
+ console.log("at: "+start+":"+end)
273
+ }
274
+ }
275
+
276
+
277
+ handleInsertSymbolMathQuill = (symbol) => {
278
+ try {
279
+ this.updateRecentSymbols(symbol)
280
+
281
+ let insert = getLatexInsertionParams(symbol)
282
+ if (insert === null || insert === undefined) {
283
+ console.err("Could not get latex insertion parameters for symbol: "+symbol)
284
+ this.mathQuill.focus()
285
+ return
286
+ }
287
+
288
+ if (insert.typedText)
289
+ this.mathQuill.typedText(insert.mathQuillLatex)
290
+ else
291
+ this.mathQuill.write(insert.mathQuillLatex)
292
+
293
+ this.mathQuill.focus()
294
+
295
+ for (let i = 0; i < insert.moveCursor; i++)
296
+ this.mathQuill.keystroke('Right')
297
+ for (let i = 0; i > insert.moveCursor; i--)
298
+ this.mathQuill.keystroke('Left')
299
+ } catch (error) {
300
+ console.error(error)
301
+ console.log("Symbol: "+symbol)
302
+ }
303
+ }
304
+
305
+ handleSelectTab = (tab) => {
306
+ if (!this.props.useExpertMode)
307
+ this.mathQuill.focus()
308
+ else
309
+ this.latexInput.focus()
310
+
311
+ GLOBAL_last_selected_tab = tab
312
+ this.setState({selectedTab:tab},
313
+ () =>
314
+ {
315
+ let buttonAreas = this.symbolButtonAreasInnerContainer.childNodes
316
+ for (let i = 0; i < buttonAreas.length; i++) {
317
+ if (buttonAreas[i].id === TAB_STARTS_AT_AREA_NAMED[tab]) {
318
+ buttonAreas[i].scrollIntoView({behavior:"smooth", inline:"start"})
319
+ }
320
+ }
321
+ })
322
+ }
323
+
324
+ mathquillDidMount(mathQuill) {
325
+ this.mathQuill = mathQuill
326
+ mathQuill.focus()
327
+ }
328
+
329
+ componentDidMount(){
330
+ // let thisComponentNode = ReactDOM.findDOMNode(this);
331
+ this.equationEditorDiv.addEventListener('keydown',this.handleKeyDown);
332
+
333
+ // Restore scroll position for tab to last selected
334
+ let buttonAreas = this.symbolButtonAreasInnerContainer.childNodes
335
+ for (let i = 0; i < buttonAreas.length; i++) {
336
+ if (buttonAreas[i].id === TAB_STARTS_AT_AREA_NAMED[GLOBAL_last_selected_tab]) {
337
+ buttonAreas[i].scrollIntoView({behavior:"auto", inline:"start"})
338
+ }
339
+ }
340
+ }
341
+
342
+ componentWillUnmount(){
343
+ // let thisComponentNode = ReactDOM.findDOMNode(this);
344
+ this.equationEditorDiv.removeEventListener('keydown',this.handleKeyDown);
345
+ }
346
+
347
+ isMobile() {
348
+ return (
349
+ (window.innerHeight > window.innerWidth) &&
350
+ (window.innerHeight < 820)) || (window.innerHeight < 500)
351
+ }
352
+
353
+ isTouchDevice() {
354
+ return ( 'ontouchstart' in window ) ||
355
+ ( navigator.maxTouchPoints > 0 ) ||
356
+ ( navigator.msMaxTouchPoints > 0 );
357
+ }
358
+
359
+ handleCursorLeft = () => {
360
+ this.mathQuill.keystroke('Left')
361
+ this.mathQuill.focus()
362
+ }
363
+
364
+ handleCursorRight = () => {
365
+ this.mathQuill.keystroke('Right')
366
+ this.mathQuill.focus()
367
+ }
368
+
369
+ handleLatexInputChange = (event) => {
370
+ this.setState({latex:event.target.value,
371
+ pretty:true})
372
+ }
373
+
374
+ renderLatexString = (latex) => {
375
+
376
+ const options = {
377
+ children: "",
378
+ displayMode: false,
379
+ output: "htmlAndMathml",
380
+ leqno: false,
381
+ fleqn: false,
382
+ throwOnError: false,
383
+ errorColor: "#cc0000",
384
+ macros: {},
385
+ minRuleThickness: 0,
386
+ colorIsTextColor: false,
387
+ strict: "warn",
388
+ trust: false
389
+ }
390
+
391
+ try {
392
+ // returns HTML markup
393
+ return katex.renderToString(latex, options);
394
+ } catch (err) {
395
+ console.error("Could not convert latex string to html: ", latex);
396
+ return "Error: Unable to render latex";
397
+ }
398
+ };
399
+
400
+ render() {
401
+ console.log("Rendering EquationEditor. Latex: "+this.state.latex)
402
+ let katexHtml = null
403
+ let prettyLatex = null
404
+ if (this.props.useExpertMode) {
405
+ katexHtml = this.renderLatexString(this.state.latex)
406
+ if (this.state.pretty)
407
+ prettyLatex = this.state.latex
408
+ else {
409
+ prettyLatex = this.prettifyLatex(this.state.latex)
410
+ }
411
+ }
412
+
413
+ let tabNames = TAB_NAMES
414
+ let buttonAreaNames = BUTTON_AREA_NAMES
415
+ let buttonAreaWidths = GLOBAL_buttonAreasWidths
416
+ if (this.props.useExpertMode) {
417
+ tabNames = TAB_NAMES_EXPERT
418
+ buttonAreaNames = BUTTON_AREA_NAMES_EXPERT
419
+ buttonAreaWidths = GLOBAL_buttonAreasWidthsExpert
420
+ }
421
+
422
+ const recentButtons = GLOBAL_recentSymbols.slice(0,RECENT_MAX).map((symbol, index) => {
423
+ return (
424
+ <SymbolButton
425
+ className="SymbolButton"
426
+ key={"recent=symbol-"+index}
427
+ symbol={symbol.name}
428
+ shouldUpdate={true}
429
+ highlight={
430
+ symbol.name === GLOBAL_lastSymbols[0] ? "0" :
431
+ symbol.name === GLOBAL_lastSymbols[1] ? "1" :
432
+ symbol.name === GLOBAL_lastSymbols[2] ? "2" : "none"
433
+ }
434
+ handleClick={(latex) => this.handleInsertSymbol(symbol.name)}
435
+ />
436
+ )
437
+ })
438
+
439
+ if (buttonAreaWidths === null) {
440
+ let widths = []
441
+ for (let i = 0; i < buttonAreaNames.length; i++) {
442
+ widths[i] = Math.ceil(getSymbolsForButtonArea(buttonAreaNames[i]).length/MAX_BUTTON_ROWS)
443
+ }
444
+ buttonAreaWidths = widths;
445
+ }
446
+
447
+ const buttonAreasButtons = buttonAreaNames.map((buttonArea, index) => {
448
+ return (
449
+ getSymbolsForButtonArea(buttonArea).map((symbol, index) => {
450
+ return (
451
+ <SymbolButton
452
+ className="SymbolButton"
453
+ key={buttonArea+"-symbol-"+index}
454
+ highlight={buttonArea.toLowerCase()}
455
+ symbol={symbol}
456
+ handleClick={() => this.handleInsertSymbol(symbol)}
457
+ />
458
+ )
459
+ }
460
+ )
461
+ )})
462
+
463
+ return <div className="EquationEditor"
464
+ ref={(r) => this.equationEditorDiv = r}
465
+ >
466
+ {/* Normal mode. Add cursor move buttons for mobile devices as they don't have
467
+ cursor keys in the soft keyboard. */}
468
+ { !this.props.useExpertMode &&
469
+ <div className="editableAndCursorButtonsWrapper">
470
+ { this.isTouchDevice() &&
471
+ <button
472
+ className="cursorButton"
473
+ onClick={(t) => this.handleCursorLeft()}>
474
+ {"\u25C1"}
475
+ </button>
476
+ }
477
+
478
+ <div
479
+ ref={(r) => this.editableWrapper = r}
480
+ className="editableWrapper">
481
+ <div className="centerBox">
482
+ <EditableMathField
483
+ className="editable"
484
+ latex={this.state.latex}
485
+ placeholder="Type equation .."
486
+ config={{
487
+ autoOperatorNames: AUTO_OPERATOR_NAMES
488
+ }}
489
+ mathquillDidMount={mathQuill => this.mathquillDidMount(mathQuill)}
490
+ onChange={(mathQuill) => this.handleChange(mathQuill)}
491
+ />
492
+ { this.state.latex.length=== 0 &&
493
+ <div className="editable-placeholder">Type equation</div>}
494
+ </div>
495
+ </div>
496
+
497
+ { this.isTouchDevice() &&
498
+ <button
499
+ className="cursorButton"
500
+ onClick={(t) => this.handleCursorRight()}>
501
+ {"\u25B7"}
502
+ </button>
503
+ }
504
+ </div>}
505
+
506
+ { /* Expert mode */}
507
+ { this.props.useExpertMode &&
508
+ <div className="latexInputAreaWrapper">
509
+ <textarea className="latexInput"
510
+ autoFocus
511
+ placeholder="Type latex"
512
+ spellCheck="false"
513
+ ref={(r) => this.latexInput = r}
514
+ onChange={this.handleLatexInputChange}
515
+ value={prettyLatex}>
516
+ </textarea>
517
+ <div className="katexRenderedInput"
518
+ dangerouslySetInnerHTML={{__html: katexHtml}}
519
+ />
520
+ </div>
521
+ }
522
+
523
+
524
+ {/* Cancel and submit button row. Cancel button is only shown on mobile devices as
525
+ there may be no visible background to click on to close the modal. */}
526
+ { this.isMobile() &&
527
+ <button
528
+ className="cancelButton"
529
+ type="button" onClick={this.handleCancel}>
530
+ Cancel
531
+ </button>
532
+ }
533
+ <button
534
+ className="insertButton"
535
+ type="button" onClick={this.handleInsert}>
536
+ Insert
537
+ </button>
538
+
539
+
540
+ {/* Recent symbols button bar */}
541
+ { !this.isMobile() && <div className="recentSymbolsButtonArea">
542
+ { GLOBAL_recentSymbols.length > 0 && GLOBAL_recentSymbols.length < 6 &&
543
+ <span className="recentSymbols-recentText-active">Recent:</span> }
544
+ { GLOBAL_recentSymbols.length === 0 &&
545
+ <span className="recentSymbols-recentText-not-active">Recent:</span>}
546
+ { recentButtons }
547
+ </div>
548
+ }
549
+
550
+
551
+ {/* Symbol buttons area */}
552
+ <div>
553
+ <div className="tabBar">
554
+ { tabNames.map((tab, index) => {
555
+ return (
556
+ <span key={"tabBar-"+tab}>
557
+ { this.state.selectedTab === tab
558
+ ? <span className="tab-selected" selected={true}>{tab}</span>
559
+ : <span className="tab" onClick={(t) => this.handleSelectTab(tab)}>{tab}</span>
560
+ } </span>
561
+ )})
562
+ }
563
+ </div>
564
+
565
+ <div className="symbolButtonAreasOuterContainer">
566
+ <div className="symbolButtonAreasInnerContainer"
567
+ ref={(r) => this.symbolButtonAreasInnerContainer = r}
568
+ >
569
+ { buttonAreasButtons.map((button, index) => {
570
+ return (
571
+ <div
572
+ id={buttonAreaNames[index]}
573
+ key={buttonAreaNames[index]}
574
+ className={"symbolButtonArea-"+buttonAreaWidths[index]}>
575
+ {button}
576
+ </div> )})
577
+ }
578
+ </div>
579
+ </div>
580
+ </div>
581
+ </div>
582
+ }
583
+ }