1. 程式人生 > >【應用】Markdown 線上閱讀器

【應用】Markdown 線上閱讀器

前言

一款線上的 Markdown 閱讀器,主要用來展示 Markdown 內容。支援 HTML 匯出,同時可以方便的新增擴充套件功能。在這個閱讀器的基礎又做了一款線上 Github Pages 頁面生成器,可以方便的生成不同主題風格的 GitHub Page 頁面。

功能

閱讀器

  • 支援檔案拖拽
  • 相容移動端
  • Prism.js / Highlight.js 程式碼高亮
  • 自動生成目錄
  • 本地圖片顯示
  • 匯出 Html (包含樣式)
  • 擴充套件功能
    • Toto 列表
    • MathJax
    • 時序圖 (Js sequence diagrams)
    • Emoji (Emojify.js)
    • 圖表 (ECharts)

Github Page 生成器

在上面的基礎上加上了下面的功能

  • 支援多種頁面主題
    • Architect
    • Cayman
    • Minimal
    • Modernist
    • Slate
    • Time machine
  • 評論
    • 多說
    • Disqus

地址

閱讀器
線上地址  效果預覽  原始碼

生成器
線上地址  效果預覽  原始碼

效果

閱讀器

690323-20170110172011244-1840122932.png

生成器

690323-20170110172032963-1424203432.png

實現

檔案解析

程式使用 marked 將 markdown 格式轉為 html 格式,這是一個 js 的庫,可以直接在瀏覽器端使用。下面是一個基本的示例

var htmlContent = marked(mdContent);
$("#content").html(htmlContent);

同時 marked 提供了一些介面,讓我們可以方便的定製自己的功能。具體的可以參考它的 說明檔案 。在下面我們會介紹我們是如何利用這些介面來實現擴充套件功能。

檔案上傳

自定義上傳按鈕樣式

原始的上傳按鈕太醜了,所以我們需要自定義自己的樣式。這裡使用的方式是使用在 input 上面覆蓋一個 button,用 button 來顯示樣式。同時我們將 buttonpointer-events 設為 none,就可以阻止 button 的事件響應(具體可以參考這裡)。下面是具體的實現程式碼:
html:

<div class="upload-area" id="upload-area">
    <input type="file" id="select-file" class="select-file">
    <button
class="select-file-style" id="drop">選擇或者拖拽 Markdown 檔案到此</button> </div>

css

.upload-area {
  width: auto;
  height: 200px;
  margin: 0 2.6em 0 0.4em;
  padding: 0;
  position: relative;
  cursor: pointer;
  transition: height 0.5s;
}
.upload-area .select-file {
  border-width: 0px;
  width: 100%;
  height: 200px;
  margin: 0;
  cursor: pointer;
}
.upload-area .select-file-style {
  background: #F5F7FA;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 200px;
  border: 0px;
  pointer-events: none;
  color: #AAB2BD;
  font-size: 2em;
  line-height: 2em;
  font-family: "Microsoft YaHei", "Tahoma", arial;
}

下面是效果圖

690323-20170110172046572-740487876.png

讀取檔案內容

因為程式完全是執行在瀏覽器端,所以我們使用 html5 的 FileReader 來讀取本地檔案。FileReader 提供 4 種讀取檔案的方式

  • readAsBinaryString(Blob|File)
  • readAsText(Blob|File, opt_encoding)
  • readAsDataURL(Blob|File)
  • readAsArrayBuffer(Blob|File)

其中 readAsText 用來讀取文字檔案,readAsDataUrl 可以用來讀取圖片。具體的介紹可以參考 這裡 。FileReader 一般結合檔案選擇事件或者拖拽事件使用,因為通過這兩個事件可以獲得原始檔。另外 FileReader 是非同步讀取的,通過 onload 事件可以監聽檔案是否讀取完畢。下面是一個示例, 通過點選 <input type= "file"> 選擇檔案,然後讀取檔案內容。

document.getElementById("file-select").addEventListener("change", function(e) {
    e.stopPropagation();
    e.preventDefault();
    var reader = new FileReader();
    reader.readAsText(this.files[0]);
    reader.onload = function (e) {
        var content = e.target.result;
        //......
    };
}, false);

拖拽檔案

為了方便使用者操作,我們提供了點選和拖拽兩種方式來上傳檔案。現在的主流瀏覽器都支援檔案拖拽功能,下面是拖拽過程中觸發的事件

