
起因
很早就接觸了 Markdown,也用過(guò)幾款 Markdown 編輯器。由于我用的是 Linux,一直無(wú)法在 Linux 上找到一款美觀順手的編輯器。Mac 上貌似有不少優(yōu)秀的編輯器,可一直無(wú)緣得見(jiàn)。
其實(shí)很早就有了自己實(shí)現(xiàn)一個(gè) Markdown 編輯器的想法,可一直覺(jué)得像編輯器這樣的東西做起來(lái)應(yīng)該不會(huì)太簡(jiǎn)單,工作量應(yīng)該會(huì)非常大。我也一直沒(méi)有弄明白這其中的原理是什么,雖然網(wǎng)上有不少開(kāi)源的 Markdown 編輯器,但在沒(méi)有說(shuō)明的情況下閱讀別人的代碼是一件十分困難的事情,所以也一直沒(méi)有去讀。
直到最近讀到了一片文章:Node Webkit (NW.js) tutorial: creating a Markdown editor。在這篇文章里作者簡(jiǎn)述了一個(gè)極其簡(jiǎn)單的 Markdown 編輯器的實(shí)現(xiàn),作者用到的技術(shù)雖然我不太熟悉,不過(guò)原理我還是看懂了。就在這篇文章的基礎(chǔ)上,我開(kāi)始實(shí)現(xiàn)自己的 Markdown 編輯器: Mango,已經(jīng)在 github 上開(kāi)源。
我給自己的編輯器取名為 Mango ---- 一種水果的名字,logo 為藍(lán)底白字的一個(gè) M (見(jiàn)上圖),M 既代表 Markdown 也代表 Mango,字體是在 PhotoShop 里隨便選了一種看得過(guò)去的字體。logo 的設(shè)計(jì)模仿了另一個(gè) Markdown 編輯器(Remarkable)的設(shè)計(jì)。有了 logo 之后就可以開(kāi)始動(dòng)工了。
一開(kāi)始我本來(lái)打算用 gtk+ 來(lái)寫(xiě),不過(guò)我對(duì) C 語(yǔ)言的一些第三方庫(kù)了解得不多,不知道能否方便地實(shí)現(xiàn)我想要的功能,比如代碼高亮,LaTeX 支持,而 JavaScript 在這方面有非常成熟的庫(kù)。而我又是一個(gè)對(duì)新技術(shù)非常感興趣的人,所以想嘗試一下用我沒(méi)有接觸過(guò)的一些技術(shù)來(lái)實(shí)現(xiàn)。于是選擇了跟上文作者相同的技術(shù):NW.js 來(lái)實(shí)現(xiàn)。
NW.js 又叫 node-webkit,把 Node.js 跟 Chromium 結(jié)合在了一起,使得可以用 web 的技術(shù)來(lái)寫(xiě)桌面 App,不僅可以使用 html、css、js,還可以使用 Node 大量的第三方庫(kù),而且輕松跨平臺(tái),實(shí)在是一種相當(dāng)酷的技術(shù),更多的介紹請(qǐng)參見(jiàn)項(xiàng)目主頁(yè)。不過(guò)我之前并沒(méi)有學(xué)過(guò)Node.js,我的前端技術(shù)(html、css、js)也只是屬于在 W3Schools 上速成的水平。所以在頭三天花了一些時(shí)間學(xué)習(xí) Node,以及惡補(bǔ)了一些 JavaScript 的知識(shí)。
開(kāi)始實(shí)現(xiàn)
說(shuō)實(shí)話,“會(huì)寫(xiě)一個(gè)” 跟 “寫(xiě)了一個(gè)” 的區(qū)別真的相當(dāng)大,雖然原理都弄明白了,可真正做起來(lái)還是有相當(dāng)大的困難。這也是我寫(xiě)這篇文章的原因,希望給后續(xù)想自己實(shí)現(xiàn)一個(gè)編輯器的人一些幫助。
其實(shí)我需要的功能不多,一個(gè)美觀的 UI,代碼高亮,LaTeX支持(我是數(shù)學(xué)系的,這個(gè)是必須的),實(shí)時(shí)預(yù)覽和同步滾動(dòng),以及方便的導(dǎo)入導(dǎo)出功能,尤其是在導(dǎo)出 HTML 和 PDF 后仍能保持美觀的 UI。在很多方面馬克飛象都做得很好,而且功能比我要求的多,但卻無(wú)法讀寫(xiě)本地文件,同步功能也不是免費(fèi)的。而NW.js 可以通過(guò) Node 的模塊輕松實(shí)現(xiàn)讀寫(xiě)文件的功能。
什么是 Markdown 呢?Markdown只是一種標(biāo)記語(yǔ)言(Markup language),不過(guò)比HTML簡(jiǎn)單直觀,非常適合寫(xiě)作和記筆記。瀏覽器并不能直接解析 Markdown,而是所以我們首先需要通過(guò)Markdown解析器(parser)把 Markdown 的語(yǔ)法解析成 HTML 語(yǔ)法,再由瀏覽器的引擎渲染成我們所見(jiàn)的頁(yè)面。原理就是這么簡(jiǎn)單。parser并不需要我們自己寫(xiě),已經(jīng)有很多 Markdown的實(shí)現(xiàn)了,這里我選了Marked。所以我們只需要在左邊放一個(gè) Editor,編輯 Markdown 源碼,然后實(shí)時(shí)把 Editor 里面的 Markdown 通過(guò) Marked 轉(zhuǎn)換成 HTML 放在右邊的 Viewer 里就可以了。要實(shí)現(xiàn)實(shí)時(shí)預(yù)覽,必須監(jiān)聽(tīng) Editor 里的變化,每次有所改變的時(shí)候,重新用 Marked 解析一次(放在reload()
函數(shù)里)。
同步滾動(dòng)實(shí)現(xiàn)
同步滾動(dòng)功能實(shí)際上非常簡(jiǎn)單,只要監(jiān)聽(tīng) Editor 和 Viewer 的滾動(dòng)事件,每次一個(gè)滾動(dòng)的時(shí)候改變另一個(gè)的滾動(dòng)軸,使得它們的百分比一樣。就是下面的代碼(我也是 google 來(lái)的):
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);
代碼高亮實(shí)現(xiàn)
代碼高亮我選擇了 highlight.js,只要把 highlight.js 的代碼嵌入 html,然后在每次更新頁(yè)面的時(shí)候,重新初始化一下,就是在reload()
函數(shù)里嵌入如下兩行代碼:
hljs.initHighlighting.called = false;
hljs.initHighlighting();
LaTex支持
這個(gè)是最難實(shí)現(xiàn)的,也是我花時(shí)間最多的。所以我會(huì)詳細(xì)講一講具體的做法。首先 MathJax 庫(kù)肯定是首選,渲染出來(lái)的數(shù)學(xué)公式非常漂亮,可以見(jiàn)下圖:

