-
Notifications
You must be signed in to change notification settings - Fork 3
/
render-math.js
396 lines (353 loc) · 14.7 KB
/
render-math.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
// dependencies:
// defineMathMode(): addon/mode/multiplex.js, optionally addon/mode/stex/stex.js
// hookMath(): MathJax
"use strict";
// Wrap mode to skip formulas (e.g. $x*y$ shouldn't start italics in markdown).
// TODO: doesn't handle escaping, e.g. \$. Doesn't check spaces before/after $ like pandoc.
// TODO: this might not exactly match the same things as formulaRE in processLine().
// We can't just construct a mode object, because there would be no
// way to use; we have to register a constructor, with a name.
CodeMirror.defineMathMode = function(name, outerModeSpec) {
CodeMirror.defineMode(name, function(cmConfig) {
var outerMode = CodeMirror.getMode(cmConfig, outerModeSpec);
var innerMode = CodeMirror.getMode(cmConfig, "text/x-stex");
return CodeMirror.multiplexingMode(
outerMode,
// "keyword" is how stex styles math delimiters.
// "delim" tells us not to pick up this style as math style.
{open: "$$", close: "$$", mode: innerMode, delimStyle: "keyword delim"},
{open: "$", close: "$", mode: innerMode, delimStyle: "keyword delim"},
{open: "\\(", close: "\\)", mode: innerMode, delimStyle: "keyword delim"},
{open: "\\[", close: "\\]", mode: innerMode, delimStyle: "keyword delim"});
});
};
// Usage: first call CodeMirror.hookMath(editor, MathJax),
// then editor.renderAllMath() to process initial content.
// TODO: simplify usage when initial pass becomes cheap.
// TODO: use defineOption(), support clearing widgets and removing handlers.
CodeMirror.hookMath = function(editor, MathJax) {
// Logging
// -------
var timestampMs = ((window.performance && window.performance.now) ?
function() { return window.performance.now(); } :
function() { return new Date().getTime(); });
function formatDuration(ms) { return (ms / 1000).toFixed(3) + "s"; }
var t0 = timestampMs();
// Goal: Prevent errors on IE (but do strive to log somehow if IE Dev Tools are open).
// While we are at it, prepend timestamp to all messages.
// The only way to keep console messages associated with original
// line is to use original `console.log` or its .bind().
// The way to use these helpers is the awkward:
//
// logf()(message, obl)
// errorf()(message, obl)
//
function logmaker(logMethod) {
try {
// console.log is native function, has no .bind in some browsers.
return Function.prototype.bind.call(console[logMethod], console,
formatDuration(timestampMs() - t0));
} catch(err) {
return function(var_args) {
try {
var args = Array.prototype.slice.call(arguments, 0);
args.unshift(formatDuration(timestampMs() - t0));
if(console[logMethod].apply) {
console[logMethod].apply(console, args);
} else {
/* IE's console.log doesn't have .apply, .call, or bind. */
console.log(Array.prototype.slice.call(args));
}
} catch(err) {}
};
}
}
function logf() { return logmaker("log"); }
function errorf() { return logmaker("error"); }
// Log time if non-negligible.
function logFuncTime(func) {
return function(var_args) {
var start = timestampMs();
func.apply(this, arguments);
var duration = timestampMs() - start;
if(duration > 100) {
logf()((func.name || "<???>") + "() took " + formatDuration(duration));
}
};
}
function catchAllErrors(func) {
return function(var_args) {
try {
return func.apply(this, arguments);
} catch(err) {
errorf()("catching error:", err);
}
}
}
// Position arithmetic
// -------------------
var Pos = CodeMirror.Pos;
// Return negative / 0 / positive. a < b iff posCmp(a, b) < 0 etc.
function posCmp(a, b) {
return (a.line - b.line) || (a.ch - b.ch);
}
// True if inside, false if on edge.
function posInsideRange(pos, fromTo) {
return posCmp(fromTo.from, pos) < 0 && posCmp(pos, fromTo.to) < 0;
}
// True if there is at least one character in common, false if just touching.
function rangesOverlap(fromTo1, fromTo2) {
return (posCmp(fromTo1.from, fromTo2.to) < 0 &&
posCmp(fromTo2.from, fromTo1.to) < 0);
}
// Track currently-edited formula
// ------------------------------
// TODO: refactor this to generic simulation of cursor leave events.
var doc = editor.getDoc();
// If cursor is inside a formula, we don't render it until the
// cursor leaves it. To cleanly detect when that happens we
// still markText() it but without replacedWith and store the
// marker here.
var unrenderedMath = null;
function unrenderRange(fromTo) {
if(unrenderedMath !== null) {
var oldRange = unrenderedMath.find();
if(oldRange !== undefined) {
var text = doc.getRange(oldRange.from, oldRange.to);
errorf()("overriding previous unrenderedMath:", text);
} else {
errorf()("overriding unrenderedMath whose .find() === undefined", text);
}
}
logf()("unrendering math", doc.getRange(fromTo.from, fromTo.to));
unrenderedMath = doc.markText(fromTo.from, fromTo.to);
unrenderedMath.xMathState = "unrendered"; // helps later remove only our marks.
}
function unrenderMark(mark) {
var range = mark.find();
if(range === undefined) {
errorf()(mark, "mark.find() === undefined");
} else {
unrenderRange(range);
}
mark.clear();
}
editor.on("cursorActivity", catchAllErrors(function(doc) {
if(unrenderedMath !== null) {
// TODO: selection behavior?
// TODO: handle multiple cursors/selections
var cursor = doc.getCursor();
var unrenderedRange = unrenderedMath.find();
if(unrenderedRange === undefined) {
// This happens, not yet sure when and if it's fine.
errorf()(unrenderedMath, ".find() === undefined");
return;
}
if(posInsideRange(cursor, unrenderedRange)) {
logf()("cursorActivity", cursor, "in unrenderedRange", unrenderedRange);
} else {
logf()("cursorActivity", cursor, "left unrenderedRange.", unrenderedRange);
unrenderedMath = null;
processMath(unrenderedRange.from, unrenderedRange.to);
flushTypesettingQueue(flushMarkTextQueue);
}
}
}));
// Rendering on changes
// --------------------
function createMathElement(from, to) {
// TODO: would MathJax.HTML make this more portable?
var text = doc.getRange(from, to);
var elem = document.createElement("span");
// Display math becomes a <div> (inside this <span>), which
// confuses CM badly ("DOM node must be an inline element").
elem.style.display = "inline-block";
if(/\\(?:re)?newcommand/.test(text)) {
// \newcommand{...} would render empty, which makes it hard to enter it for editing.
text = text + " \\(" + text + "\\)";
}
elem.appendChild(document.createTextNode(text));
elem.title = text;
var isDisplay = /^\$\$|^\\\[|^\\begin/.test(text); // TODO: probably imprecise.
// TODO: style won't be stable given surrounding edits.
// This appears to work somewhat well but only because we're
// re-rendering too aggressively (e.g. one line below change)...
// Sample style one char into the formula, because it's null at
// start of line.
var insideFormula = Pos(from.line, from.ch + 1);
var tokenType = editor.getTokenAt(insideFormula, true).type;
var className = isDisplay ? "display_math" : "inline_math";
if(tokenType && !/delim/.test(tokenType)) {
className += " cm-" + tokenType.replace(/ +/g, " cm-");
}
elem.className = className;
return elem;
}
// MathJax returns rendered DOMs asynchroonously.
// Batch inserting those into the editor to reduce layout & painting.
// (that was the theory; it didn't noticably improve speed in practice.)
var markTextQueue = [];
var flushMarkTextQueue = logFuncTime(function flushMarkTextQueue() {
editor.operation(function() {
for(var i = 0; i < markTextQueue.length; i++) {
markTextQueue[i]();
}
markTextQueue = [];
});
});
// MathJax doesn't support typesetting outside the DOM (https://github.com/mathjax/MathJax/issues/1185).
// We can't put it into a CodeMirror widget because CM might unattach it when it's outside viewport.
// So we need a stable invisible place to measure & typeset in.
var typesettingDiv = document.createElement("div");
typesettingDiv.style.position = "absolute";
typesettingDiv.style.height = 0;
typesettingDiv.style.overflow = "hidden";
typesettingDiv.style.visibility = "hidden";
typesettingDiv.className = "CodeMirror-MathJax-typesetting";
editor.getWrapperElement().appendChild(typesettingDiv);
// MathJax is much faster when typesetting many formulas at once.
// Each batch's elements will go into a div under typesettingDiv.
var typesettingQueueDiv = document.createElement("div");
var typesettingQueue = []; // functions to call after typesetting.
var flushTypesettingQueue = logFuncTime(function flushTypesettingQueue(callback) {
var currentDiv = typesettingQueueDiv;
typesettingQueueDiv = document.createElement("div");
var currentQueue = typesettingQueue;
typesettingQueue = [];
typesettingDiv.appendChild(currentDiv);
logf()("-- typesetting", currentDiv.children.length, "formulas --");
MathJax.Hub.Queue(["Typeset", MathJax.Hub, currentDiv]);
MathJax.Hub.Queue(function() {
currentDiv.parentNode.removeChild(currentDiv);
for(var i = 0; i < currentQueue.length; i++) {
currentQueue[i]();
}
if(callback) {
callback();
}
});
});
function processMath(from, to) {
// By the time typesetting completes, from/to might shift.
// Use temporary non-widget marker to track the exact range to be replaced.
var typesettingMark = doc.markText(from, to, {className: "math-typesetting"});
typesettingMark.xMathState = "typesetting";
var elem = createMathElement(from, to);
elem.style.position = "absolute";
typesettingDiv.appendChild(elem);
var text = elem.innerHTML;
logf()("going to typeset", text, elem);
typesettingQueueDiv.appendChild(elem);
typesettingQueue.push(function() {
logf()("done typesetting", text);
elem.parentNode.removeChild(elem);
elem.style.position = "static";
var range = typesettingMark.find();
if(!range) {
// Was removed by deletion and/or clearOurMarksInRange().
logf()("done typesetting but range disappered, dropping.");
return;
}
var from = range.from;
var to = range.to;
typesettingMark.clear();
// TODO: behavior during selection?
var cursor = doc.getCursor();
if(posInsideRange(cursor, {from: from, to: to})) {
// This doesn't normally happen during editing, more likely
// during initial pass.
errorf()("posInsideRange", cursor, from, to, "=> not rendering");
unrenderRange({from: from, to: to});
} else {
markTextQueue.push(function() {
var mark = doc.markText(from, to, {replacedWith: elem,
clearOnEnter: false});
mark.xMathState = "rendered"; // helps later remove only our marks.
CodeMirror.on(mark, "beforeCursorEnter", catchAllErrors(function() {
unrenderMark(mark);
}));
});
}
});
}
// TODO: multi line \[...\]. Needs an approach similar to overlay modes.
function processLine(lineHandle) {
var text = lineHandle.text;
var line = doc.getLineNumber(lineHandle);
//logf()("processLine", line, text);
// TODO: At least unit test this regexp mess.
// TODO: doesn't handle escaping, e.g. \$. Doesn't check spaces before/after $ like pandoc.
// TODO: matches inner $..$ in $$..$ etc.
// JS has lookahead but not lookbehind.
// For \newcommand{...} can't match end reliably, just consume till last } on line.
var formulaRE = /\$\$.*?[^$\\]\$\$|\$.*?[^$\\]\$|\\\(.*?[^$\\]\\\)|\\\[.*?[^$\\]\\\]|\\begin\{([*\w]+)\}.*?\\end{\1}|\\(?:eq)?ref{.*?}|\\(?:re)?newcommand\{.*\}/g;
var match;
while((match = formulaRE.exec(text)) != null) {
var fromCh = match.index;
var toCh = fromCh + match[0].length;
processMath(Pos(line, fromCh), Pos(line, toCh));
}
}
function clearOurMarksInRange(from, to) {
// doc.findMarks() added in CM 3.22.
var oldMarks = doc.findMarks ? doc.findMarks(from, to) : doc.getAllMarks();
for(var i = 0; i < oldMarks.length; i++) {
var mark = oldMarks[i];
if(mark.xMathState === undefined) {
logf()("not touching foreign mark at", mark.find());
continue;
}
// Verify it's in range, even after findMarks() - it returns
// marks that touch the range, we want at least one char overlap.
var found = mark.find();
if(found.line !== undefined ?
/* bookmark */ posInsideRange(found, {from: from, to: to}) :
/* marked range */ rangesOverlap(found, {from: from, to: to}))
{
logf()("cleared mark at", found, "as part of range:", from, to);
mark.clear();
}
}
}
// CM < 4 batched editor's "change" events via a .next property, which we'd
// have to chase - and what's worse, adjust all coordinates.
// Documents' "change" events were never batched, so not a problem.
CodeMirror.on(doc, "change", catchAllErrors(logFuncTime(function processChange(doc, changeObj) {
logf()("change", changeObj);
// changeObj.{from,to} are pre-change coordinates; adding text.length
// (number of inserted lines) is a conservative(?) fix.
// TODO: use cm.changeEnd()
var endLine = changeObj.to.line + changeObj.text.length + 1;
clearOurMarksInRange(Pos(changeObj.from.line, 0), Pos(endLine, 0));
doc.eachLine(changeObj.from.line, endLine, processLine);
if("next" in changeObj) {
errorf()("next");
processChange(changeObj.next);
}
flushTypesettingQueue(flushMarkTextQueue);
})));
// First pass - process whole document.
editor.renderAllMath = logFuncTime(function renderAllMath(callback) {
doc.eachLine(processLine);
flushTypesettingQueue(function() {
flushMarkTextQueue();
logf()("---- All math rendered. ----");
if(callback) {
callback();
}
});
});
// Make sure stuff doesn't somehow remain in the batching queues.
setInterval(function() {
if(typesettingQueue.length !== 0) {
errorf()("Fallaback flushTypesettingQueue:", typesettingQueue.length, "elements");
flushTypesettingQueue();
}
}, 500);
setInterval(function() {
if(markTextQueue.length !== 0) {
errorf()("Fallaback flushMarkTextQueue:", markTextQueue.length, "elements");
flushMarkTextQueue();
}
}, 500);
}