十分钟打造一款在线的数学公式编辑器
最近,一个朋友要求做一个数学编辑器,方便数学公式的录入,特别是微积分、矩阵等公式,普通录入非常麻烦,这里,花了一周时间,做了一个数学公式在线编辑功能。
下面记录一下打造的过程。但是,目前很遗憾,这个系统还不支持导入导出功能。
如何实现web录入的试题导出到word或者把word试题导入到系统,如果您有好的方法,欢迎推荐。(感觉要自己写解析Latex)
在线体验 http://demo.dotnetcms.org/math 免费下载 https://files.cnblogs.com/files/mqingqing123/math5.0.rar
1.MathJax
在数学公式里,最流行的是 http://www.mathjax.org ,Mathjax支持数理化等各种公式,其实如果你希望只针对数学录入,可以使用 https://katex.org/ KaTex更简单、速度更快。
Mathjax的文档里列出了MathJax目前支持的LaTex语法。对于未实现的语法,可以自定义宏来实现。
从声明里看到实现了 sin,cos,tan,ctan等都支持,但是一些反正切没实现。
所以,在MathJax的全局配置里,定义一个macros
<script> MathJax = { options: { enableMenu: false, a11y: { speech: false, // switch on speech output braille: false, // switch on Braille output subtitles: false } }, tex: { inlineMath: [['@', '@'], ['\\(', '\\)']], displayMath: [['@@', '@@'], ['\\[', '\\]']], macros: { arcsec: '\\DeclareMathOperator{\\arcsec}{arcsec}\\arcsec', arccsc: '\\DeclareMathOperator{\\arccsc}{arccsc}\\arccsc', arccot: '\\DeclareMathOperator{\\arccot}{arccot}\\arccot' } } } </script>
然后引入Mathjax库
<script src="../js/math/tex-chtml-full.js"></script>
另外,对于数学公式的“开始”和“结束”,MathJax默认使用"\("和"\)"作为分割的,
如果是块状的则使用"\\["和"\\]"区分,
参考下图,左边是录入的内容,右边是显示的结果。
但是Mathjax允许你自定义公式识别符,
上面代码,我增加了“@”作为行内公式,使用"@@"作为块公式。
其实,在选型时,作者测试了“$”或者“#”作为分隔符,但是最终确定使用@符号,最根本的原因是:
在录入时,只有@符号,在中英模式下是一样的。
现在老师可以像写文本一样,写题目了。
2.引入CodeMirror
在录入页面,引入Codemirror美化录入界面。
毕竟,textarea默认太丑了。
<link href="../js/codeMirror/lib/codemirror.css" rel="stylesheet" /> <script src="../js/codeMirror/lib/codemirror.js"></script>
初始化文本框,整个布局分左右布局,
左边是文本框textarea进入录入,右边是iframe进行预览,
在父div里,设置display为flex,进行左右布局,这样就不用 float 飞来飞去的了。
<div style="display:flex">
<div style="width:50%">
<textarea id="txt_question"></textarea>
</div>
<div style="width:50%; background-color:#f2f2f2">
<iframe id=preview frameborder="0"
width="100%"
scrolling="no" >
</iframe>
</div>
<script> var delay; var editor = CodeMirror.fromTextArea(document.getElementById('txt_question'), { lineNumbers: true, mode: 'text/html', lineWrapping:true }); editor.on("change", function () { clearTimeout(delay); delay = setTimeout(updatePreview, 500); }); function updatePreview() { var iframe = document.getElementById('preview'); var doc2 = iframe.contentDocument || iframe.contentWindow.document; let body2 = doc2.getElementsByTagName('body')[0]; var data = editor.getValue().replace(/\n/g, "<br>"); body2.innerHTML = "<div class=mathjax-qmx>" + data + "</div> "; if(doc2.defaultView.MathJax!=null) { doc2.defaultView.MathJax.typeset(); } } setTimeout(updatePreview, 500); </script>
在预览时,需要通过JS引入Mathjax
<script> $(document).ready(function () { let iframe = document.getElementById("preview"); let iframeWindow = iframe.contentWindow || iframe.contentDocument.document || iframe.contentDocument; let doc3 = iframeWindow.document; let head3 = doc3.getElementsByTagName('head')[0]; let body3 = doc3.getElementsByTagName('body')[0]; let js1 = doc3.createElement('script'); js1.src = "../js/math/math-config.js"; js1.type = 'text/javascript'; head3.appendChild(js1); let js2 = doc3.createElement('script'); js2.src = "../js/math/tex-mml-chtml.js"; js2.type = 'text/javascript'; js2.async = true; js2.charset = 'utf-8'; head3.appendChild(js2); }); </script>
最后使用codemirror提供的getValue可以获取值。
另外,在预览时,会把回车“\n”替换为“<br>”
var question = editor.getValue().replace(/\n/g, "<br>")+"";
这样就可以获取录入的值。
3.打造菜单
为了方便录入,打造了一个菜单,
菜单布局父class是math-menu,子菜单由sub-math-menu包裹。下面是HTML代码
<div class="math-menu" data-editorid="editor"> <a href="###">菜单1</a> <div class="sub-math-menu"> <span class="subnavbtn9">希腊字母 <span class="drop"></span> </span> <div class="subnav-content9"> <div>小写字母</div> <a class="add" data-math="\alpha">@\alpha@</a> <div style="clear:both"></div> </div> </div> </div>
下图是预览效果。
下面是CSS样式
.math-menu { overflow: hidden; background-color: #f2f2f2; } .math-menu a { float: left; font-size: 16px; color: #000; text-align: center; padding: 14px 16px; text-decoration: none; } .math-menu .sub-math-menu a { font-size: 14px; padding: 12px 14px; } .sub-math-menu { float: left; overflow: hidden; } .sub-math-menu .subnavbtn9 { font-size: 16px; border: none; outline: none; color: #000; padding: 14px 16px; background-color: inherit; font-family: inherit; margin: 0; display:flex; } .math-menu a:hover, .sub-math-menu:hover .subnavbtn9 { background-color: #ccc; } .subnav-content9 { display: none; position:absolute; background-color: #ccc; z-index: 1000; left:12.5%; width: 75%; } .subnav-content9 a { float: left; color: #000; text-decoration: none; height:50px; } .subnav-content9 a:hover { background-color: #ffffff; color: black; } .drop{ margin-top:10px; margin-left:2px; width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 7px solid #333; } .CodeMirror { border: 1px solid #eee; height: 400px; word-break:break-all; font-family:Verdana; } .add{ cursor:pointer; } .layui-card{ margin-bottom:15px; }
增加鼠标经过,菜单显示效果。
注意:这里使用的是mouseover事件,而不是mouseenter事件。
<script> $('.sub-math-menu').mouseover(function () { $(this).find(".subnav-content9").show(); }) $('.sub-math-menu').mouseout(function () { $(this).find(".subnav-content9").hide(); }) $(".add").click( function () { var ed= $(this).parent().parent().parent().data("editorid"); if(ed=="editor") { editor.replaceSelection("@"+$(this).data("math")+"@") } else { editor2.replaceSelection("@"+$(this).data("math")+"@") } $(this).parent().parent().find(".subnav-content9").hide(); } ); </script>
到此,大功告成。
4.打造普通模式(小白模式)
当然,有时候你可能希望更多的控制,例如插入表格)
这里使用Tinymce集成Mathjax实现,其中,这里使用一个插件:https://github.com/dimakorotkov/tinymce-mathjax
代码里,扩展了Tinymce菜单的定制。
默认这个插件提供的弹窗太小,可以放大,修改后代码如下:
tinymce.PluginManager.add('mathjax', function(editor, url) { // plugin configuration options let mathjaxClassName = editor.settings.mathjax.className || "math-tex"; let mathjaxTempClassName = mathjaxClassName + '-original'; mathjaxSymbols = editor.settings.mathjax.symbols || { start: '\\(', end: '\\) ' }; let mathjaxUrl = editor.settings.mathjax.lib || null; let mathjaxConfigUrl = (editor.settings.mathjax.configUrl || url + '/config.js') + '?class=' + mathjaxTempClassName; let mathjaxScripts = [mathjaxConfigUrl]; if (mathjaxUrl) { mathjaxScripts.push(mathjaxUrl); } // load mathjax and its config on editor init editor.on('init', function () { for (let i = 0; i < mathjaxScripts.length; i++) { let id = editor.dom.uniqueId(); let script = editor.dom.create('script', {id: id, type: 'text/javascript', src: mathjaxScripts[i]}); editor.getDoc().getElementsByTagName('head')[0].appendChild(script); } }); // remove extra tags on get content editor.on('GetContent', function (e) { let div = editor.dom.create('div'); div.innerHTML = e.content; let elements = div.querySelectorAll('.' + mathjaxClassName); for (let i = 0; i < elements.length; i++) { let children = elements[i].querySelectorAll('span'); for (let j = 0; j < children.length; j++) { children[j].remove(); } let latex = elements[i].getAttribute('data-latex'); elements[i].removeAttribute('contenteditable'); elements[i].removeAttribute('style'); elements[i].removeAttribute('data-latex'); elements[i].innerHTML = latex; } e.content = div.innerHTML; }); let checkElement = function(element) { if (element.childNodes.length != 2) { element.setAttribute('contenteditable', false); element.style.cursor = 'pointer'; let latex = element.getAttribute('data-latex') || element.innerHTML; element.setAttribute('data-latex', latex); element.innerHTML = ''; let math = editor.dom.create('span'); math.innerHTML = latex; math.classList.add(mathjaxTempClassName); element.appendChild(math); let dummy = editor.dom.create('span'); dummy.classList.add('dummy'); dummy.innerHTML = 'dummy'; dummy.setAttribute('hidden', 'hidden'); element.appendChild(dummy); } }; // add dummy tag on set content editor.on('BeforeSetContent', function (e) { let div = editor.dom.create('div'); div.innerHTML = e.content; let elements = div.querySelectorAll('.' + mathjaxClassName); for (let i = 0 ; i < elements.length; i++) { checkElement(elements[i]); } e.content = div.innerHTML; }); // refresh mathjax on set content editor.on('SetContent', function(e) { if (editor.getDoc().defaultView.MathJax) { editor.getDoc().defaultView.MathJax.startup.getComponents(); editor.getDoc().defaultView.MathJax.typeset(); } }); // add button to tinimce editor.ui.registry.addButton('插入公式', { text: '插入公式', tooltip: '插入公式', onAction: function () { openMathjaxEditor(); } }); // handle click on existing editor.on("click", function (e) { let closest = e.target.closest('.' + mathjaxClassName); if (closest) { openMathjaxEditor(closest); } }); // open window with editor let openMathjaxEditor = function(target) { let mathjaxId = editor.dom.uniqueId(); let latex = ''; if (target) { latex_attribute = target.getAttribute('data-latex'); if (latex_attribute.length >= (mathjaxSymbols.start + mathjaxSymbols.end).length) { latex = latex_attribute.substr(mathjaxSymbols.start.length, latex_attribute.length - (mathjaxSymbols.start + mathjaxSymbols.end).length); } } // show new window editor.windowManager.open({ title: 'Mathjax', size: 'medium', body: { type: 'panel', items: [ { type: 'htmlpanel', html: '<div > <input onclick=changesybol() type=checkbox id=cb_br name=cb_br>换行 <a href="https://www.cnblogs.com/mqingqing123/p/12063096.html" target="blank" >LaTex说明</a> <a href="http://www.dotnetcms.org" target="blank" >启明星官网</a> <style>.tox-textarea{height:150px !important; border-radius:0px;}</style> </div>' }, { type: 'textarea', name: 'title' }, { type: 'htmlpanel', html: '<iframe id="' + mathjaxId + '" style="width:98%; min-height: 50px; " ></iframe>' } ] }, buttons: [{ type: 'submit', text: '确定' }], onSubmit: function onsubmit(api) { let value = api.getData().title.trim(); if (target) { target.innerHTML = ''; target.setAttribute('data-latex', getMathText(value)); checkElement(target); } else { let newElement = editor.getDoc().createElement('span'); newElement.innerHTML = getMathText(value); newElement.classList.add(mathjaxClassName); checkElement(newElement); editor.insertContent(newElement.outerHTML); } editor.getDoc().defaultView.MathJax.startup.getComponents(); editor.getDoc().defaultView.MathJax.typeset(); api.close(); }, onChange: function(api) { var value = api.getData().title.trim(); if (value != latex) { refreshDialogMathjax(value, document.getElementById(mathjaxId)); latex = value; } }, initialData: {title: latex} }); if (mathjaxSymbols.start == "\\(") { document.getElementById("cb_br").checked = false; } else { document.getElementById("cb_br").checked = true; } // add scripts to iframe let iframe = document.getElementById(mathjaxId); let iframeWindow = iframe.contentWindow || iframe.contentDocument.document || iframe.contentDocument; let iframeDocument = iframeWindow.document; let iframeHead = iframeDocument.getElementsByTagName('head')[0]; let iframeBody = iframeDocument.getElementsByTagName('body')[0]; // get latex for mathjax from simple text let getMathText = function (value, symbols) { if (!symbols) { symbols = mathjaxSymbols; } return symbols.start + ' ' + value + ' ' + symbols.end ; }; // refresh latex in mathjax iframe let refreshDialogMathjax = function(latex) { let MathJax = iframeWindow.MathJax; let div = iframeBody.querySelector('div'); if (!div) { div = iframeDocument.createElement('div'); div.classList.add(mathjaxTempClassName); iframeBody.appendChild(div); } div.innerHTML = getMathText(latex, {start: '$$', end: '$$'}); if (MathJax && MathJax.startup) { MathJax.startup.getComponents(); MathJax.typeset(); } }; refreshDialogMathjax(latex); // add scripts for dialog iframe for (let i = 0; i < mathjaxScripts.length; i++) { let node = iframeWindow.document.createElement('script'); node.src = mathjaxScripts[i]; node.type = 'text/javascript'; node.async = false; node.charset = 'utf-8'; iframeHead.appendChild(node); } }; }); function changesybol() { if (document.getElementById("cb_br").checked) { mathjaxSymbols = { start: '\\[', end: '\\] ' }; } else { mathjaxSymbols = { start: '\\(', end: '\\) ' }; } }
这样,这个系统核心就完成了。
在线体验 http://demo.dotnetcms.org/math