要想實(shí)現(xiàn)數(shù)學(xué)公式的實(shí)時(shí)渲染,就必須在reload()
函數(shù)里調(diào)用 MathJax 的Typeset
方法重新渲染一遍整個(gè)數(shù)學(xué)公式,而渲染需要有一定的時(shí)間,這就造成了在每次輸入的時(shí)候有數(shù)學(xué)公式的地方都會(huì)不斷的跳(不知如何形容,就是你首先會(huì)看到源碼,然后看到數(shù)學(xué)公式),這真的是一個(gè)非常影響用戶體驗(yàn)的問(wèn)題。國(guó)內(nèi)一些在線編輯器做得非常好,沒(méi)有這個(gè)問(wèn)題,不過(guò)國(guó)外的 stackedit仍然有這個(gè)問(wèn)題,只要輸入速度快一點(diǎn),數(shù)學(xué)公式會(huì)不斷變大變小。
解決這個(gè)問(wèn)題的一個(gè)方法是:首先把經(jīng)由 Marked 解析出來(lái)的 html 源碼放入一個(gè) buffer 里,而這個(gè) buffer 是不顯示的。然后由 MathJax 把 buffer 里的 html 中的數(shù)學(xué)公式排版成可見(jiàn)的格式,然后再把 buffer 里的 html 送到 Viewer 顯示出來(lái),這樣 Viewer 得到的 html 就總是經(jīng)過(guò) MathJax 排版過(guò)的。這里有一個(gè)問(wèn)題,就是Typeset
函數(shù)是異步的,我們必須要在Typeset
函數(shù)完成后,再把 buffer 里的 html 送到 Viewer,這里要借助一下 MathJax 提供的Queue
。部分代碼如下:
//reload函數(shù)部分片段
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函數(shù)里面實(shí)現(xiàn)了把buffer里的html送到Viewer:resultDiv.html(buffer.innerHTML);
看起來(lái)非常完美,可我經(jīng)過(guò)測(cè)試之后發(fā)現(xiàn)問(wèn)題任然存在。原因是因?yàn)槲覀儾粩嗑庉媽?dǎo)致reload
函數(shù)頻繁觸發(fā),可能第二個(gè)reload
函數(shù)運(yùn)行到buffer.innerHTML = (marked(text))
這一步的時(shí)候,前一個(gè)preview
函數(shù)剛好運(yùn)行resultDiv.html(buffer.innerHTML)
,而此時(shí)的buffer.innerHTML
是未經(jīng)Typeset
函數(shù)處理的 。所以我想了個(gè)加鎖(lock)的辦法,就是在前一個(gè)preview
函數(shù)沒(méi)有運(yùn)行完的時(shí)候,后來(lái)的reload
函數(shù)不能運(yùn)行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;
}
}
當(dāng)然加鎖之后實(shí)時(shí)更新可能會(huì)有一次延遲,不過(guò)這個(gè)問(wèn)題不大。
這里還有一個(gè)問(wèn)題,就是 LaTeX 的語(yǔ)法跟 Markdown 的語(yǔ)法有部分沖突,主要是雙下劃線_..._
與\
,LaTeX 里使用_
表示下標(biāo),當(dāng)有兩個(gè)下標(biāo)的時(shí)候,會(huì)先被 Marked 解析為斜體,然后 LaTeX 就無(wú)法渲染了。\\
會(huì)被 Marked 轉(zhuǎn)義成\
,這樣 LaTeX 里就無(wú)法使用\\
了,必須使用\\\
。要解決這個(gè)問(wèn)題必須修改 parser,要不然就重新實(shí)現(xiàn) parser 使得 parser 不解析$$...$$
和$...$
中的內(nèi)容。這里參考了讓marked與MathJax和諧共存這篇文章的解決辦法,修改了 Marked 的部分源碼,不過(guò)就無(wú)法在 Mango 中使用_..._
來(lái)表示斜體了,可以使用*...*
。
導(dǎo)出功能實(shí)現(xiàn)
一個(gè)合格的 Markdown 必然要有導(dǎo)出 HTML 和 PDF 的功能。導(dǎo)出 HTML 的功能比較容易實(shí)現(xiàn),因?yàn)檎麄€(gè)界面本身就是 HTML,只要把不該出現(xiàn)的東西(比如工具欄,編輯區(qū))在導(dǎo)出的時(shí)候隱藏掉就可以了。而 PDF 的功能有些困難。這里我不得不吐槽一下 npm。npm 雖然非常好用,庫(kù)也非常龐大,隨手一搜發(fā)現(xiàn)很多庫(kù)都可以實(shí)現(xiàn)此功能,但是這些庫(kù)的質(zhì)量參差不齊,有些文檔都寫(xiě)不清楚,上手相當(dāng)有困難。我也是試了幾種不同的庫(kù)才終于找到一個(gè)有用的:phantom-html2pdf。不過(guò)這個(gè)庫(kù)也好不到哪里去,文檔不太清楚,作者貌似也不太管事,別人在 github 上提了幾個(gè) issue 都沒(méi)有得到回應(yīng)。我也提了一個(gè),是關(guān)于使用多個(gè)css
的問(wèn)題,作者理都不理我。。。具體的實(shí)現(xiàn)請(qǐng)參見(jiàn)exportToHTML
和exportToPDF
這兩個(gè)函數(shù),比較簡(jiǎn)單,就不細(xì)說(shuō)了。
美觀的 UI
對(duì)于一個(gè)優(yōu)秀的軟件來(lái)說(shuō),一個(gè)好的 UI 必然會(huì)為其增色不少。Markdown 解析器只是把 Markdown 轉(zhuǎn)為 HTML,而沒(méi)有規(guī)定格式,所以不同的編輯器轉(zhuǎn)化出來(lái)的格式并不是一樣的,簡(jiǎn)書(shū)有簡(jiǎn)書(shū)的 UI,Medium 有 Medium 的 UI,馬克飛象有馬克飛象的 UI。我個(gè)人非常喜歡馬克飛象和作業(yè)部落的字體顏色,所以在 Mango 中選了跟它們一樣的字體顏色。我的css
水平真的非常差,不過(guò)幸好 bootstrap 提供了不錯(cuò)的格式,再此基礎(chǔ)上修改一些就可以了。其中blockquote
的格式是 google 來(lái)的(在一個(gè)專門(mén)講 css 技巧的網(wǎng)站)。具體的css
代碼可以見(jiàn)preview.css
.為了在導(dǎo)出的時(shí)候仍然有美觀的 UI,css
都是直接在 html 里面寫(xiě)的,并沒(méi)有外鏈。
結(jié)語(yǔ)
NW.js 的優(yōu)點(diǎn)和缺點(diǎn)
說(shuō)實(shí)話 NW.js 非常好用,及其方便容易就可以創(chuàng)建一個(gè)桌面App,Node 大量的第三方包讓你幾乎可以找到任何你想要的功能,可是必須要在 NW.js 環(huán)境才能運(yùn)行,可是 NW 可執(zhí)行文件有70多MB!!!即使你的程序很小,打包在一起也會(huì)十分龐大。如果你的程序也非常大,那就更麻煩了。比如在 Mango 中為了有 PDF 導(dǎo)出功能,需要phantomjs
,可這個(gè)包有30多MB,這就使得程序非常大了。
另外,報(bào)錯(cuò)信息太不詳細(xì)了,經(jīng)常解決一個(gè) bug 花很長(zhǎng)時(shí)間,總是報(bào)一些百思不得其解的錯(cuò)(不知道到這是 NW.js 的原因還是 JavaScript 的原因)。
Mango 的未來(lái)
其實(shí) Mango 還很不完善,比如連查找替換的功能都沒(méi)有,也沒(méi)有其他編輯器的流程圖功能。因?yàn)?Mango 的定位是用來(lái)記筆記和寫(xiě)一些小文章(我想這也是所有 Markdown 編輯器的定位),又不是寫(xiě)代碼,所以我想查找替換的功能很少會(huì)用到。而流程圖,語(yǔ)法太繁瑣,違背了簡(jiǎn)約的原則,而且估計(jì)也很少會(huì)用,所以也沒(méi)有實(shí)現(xiàn)了。其實(shí)還是有一些功能我想做的,比如與一些云服務(wù)相結(jié)合,實(shí)時(shí)同步到云端(就像馬克飛象那樣,當(dāng)然也不一定跟印象筆記結(jié)合)。另一個(gè)是實(shí)現(xiàn)一些自定義的功能,比如自定義css
等。如果 Mango 有用戶使用的話,我將繼續(xù)完善。