前言
一款在線的 Markdown 閱讀器,主要用來展示 Markdown 內容。支持 HTML 導出,同時可以方便的添加擴展功能。在這個閱讀器的基礎又做了一款在線 Github Pages 頁面生成器,可以方便的生成不同主題風格的 GitHub Page 頁面。
功能
閱讀器
- 支持文件拖拽
- 兼容移動端
Prism.js
/Highlight.js
代碼高亮- 自動生成目錄
- 本地圖片顯示
- 導出 Html (包含樣式)
- 擴展功能
Github Page 生成器
在上面的基礎上加上了下面的功能
地址
效果
閱讀器
生成器
實現
文件解析
程序使用 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
要使用絕對路徑。
具體的插入邏輯可參考源碼的實現,這里不再贅述。