一个 Markdown 编辑器的实现

作者: egrcc | 来源:发表于2014-05-25 23:52 被阅读9394次
    Mango logoMango 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 有用户使用的话,我将继续完善。

    相关文章

      网友评论

      • 奔跑的大龙猫:您好,我也是一个小码,请问个简单的问题,你的代码怎么在我的电脑上跑起来,还有就是怎么把它转换为可以win下运行的程序,新手,求指教,十分希望能回答,因为一直做web这个不太懂
      • 42b56205e6d2:很不错 顶一顶
        bootstrap特效对照手册:http://t.cn/RK5JCg6
      • 木木烈少:请问浏览区的代码高亮是通过什么实现的呢?
      • 学会畏惧你的影子:写的很好,今天我本打算写一个编辑器,先试一试javascript 怎么样
      • freecast:也想写一个。支持VIM,马克飞象的ui。cmd markdown虽然免费,但是离线的要收费。
      • 左蓝:先mark,Linux下的markdown编辑器还是蛮多的
      • AaronPei:太牛了 佩服佩服 ~
      • 1f6d7b90cace:@egrcc 所以就一直用Vim来写Markdown然后在网页上渲染......太麻烦
        楼主造福大家了......加油
      • egrcc:@qq1693129601 parser确实是关键,如果可以自己改parser的话,在语法上可以做一些扩展,也就不存在与LaTeX不兼容的问题了。不过我对 parser 这块确实不了解。
      • egrcc:@剑紫青天 同道中人 :smile:
      • egrcc:@LostAbaddon 感谢你的多个意见。我确实是个初学者,做这个东西比较仓促,有很多地方都考虑不周道。

        关于同步滚动,你的想法非常好,我下次照你的想法试一试。

        关于MathJax,不知道哪里有关于LaTeX语句缓存的说明?而且我这里所有的生成的数学公式都并非图片,而是可以选中的。

        如果不用PhantomJS的话,我感觉导出pdf还是有困难的,不知道能否提供一个较清晰的思路?

        多谢指导。
      • 0a95e01657e7:关键是parser?
      • 1f6d7b90cace:我去,怒赞,我也是Linux党,一直为桌面写Markdown发愁......
      • ad845f2547c7:楼下的楼下是个大神吧😍😍
      • iHTCboy:暂时看不懂,感觉到好利害的样子,以后有机会学习,喜欢开源的小码~
      • LostAbaddon:原则上说,既然有NWJS了,是可以不用PhantomJS的,因为后者的核心还是Webkit。
        不过这部分需要自己给NWJS做插件了,我也不清楚有没有现成的库。
      • LostAbaddon:不断编辑导致reload函数频繁触发
        这个问题也是可以通过一个Timer或者类似的输入Buffer来解决的
      • LostAbaddon:MathJax
        这部分可以使用LaTeX语句缓存,也就是记录下所用到的LaTeX语句与生成图片的缓存,当前者发生改变的时候再去重新生成后者,这样就不会闪烁了。
      • LostAbaddon:同步滚动实现
        这部分的做法不理想,因为如果内容里有图片的话,图片的长度你可不是按照图片Markdown语句的长度等比例来的,这样就会导致源码与内容的错开。


        我的做法是每一行一个标记,源与预览的行与行对应,然后计算屏幕中央对应行的位置,这样效果更好。
        31034e09212a:@塔塔酱 bootstrap的滚动监听。。是个不错的参考

      本文标题:一个 Markdown 编辑器的实现

      本文链接:https://www.haomeiwen.com/subject/cpjdtttx.html