事件 描述
dragstart 使用者開始拖動物件時觸發。
dragenter 滑鼠初次移到目標元素並且正在進行拖動時觸發。這個事件的監聽器應該之指出這個位置是否允許放置元素。如果沒有監聽器或者監聽器不執行任何操作,預設情況下不允許放置。
dragover 拖動時滑鼠移到某個元素上的時候觸發。
dragleave 拖動時滑鼠離開某個元素的時候觸發。
drag 物件被拖拽時每次滑鼠移動都會觸發。
drop 拖動操作結束,放置元素時觸發。
dragend 拖動物件時使用者釋放滑鼠按鍵的時候觸發。

另外在拖拽過程中是不觸發滑鼠事件的。檔案讀取完後文件資訊會儲存在 DataTransfer 物件中。詳細的介紹可以參考 這裡 。下面是新增事件的示例

fileSelect.addEventListener("dragenter", dragMdEnter, false);
fileSelect.addEventListener("dragleave", dragMdLeave, false);
fileSelect.addEventListener('drop', dropMdFile, false);

讀取拖拽的檔案

function dropMdFile(e) {
    // 取消瀏覽器預設行為
    e.stopPropagation();
    e.preventDefault();
    var reader = new FileReader();
    reader.readAsText(e.dataTransfer.files[0]);
    reader.onload = function (e) {
        var content = e.target.result;
        //......
    };
}

本地圖片顯示

因為沒有伺服器,所以為了顯示本地圖片,使用了替換圖片 src 的方式。首先讀取本地檔案,然後將 <img>src 路徑替換為圖片內容 。如下所示:

<img src="path">
// 替換為
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgI...">

下面是具體的程式碼實現:

// 讀取選擇或者拖拽的檔案(多個檔案)
function processImages(imgFiles) {
   var index = 0;
    for (i = 0; i < imgFiles.length; i++) {
        var file = imgFiles[i];
        var reader = new FileReader();
        reader.readAsDataURL(file);
        (function (reader, file) {
            reader.onload = function (e) {
                cacheImages[file.name] = e.target.result;
                index++;
                if (index == length) {
                    replaceImage();
                }
            }
        })(reader, file);
    }
}

// 將路徑替換為圖片內容
function replaceImage() {
    var images = $("img");
    var i;
    for (i = 0; i < images.length; i++) {
        var imgSrc = images[i].src;
        var imgName = getImgName(imgSrc);
        if (cacheImages.hasOwnProperty(imgName)) {
            images[i].src = cacheImages[imgName];
        }
    }
}

如果圖片過大,我們可以將圖片壓縮一下,具體方法就是建立一個 canvas 元素,將圖片繪製到 canvas 上,然後將 canvas 轉為圖片。這種方式對 jpg 檔案壓縮效果較好,對 png 檔案壓縮效果不太好。下面是程式碼實現:

function compressImage(img, format) {
    var max_width = 862;
    var canvas = document.createElement('canvas');

    var width = img.width;
    var height = img.height;
    if (format == null || format == "") {
        format = "image/png";
    }

    if (width > max_width) {
        height = Math.round(height *= max_width / width);
        width = max_width;
    }

    // resize the canvas and draw the image data into it
    canvas.width = width;
    canvas.height = height;
    var ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0, width, height);
    return canvas.toDataURL(format);
}

迴圈中使用非同步回撥函式

為了方便使用,我們可以同時上傳多個圖片,我們使用 for 迴圈來讀取多個檔案,但是有個問題是檔案的讀取是非同步的,也就是說在 for 迴圈執行完之後,圖片可能仍在讀取中,當圖片讀取完後,再呼叫 onload 回撥函式進行處理。簡單一點就是說如何在 for 迴圈中正確使用延遲呼叫的回撥函式。看下面的例子:

function print(value, callback) {
    console.log("value in print", value);
    setTimeout(callback, 1000);
}

for(var i = 0; i < 4; i++) {
    var value = i;
    print(value, function() {
        console.log("value in callback", value);
    });
}

上面打的程式碼和我們讀取圖片檔案的邏輯類似,callback 函式會在呼叫 print 函式1秒後執行,下面是輸出結果

value in print 0
value in print 1
value in print 2
value in print 3
value in callback 3
value in callback 3
value in callback 3
value in callback 3

最後在 callbackvalue 值都是3,這是因為在 js 中沒有塊級作用域,只有函式作用域,也就是說下面的兩段程式碼是等同的:

