一个 Markdown 编辑器的实现

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 来的):

1
2
3
4
5
6
7
8
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()函数里嵌入如下两行代码:

1
2
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。部分代码如下:

1
2
3
4
5
6
7
8
9
10
//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))这段代码。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 有用户使用的话,我将继续完善。