├── .gitmodules ├── LICENSE ├── README.md ├── demo.html └── render-math.js /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "version.js"] 2 | path = version.js 3 | url = https://github.com/cben/version.js 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Originally this project was MIT-licensed but I've changed it, *retroactively to all past versions*, 2 | to be dual-licensed: you can use it under the MIT License (below), 3 | or the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). 4 | 5 | --------------------------------------------------------------------------------- 6 | 7 | The MIT License (MIT) 8 | 9 | Copyright (c) 2013 Beni Cherniavsky-Paskin 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy of 12 | this software and associated documentation files (the "Software"), to deal in 13 | the Software without restriction, including without limitation the rights to 14 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 15 | the Software, and to permit persons to whom the Software is furnished to do so, 16 | subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 23 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 24 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 25 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 26 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UPDATE: See the more mature https://github.com/SamyPesse/codemirror-widgets 2 | 3 | codemirror-widgets powers [GitBook's new desktop editor](https://www.gitbook.com/blog/releases/editor-5-beta), 4 | is abstracted to supports rendering in-place various things (math, links, images), 5 | and seems generally well structured. 6 | 7 | I haven't carefully reviewed codemirror-widgets yet, but I'll probably abandon this project in favor of improving codemirror-widgets, and switch [mathdown](https://github.com/cben/mathdown) to it too. 8 | 9 | # Attempt at CodeMirror + in-place MathJax 10 | 11 | Experimenting to replace $math$ (and related LaTeX syntaxes) with formulas in CodeMirror. 12 | Buggy and work-in-progress... 13 | 14 | Mostly tested with CodeMirror 4.x, 5.x versions but probably works with 3.x too. 15 | 16 | Performance is currently OK with MathJax 2.4, horribly slow with 2.5 or 2.6. Working on it... 17 | 18 | ## Demo 19 | 20 | http://cben.github.io/CodeMirror-MathJax/demo.html 21 | 22 | If you just want to use this for writing, check out [mathdown.net](http://mathdown.net) powered by https://github.com/cben/mathdown. 23 | 24 | ## UNSTABLE API 25 | 26 | I'm currently changing the API at will. 27 | If you want to use this for anything do contact me — I'll be glad to help. 28 | 29 | ## Git trivia 30 | 31 | After checking out, run this to materialize CodeMirror subdir: 32 | 33 | git submodule update --init 34 | 35 | I'm directly working in `gh-pages` branch without a `master` branch, 36 | as that's the simplest thing that could possibly work; 37 | http://oli.jp/2011/github-pages-workflow/ lists several alternatives. 38 | 39 | TODO: learn about bower or other ways to manage local vs online deps. 40 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | attempt at CodeMirror + in-place MathJax 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 52 | 53 | 70 | 72 | 73 | 74 | 75 | 76 |

attempt at CodeMirror + in-place MathJax 77 | [source on github] 78 |

79 | 80 |
81 | MathJax version: | 88 | CodeMirror version: 96 | (for any other versions edit URL params) 97 |
98 | 99 |

Loading...

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