for(var i = 0; i < 4; i++) {
    var value = i;
    // do someting
}
// 等同於
var value;
for(var i = 0; i < 4; i++) {
    value = i;
    // do someting
}

因此,為了解決這個問題,我們只需要為迴圈中的回撥函式新增一個單獨的作用域即可,我們使用閉包來實現:

for(var i = 0; i < 4; i++) {
    var value = i;
    (function(value) {
        print(value, function() {
            console.log("value in callback", value);
        });
    }(value));
}

程式碼高亮

我們使用兩款程式碼高亮外掛 -- highlight.js 和 prism.js,根據喜好可以自由切換。這兩款外掛對程式碼塊的 html 格式有不同的要求,我們重寫了 marked 中解析程式碼塊的方法,根據高亮方式來生成不同的 html 程式碼:

renderer.code = function (code, lang) {
    if (Setting.highlight == Constants.highlight) {
        return "<pre><code class='" + lang + "'>" + code + "</code></pre>";
    }
    return "<pre><code class='language-" + lang + "'>" + code + "</code></pre>";
};

然後呼叫 highlight.js 和 prism.js 的程式碼高亮方法即可

if (Setting.highlight == Constants.highlight) {
    $('pre code').each(function (i, block) {
        hljs.highlightBlock(block);
    });
} else {
    // 新增行號支援
    $("pre").addClass("line-numbers");
    Prism.highlightAll();
}

目錄

為了生成檔案的目錄,我們需要首先獲得目錄資訊,因此我們重寫 markedheading 方法, 將目錄資訊儲存起來,同時為每個標題新增連結圖示(仿照 github),下面是程式碼:

renderer.heading = function (text, level) {
    var slug = text.toLowerCase().replace(/[\s]+/g, '-');
    if (tocStr.indexOf(slug) != -1) {
        slug += "-" + tocDumpIndex;
        tocDumpIndex++;
    }

    tocStr += slug;
    toc.push({
        level: level,
        slug: slug,
        title: text
    });

    return "<h" + level + " id=\"" + slug + "\"><a href=\"#" + slug + "\" class=\"anchor\">" + '' +
        '<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewBox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>' +
        '' + "</a>" + text + "</h" + level + ">";
};

同時需要加入下面的 css,以是標題的連結圖片正常顯示:

h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {
    text-decoration: none
}

h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link {
    visibility: visible
}

.octicon {
    display: inline-block;
    vertical-align: text-top;
    fill: currentColor;
}

.anchor {
    float: left;
    padding-right: 4px;
    margin-left: -20px;
    line-height: 1;
}

為了生成目錄,我們只需按照儲存的目錄資訊,生成 <ul><li> 標籤即可,具體的可以參考原始碼中的實現。

配置頁面錨鏈接

目錄使用的是頁內錨鏈接的方式進行跳轉,如下面所示:

<a href="#h1">跳轉到 H1</a>
...
<h1 id="h1">我是 H1</h1>
...

預設情況下,頁內錨鏈接跳轉之後,目標標籤(上面程式碼中的 <h1> )會移動到頁面的最頂部,但是在我們的程式中有一個固定的 header,如果跳轉到最頂部,目標標籤會被 header 遮擋住,所以我們希望目標標籤移動到距離頁面頂部 header-height 的地方。為了實現我們的需要,只要加入下面的 css 程式碼即可。

:target:before {
    content:"";
    display:block;
    height:50px; /* fixed header height*/
    margin:-50px 0 0; /* negative fixed header height */
}

Todo 列表

Todo 列表實際上就是 checkbox 的列表,完成的工作用選中的 checkbox 表示,未完成的工作用喂選中的列表表示,如下圖所示:

690323-20170110172519103-722426794.png

一般來說,會將下面形式的 markdown 程式碼解析為 todo 列表

- [x] 完成
- [ ] 未完成
- [ ] 未完成

為了實現這個功能,我們重寫 marked 中解析列表的方法,加入對 todo 列表的支援。

renderer.listitem = function (text) {
    if (/^\s*\[[x ]\]\s*/.test(text)) {
        text = text
            .replace(/^\s*\[\]\s*/, '<input type="checkbox" class="task-list-item-checkbox" disabled> ')
            .replace(/^\s*\[x\]\s*/, '<input type="checkbox" class="task-list-item-checkbox" disabled checked> ');
        return '<li style="list-style: none">' + text + '</li>';
    } else {
        return '<li>' + text + '</li>';
    }
};

