一个 Markdown 编辑器的实现

Mango logo
Mango logo

起因

很早就接触了 Markdown,也用过几款 Markdown 编辑器。由于我用的是 Linux,一直无法在 Linux 上找到一款美观顺手的编辑器。Mac 上貌似有不少优秀的编辑器,可一直无缘得见。

其实很早就有了自己实现一个 Markdown 编辑器的想法,可一直觉得像编辑器这样的东西做起来应该不会太简单,工作量应该会非常大。我也一直没有弄明白这其中的原理是什么,虽然网上有不少开源的 Markdown 编辑器,但在没有说明的情况下阅读别人的代码是一件十分困难的事情,所以也一直没有去读。

直到最近读到了一片文章:Node Webkit (NW.js) tutorial: creating a Markdown editor。在这篇文章里作者简述了一个极其简单的 Markdown 编辑器的实现,作者用到的技术虽然我不太熟悉,不过原理我还是看懂了。就在这篇文章的基础上,我开始实现自己的 Markdown 编辑器: Mango,已经在 github 上开源。

我给自己的编辑器取名为 Mango ---- 一种水果的名字,logo 为蓝底白字的一个 M (见上图),M 既代表 Markdown 也代表 Mango,字体是在 PhotoShop 里随便选了一种看得过去的字体。logo 的设计模仿了另一个 Markdown 编辑器(Remarkable)的设计。有了 logo 之后就可以开始动工了。

一开始我本来打算用 gtk+ 来写,不过我对 C 语言的一些第三方库了解得不多,不知道能否方便地实现我想要的功能,比如代码高亮,LaTeX 支持,而 JavaScript 在这方面有非常成熟的库。而我又是一个对新技术非常感兴趣的人,所以想尝试一下用我没有接触过的一些技术来实现。于是选择了跟上文作者相同的技术:NW.js 来实现。

NW.js 又叫 node-webkit,把 Node.js 跟 Chromium 结合在了一起,使得可以用 web 的技术来写桌面 App,不仅可以使用 html、css、js,还可以使用 Node 大量的第三方库,而且轻松跨平台,实在是一种相当酷的技术,更多的介绍请参见项目主页。不过我之前并没有学过Node.js,我的前端技术(html、css、js)也只是属于在 W3Schools 上速成的水平。所以在头三天花了一些时间学习 Node,以及恶补了一些 JavaScript 的知识。

开始实现

说实话,“会写一个” 跟 “写了一个” 的区别真的相当大,虽然原理都弄明白了,可真正做起来还是有相当大的困难。这也是我写这篇文章的原因,希望给后续想自己实现一个编辑器的人一些帮助。

其实我需要的功能不多,一个美观的 UI,代码高亮,LaTeX支持(我是数学系的,这个是必须的),实时预览和同步滚动,以及方便的导入导出功能,尤其是在导出 HTML 和 PDF 后仍能保持美观的 UI。在很多方面马克飞象都做得很好,而且功能比我要求的多,但却无法读写本地文件,同步功能也不是免费的。而NW.js 可以通过 Node 的模块轻松实现读写文件的功能。

什么是 Markdown 呢?Markdown只是一种标记语言(Markup language),不过比HTML简单直观,非常适合写作和记笔记。浏览器并不能直接解析 Markdown,而是所以我们首先需要通过Markdown解析器(parser)把 Markdown 的语法解析成 HTML 语法,再由浏览器的引擎渲染成我们所见的页面。原理就是这么简单。parser并不需要我们自己写,已经有很多 Markdown的实现了,这里我选了Marked。所以我们只需要在左边放一个 Editor,编辑 Markdown 源码,然后实时把 Editor 里面的 Markdown 通过 Marked 转换成 HTML 放在右边的 Viewer 里就可以了。要实现实时预览,必须监听 Editor 里的变化,每次有所改变的时候,重新用 Marked 解析一次(放在reload()函数里)。

同步滚动实现

同步滚动功能实际上非常简单,只要监听 Editor 和 Viewer 的滚动事件,每次一个滚动的时候改变另一个的滚动轴,使得它们的百分比一样。就是下面的代码(我也是 google 来的):

var $divs = $('textarea#editor, div#preview');
var sync = function(e){
   var $other = $divs.not(this).off('scroll'), other = $other.get(0);
   var percentage = this.scrollTop / (this.scrollHeight - this.offsetHeight);
   other.scrollTop = percentage * (other.scrollHeight - other.offsetHeight);
   setTimeout( function(){ $other.on('scroll', sync ); },200);
}
$divs.on('scroll', sync);

代码高亮实现

代码高亮我选择了 highlight.js,只要把 highlight.js 的代码嵌入 html,然后在每次更新页面的时候,重新初始化一下,就是在reload()函数里嵌入如下两行代码:

hljs.initHighlighting.called = false;
hljs.initHighlighting();

LaTex支持

这个是最难实现的,也是我花时间最多的。所以我会详细讲一讲具体的做法。首先 MathJax 库肯定是首选,渲染出来的数学公式非常漂亮,可以见下图:

要想实现数学公式的实时渲染,就必须在reload()函数里调用 MathJax 的Typeset方法重新渲染一遍整个数学公式,而渲染需要有一定的时间,这就造成了在每次输入的时候有数学公式的地方都会不断的跳(不知如何形容,就是你首先会看到源码,然后看到数学公式),这真的是一个非常影响用户体验的问题。国内一些在线编辑器做得非常好,没有这个问题,不过国外的 stackedit仍然有这个问题,只要输入速度快一点,数学公式会不断变大变小。

