一個(gè) Markdown 編輯器的實(shí)現(xiàn)

Mango logo
Mango logo

起因

很早就接觸了 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)exportToHTMLexportToPDF這兩個(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ù)完善。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,698評(píng)論 6 539
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,202評(píng)論 3 426
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 177,742評(píng)論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 63,580評(píng)論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,297評(píng)論 6 410
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,688評(píng)論 1 327
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,693評(píng)論 3 444
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,875評(píng)論 0 289
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,438評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,183評(píng)論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,384評(píng)論 1 372
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,931評(píng)論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,612評(píng)論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 35,022評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 36,297評(píng)論 1 292
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,093評(píng)論 3 397
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,330評(píng)論 2 377

推薦閱讀更多精彩內(nèi)容