同時加入下面的樣式:

.task-list-item-checkbox {
    margin: 0 0.2em 0.25em -2.3em;
    vertical-align: middle;
}

[type="checkbox"], [type="radio"] {
    box-sizing: border-box;
    padding: 0;
}

快取

現在的瀏覽器都已經支援 localStorage,可以方便的儲存資料。localStorage 就是一個物件。我們儲存資料就是直接給它新增一個屬性,可以通過 localStoage["a"]=1 或者 localStorage.a = 1 的方式來儲存資料,但是看起來總覺的不太優雅,因為一般使用下面的方式來操作 localStorage

localStorage.setItem(key, vlaue);
localStorage.getItem(key);
localStorage.removeItem(key);

另外 localStorage 也有一些侷限,使用時需要注意:

  • 儲存空間有限制,一般是 5M 左右,和瀏覽器有關
  • 使用者清除瀏覽器快取之後有可能丟失本地快取的資料
  • 不能直接存物件,要先使用 JSON.stringfy 方法將物件進行序列化處理之後再儲存。使用時需要使用 JSON.parse 方法將字串轉為物件。

匯出檔案

通過使用 FileSaver.js,我們可以方便的在瀏覽器端生成檔案,並提供給使用者下載。使用方法也很簡單:

var blob = new Blob([htmlContent], {type: "text/html;charset=utf-8"});
saveAs(blob, name);

擴充套件

我們提供了一些擴充套件功能,用來更好的展示 markdown 內容。在現在的程式中我們可以很方便的新增擴充套件功能,下面會具體介紹。

自定義擴充套件

為了新增擴充套件,我們首先需要確定哪些內容需要作為擴充套件處理。因為在將 markdown 檔案轉為 html 的過程中,一般是不處理程式碼塊中的內容的,所以我們使用程式碼塊來存放擴充套件內容,通過程式碼塊的語言來確定是哪種擴充套件。以新增序列圖擴充套件為例:

  • 確定時序圖的程式碼標記
    690323-20170110172156447-1252756805.png

  • 修改 marked 中對於程式碼塊的解析函式,新增對於時序圖示記的支援

    var renderer = new marked.Renderer();
    var originalCodeFun = function (code, lang) {
    if (Setting.highlight == Constants.highlight) {
        return "<pre><code class='" + lang + "'>" + code + "</code></pre>";
    }
    return "<pre><code class='language-" + lang + "'>" + code + "</code></pre>";
    };
    renderer.code = function (code, language) {
    if (language == "seq") {
        return "<div class='diagram' id='diagram'>" + code + "</div>"
    } else {
        return originalCodeFun.call(this, code, language);
    }
    };
    marked.setOptions({
    renderer: renderer
    });
  • 引入 js-sequence-diagrams 相關檔案

    <link href="{{ bower directory }}/js-sequence-diagrams/dist/sequence-diagram-min.css" rel="stylesheet" />
    <script src="{{ bower directory }}/bower-webfontloader/webfont.js" />
    <script src="{{ bower directory }}/snap.svg/dist/snap.svg-min.js" />
    <script src="{{ bower directory }}/underscore/underscore-min.js" />
    <script src="{{ bower directory }}/js-sequence-diagrams/dist/sequence-diagram-min.js" />
  • 渲染 Markdown 檔案時,呼叫相關函式

    $(".diagram").sequenceDiagram({theme: 'simple'});

新增擴充套件會影響檔案的渲染速度,如果不需要某個擴充套件可以手動關閉。

Mathjax

使用Mathjax 對數學公式進行支援。關於Mathjax 語法,請參考這裡。下面是新增擴充套件的流程:

  • 引入檔案並配置

    <script type="text/x-mathjax-config">
      MathJax.Hub.Config({tex2jax: {inlineMath: [['$','$'], ['\\(','\\)']]},
    TeX: {
      equationNumbers: {
        autoNumber: ["AMS"],
        useLabelIds: true
      }
    },
    "HTML-CSS": {
      linebreaks: {
        automatic: true
      }
    },
    SVG: {
      linebreaks: {
        automatic: true
      }
    }
      });
    </script>
    <script type="text/javascript" src="http://cdn.bootcss.com/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
  • 將 markdown 檔案轉為 html 之後,呼叫 Mathjax 中的方法將對應標記轉為數學公式。

    // content 是需要處理的 html 標籤的 id
    MathJax.Hub.Queue(["Typeset", MathJax.Hub, "content"]);

