一个 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 有用户使用的话,我将继续完善。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容