【應用】Markdown 在線閱讀器


前言

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

功能

閱讀器

Github Page 生成器

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

地址

閱讀器
在線地址  效果預覽  源碼

生成器
在線地址  效果預覽  源碼

效果

閱讀器

生成器

實現

文件解析

程序使用 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;
}

下面是效果圖

讀取文件內容

因為程序完全是運行在瀏覽器端,所以我們使用 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.jsprism.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 表示,未完成的工作用喂選中的列表表示,如下圖所示:

一般來說,會將下面形式的 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 的過程中,一般是不處理代碼塊中的內容的,所以我們使用代碼塊來存放擴展內容,通過代碼塊的語言來確定是哪種擴展。以添加序列圖擴展為例:

  • 確定時序圖的代碼標記

  • 修改 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 中的語法標簽

  • 在 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 要使用絕對路徑。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM