【應用】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
地址
閱讀器
線上地址 效果預覽 原始碼
生成器
線上地址 效果預覽 原始碼
效果
閱讀器
生成器
實現
檔案解析
程式使用 marked 將 markdown 格式轉為 html 格式,這是一個 js 的庫,可以直接在瀏覽器端使用。下面是一個基本的示例
var htmlContent = marked(mdContent);
$("#content").html(htmlContent);
同時 marked 提供了一些介面,讓我們可以方便的定製自己的功能。具體的可以參考它的 說明檔案 。在下面我們會介紹我們是如何利用這些介面來實現擴充套件功能。
檔案上傳
自定義上傳按鈕樣式
原始的上傳按鈕太醜了,所以我們需要自定義自己的樣式。這裡使用的方式是使用在 input
上面覆蓋一個 button
,用
button
來顯示樣式。同時我們將 button
的 pointer-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
最後在 callback
中 value
值都是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();
}
目錄
為了生成檔案的目錄,我們需要首先獲得目錄資訊,因此我們重寫 marked
的 heading
方法, 將目錄資訊儲存起來,同時為每個標題新增連結圖示(仿照 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-url
和
short_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.url
和 page.indertifier
是需要我們自定義的東西。這裡需要注意的是
page.url
要使用絕對路徑。
具體的插入邏輯可參考原始碼的實現,這裡不再贅述。