Emoji

使用 emojify.js 來提供對 Emoji 標籤的支援。Emoji表情參見 EMOJI CHEAT SHEET。下面是新增擴充套件的流程

  • 引用檔案並配置

    <script src="http://cdn.bootcss.com/emojify.js/1.1.0/js/emojify.min.js"></script>
    <script type="text/javascript">
    emojify.setConfig({
        emojify_tag_type: 'div',           // Only run emojify.js on this element
        only_crawl_id: null,            // Use to restrict where emojify.js applies
        img_dir: 'http://cdn.bootcss.com/emojify.js/1.0/images/basic',  // Directory for emoji images
        ignored_tags: {                // Ignore the following tags
            'SCRIPT': 1,
            'TEXTAREA': 1,
            'A': 1,
            'PRE': 1,
            'CODE': 1
        }
    });
    </script>
  • 將 markdown 檔案轉為 html 之後,呼叫 emojify 中的方法將對應標記轉換 emoji 表情。

     emojify.run(document.getElementById('content'))

圖表 (ECharts)

使用 ECharts 來提供對圖表的支援。ECharts 的語法可以參考 官網的示例。下面是使用方法:

  • 確定 ECharts 在 markdown 中的語法標籤
    690323-20170110172214478-1712725908.png

  • 在 code 方法解析中新增對 echarts 的支援

    renderer.code = function (code, language) {
    switch (language) {
        case "echarts":
            if (Setting.echarts) {
                return loadEcharts(code);
            }
            return originalCodeFun.call(this, code, language);
    }
    };
    function loadEcharts(text) {
    var width = "100%";
    var height = "400px";
    try {
        var options = eval("(" + text + ")");
        if (options.hasOwnProperty("width")) {
            width = options["width"];
        }
        if (options.hasOwnProperty("height")) {
            height = options["height"];
        }
        echartIndex++;
        echartData.push({
            id: echartIndex,
            option: options,
            previousOption: text
        });
        return '<div id="echarts-' + echartIndex + '" style="width: ' + width + ';height:' + height + ';"></div>'
    } catch (e) {
        console.log(e);
        return "";
    }
    }
  • 將 markdown 檔案轉為 html 之後,呼叫 echarts 中的方法,將對應的 div 轉為圖表:

    var chart;
    echartData.forEach(function (data) {
    if (data.option.theme) {
        chart = echarts.init(document.getElementById('echarts-' + data.id), data.option.theme);
    } else {
        chart = echarts.init(document.getElementById('echarts-' + data.id));
    }
    chart.setOption(data.option);
    });

評論

在生成Github Page頁面時,我們可以選擇新增 多說 或者 Disqus 評論,其中多說就是在匯出的頁面中加入下面的程式碼

<div class="ds-thread" data-thread-key="" data-title="" data-url=""></div>
<script type="text/javascript">
    var duoshuoQuery = {
        short_name: ""
    };
    (function() {
        var ds = document.createElement("script");
        ds.type = "text/javascript";
        ds.async = true;
        ds.src = (document.location.protocol == "https:" ? "https:" : "http:") + "//static.duoshuo.com/embed.js";
        ds.charset = "UTF-8";
        (document.getElementsByTagName("head")[0] || document.getElementsByTagName("body")[0]).appendChild(ds);
    })();
</script>

其中 data-thread-key, data-title, data-urlshort_name 是需要我們自定義的東西。而Disqus 需要在匯出時插入下面的程式碼:

<div id="disqus_thread"></div>
<script type="text/javascript">
    var disqus_shortname = '';
    var prefix = document.location.protocol == "https:" ? "https:" : "http:"
    var disqus_config = function() {
        this.page.url = "";
        this.page.identifier = ""
    };
    (function() {
        var d = document,
                s = d.createElement('script');
        s.src = prefix + '//' + disqus_shortname + '.disqus.com/embed.js';
        s.setAttribute('data-timestamp', +new Date());
        (d.head || d.body).appendChild(s);
    })();
</script>

其中 disqus_shortname, page.urlpage.indertifier 是需要我們自定義的東西。這裡需要注意的是 page.url 要使用絕對路徑。

具體的插入邏輯可參考原始碼的實現,這裡不再贅述。