十分鐘打造一款在線的數學公式編輯器

  • 2021 年 3 月 10 日
  • 筆記

最近,一個朋友要求做一個數學編輯器,方便數學公式的錄入,特別是微積分、矩陣等公式,普通錄入非常麻煩,這裡,花了一周時間,做了一個數學公式在線編輯功能。

下面記錄一下打造的過程。但是,目前很遺憾,這個系統還不支援導入導出功能。

如何實現web錄入的試題導出到word或者把word試題導入到系統,如果您有好的方法,歡迎推薦。(感覺要自己寫解析Latex)

在線體驗  //demo.dotnetcms.org/math  免費下載  //files.cnblogs.com/files/mqingqing123/math5.0.rar

 

1.MathJax

在數學公式里,最流行的是 //www.mathjax.org ,Mathjax支援數理化等各種公式,其實如果你希望只針對數學錄入,可以使用 //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實現,其中,這裡使用一個插件://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="//www.cnblogs.com/mqingqing123/p/12063096.html" target="blank" >LaTex說明</a>   <a href="//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: '\\) ' };
    }


}

  

這樣,這個系統核心就完成了。

在線體驗  //demo.dotnetcms.org/math