解决这个问题的一个方法是:首先把经由 Marked 解析出来的 html 源码放入一个 buffer 里,而这个 buffer 是不显示的。然后由 MathJax 把 buffer 里的 html 中的数学公式排版成可见的格式,然后再把 buffer 里的 html 送到 Viewer 显示出来,这样 Viewer 得到的 html 就总是经过 MathJax 排版过的。这里有一个问题,就是Typeset函数是异步的,我们必须要在Typeset函数完成后,再把 buffer 里的 html 送到 Viewer,这里要借助一下 MathJax 提供的Queue。部分代码如下:

//reload函数部分片段
var resultDiv = global.$('.md_result');
var buffer = global.window.document.getElementById("buffer");
var textEditor = global.$('#editor');
var text = textEditor.val();

buffer.innerHTML = (marked(text));
MathJax.Hub.Queue(["Typeset",MathJax.Hub,buffer],
                      ["preview",this]);
//preview函数里面实现了把buffer里的html送到Viewer:resultDiv.html(buffer.innerHTML);

看起来非常完美,可我经过测试之后发现问题任然存在。原因是因为我们不断编辑导致reload函数频繁触发,可能第二个reload函数运行到buffer.innerHTML = (marked(text))这一步的时候,前一个preview函数刚好运行resultDiv.html(buffer.innerHTML),而此时的buffer.innerHTML是未经Typeset函数处理的 。所以我想了个加锁(lock)的办法,就是在前一个preview函数没有运行完的时候,后来的reload函数不能运行buffer.innerHTML = (marked(text))这段代码。代码如下:

function reload(){
    if (lock == false) {
        buffer.innerHTML = (marked(text));
        MathJax.Hub.Queue(["Typeset",MathJax.Hub,buffer],
                      ["preview",this]);
    }
}
function preview(){
    if (lock == false){
        lock = true;
        resultDiv.html(buffer.innerHTML);
        lock = false;
    }
}

当然加锁之后实时更新可能会有一次延迟,不过这个问题不大。

这里还有一个问题,就是 LaTeX 的语法跟 Markdown 的语法有部分冲突,主要是双下划线_..._\,LaTeX 里使用_表示下标,当有两个下标的时候,会先被 Marked 解析为斜体,然后 LaTeX 就无法渲染了。\\会被 Marked 转义成\,这样 LaTeX 里就无法使用\\了,必须使用\\\。要解决这个问题必须修改 parser,要不然就重新实现 parser 使得 parser 不解析$$...$$$...$中的内容。这里参考了让marked与MathJax和谐共存这篇文章的解决办法,修改了 Marked 的部分源码,不过就无法在 Mango 中使用_..._来表示斜体了,可以使用*...*

导出功能实现

一个合格的 Markdown 必然要有导出 HTML 和 PDF 的功能。导出 HTML 的功能比较容易实现,因为整个界面本身就是 HTML,只要把不该出现的东西(比如工具栏,编辑区)在导出的时候隐藏掉就可以了。而 PDF 的功能有些困难。这里我不得不吐槽一下 npm。npm 虽然非常好用,库也非常庞大,随手一搜发现很多库都可以实现此功能,但是这些库的质量参差不齐,有些文档都写不清楚,上手相当有困难。我也是试了几种不同的库才终于找到一个有用的:phantom-html2pdf。不过这个库也好不到哪里去,文档不太清楚,作者貌似也不太管事,别人在 github 上提了几个 issue 都没有得到回应。我也提了一个,是关于使用多个css的问题,作者理都不理我。。。具体的实现请参见exportToHTMLexportToPDF这两个函数,比较简单,就不细说了。

美观的 UI

对于一个优秀的软件来说,一个好的 UI 必然会为其增色不少。Markdown 解析器只是把 Markdown 转为 HTML,而没有规定格式,所以不同的编辑器转化出来的格式并不是一样的,简书有简书的 UI,Medium 有 Medium 的 UI,马克飞象有马克飞象的 UI。我个人非常喜欢马克飞象和作业部落的字体颜色,所以在 Mango 中选了跟它们一样的字体颜色。我的css水平真的非常差,不过幸好 bootstrap 提供了不错的格式,再此基础上修改一些就可以了。其中blockquote的格式是 google 来的(在一个专门讲 css 技巧的网站)。具体的css代码可以见preview.css.为了在导出的时候仍然有美观的 UI,css都是直接在 html 里面写的,并没有外链。

结语

NW.js 的优点和缺点

说实话 NW.js 非常好用,及其方便容易就可以创建一个桌面App,Node 大量的第三方包让你几乎可以找到任何你想要的功能,可是必须要在 NW.js 环境才能运行,可是 NW 可执行文件有70多MB!!!即使你的程序很小,打包在一起也会十分庞大。如果你的程序也非常大,那就更麻烦了。比如在 Mango 中为了有 PDF 导出功能,需要phantomjs,可这个包有30多MB,这就使得程序非常大了。

另外,报错信息太不详细了,经常解决一个 bug 花很长时间,总是报一些百思不得其解的错(不知道到这是 NW.js 的原因还是 JavaScript 的原因)。

Mango 的未来

其实 Mango 还很不完善,比如连查找替换的功能都没有,也没有其他编辑器的流程图功能。因为 Mango 的定位是用来记笔记和写一些小文章(我想这也是所有 Markdown 编辑器的定位),又不是写代码,所以我想查找替换的功能很少会用到。而流程图,语法太繁琐,违背了简约的原则,而且估计也很少会用,所以也没有实现了。其实还是有一些功能我想做的,比如与一些云服务相结合,实时同步到云端(就像马克飞象那样,当然也不一定跟印象笔记结合)。另一个是实现一些自定义的功能,比如自定义css等。如果 Mango 有用户使用的话,我将继续完善。

推荐阅读更多精彩内容