筆者平時寫文章使用的都是Markdown
,但是釋出的時候就會遇到一些平臺不支援Markdown
的情況,重排是不可能重排的,所以都會使用一些Markdown
轉富文字的工具,比如markdown-nice,用的多了就會好奇是怎麼實現的,於是就有了本篇文章。
markdown-nice
是一個基於React
構建的專案,先來看一下它的整體頁面:
一個頂部工具欄,中間三個並列的區域,分別是編輯區域、預覽區域、自定義主題區域,自定義主題區域預設是隱藏的。
大體上就是一個Markdown
編輯器,增加了一些對各個平臺的適配而已。
編輯器
編輯器使用的是CodeMirror,具體來說是一個二次封裝的元件React-CodeMirror:
import CodeMirror from "@uiw/react-codemirror";
class App extends Component {
render() {
return (
<CodeMirror
value={this.props.content.content}
options={{
theme: "md-mirror",// 主題
keyMap: "sublime",// 快捷鍵
mode: "markdown",// 模式,也就是語言型別
lineWrapping: true,// 開啟超長換行
lineNumbers: false,// 不顯示行號
extraKeys: {// 配置快捷鍵
...bindHotkeys(this.props.content, this.props.dialog),
Tab: betterTab,
RightClick: rightClick,
},
}}
onChange={this.handleThrottleChange}
onScroll={this.handleScroll}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onDrop={this.handleDrop}
onPaste={this.handlePaste}
ref={this.getInstance}
/>
)
}
}
快捷鍵、命令
markdown-nice
通過extraKeys
選項設定一些快捷鍵,此外還在工具欄中增加了一些快捷按鈕:
這些快捷鍵或者命令按鈕操作文字內容的邏輯基本是一致的,先獲取當前選區的內容:
const selected = editor.getSelection()
然後進行加工修改:
`**${selected}**`
最後替換選區的內容:
editor.replaceSelection(`**${selected}**`)
此外也可以修改游標的位置來提升體驗,比如加粗操作後游標位置會在文字後面,而不是*
後面就是因為markdown-nice
在替換完選區內容後還修改了游標的位置:
export const bold = (editor, selection) => {
editor.replaceSelection(`**${selection}**`);
const cursor = editor.getCursor();
cursor.ch -= 2;// 游標位置向前兩個字元
editor.setCursor(cursor);
};
表格
Markdown
的表格語法手寫起來是比較麻煩的,markdown-nice
對於表格只提供了幫你插入表格語法符號的功能,你可以輸入要插入的表格行列數:
確認以後會自動插入符號:
實現其實就是一個字串的拼接邏輯:
const text = this.buildFormFormat(this.state.rowNum, this.state.columnNum);
buildFormFormat = (rowNum, columnNum) => {
let formFormat = "";
// 最少會建立三行
for (let i = 0; i < 3; i++) {
formFormat += this.buildRow(i, columnNum);
}
// 超過三行
for (let i = 3; i <= rowNum; i++) {
formFormat += this.buildRow(i, columnNum);
}
return formFormat;
};
buildRow = (rowNum, columnNum) => {
let appendText = "|";
// 第一行為表頭和內容的分隔
if (rowNum === 1) {
appendText += " --- |";
for (let i = 0; i < columnNum - 1; i++) {
appendText += " --- |";
}
} else {
appendText += " |";
for (let i = 0; i < columnNum - 1; i++) {
appendText += " |";
}
}
return appendText + (/windows|win32/i.test(navigator.userAgent) ? "\r\n" : "\n");
};
表格字元生成以後替換當前選區內容即可:
handleOk = () => {
const {markdownEditor} = this.props.content;
const cursor = markdownEditor.getCursor();
const text = this.buildFormFormat(this.state.rowNum, this.state.columnNum);
markdownEditor.replaceSelection(text);
cursor.ch += 2;
markdownEditor.setCursor(cursor);
markdownEditor.focus();
};
同樣修改了游標位置並且讓編輯器重新聚焦。
圖片上傳
markdown-nice
支援直接拖動圖片到編輯區域進行上傳和貼上圖片直接上傳,這是通過監聽CodeMirror
編輯器的drop
和paste
事件實現的:
<CodeMirror
onDrop={this.handleDrop}
onPaste={this.handlePaste}
/>
handleDrop = (instance, e) => {
if (!(e.dataTransfer && e.dataTransfer.files)) {
return;
}
for (let i = 0; i < e.dataTransfer.files.length; i++) {
uploadAdaptor({file: e.dataTransfer.files[i], content: this.props.content});
}
};
handlePaste = (instance, e) => {
if (e.clipboardData && e.clipboardData.files) {
for (let i = 0; i < e.clipboardData.files.length; i++) {
uploadAdaptor({file: e.clipboardData.files[i], content: this.props.content});
}
}
}
判斷如果拖拽或貼上的資料中存在檔案那麼會呼叫uploadAdaptor
方法:
export const uploadAdaptor = (...args) => {
const type = localStorage.getItem(IMAGE_HOSTING_TYPE);
if (type === IMAGE_HOSTING_NAMES.aliyun) {
const config = JSON.parse(window.localStorage.getItem(ALIOSS_IMAGE_HOSTING));
if (
!config.region.length ||
!config.accessKeyId.length ||
!config.accessKeySecret.length ||
!config.bucket.length
) {
message.error("請先配置阿里雲圖床");
return false;
}
return aliOSSUpload(...args);
}
}
省略了其他型別的圖床,以阿里雲OSS
為例,會先檢查一下相關的配置是否存在,存在的話則會呼叫aliOSSUpload
方法:
import OSS from "ali-oss";
export const aliOSSUpload = ({
file = {},
onSuccess = () => {},
onError = () => {},
images = [],
content = null, // store content
}) => {
const config = JSON.parse(window.localStorage.getItem(ALIOSS_IMAGE_HOSTING));
// 將檔案型別轉成base64型別
const base64Reader = new FileReader();
base64Reader.readAsDataURL(file);
base64Reader.onload = (e) => {
const urlData = e.target.result;
const base64 = urlData.split(",").pop();
// 獲取檔案型別
const fileType = urlData
.split(";")
.shift()
.split(":")
.pop();
// base64轉blob
const blob = toBlob(base64, fileType);
// blob轉arrayBuffer
const bufferReader = new FileReader();
bufferReader.readAsArrayBuffer(blob);
bufferReader.onload = (event) => {
const buffer = new OSS.Buffer(event.target.result);
aliOSSPutObject({config, file, buffer, onSuccess, onError, images, content});
};
};
};
這一步主要是將檔案型別轉換成了arrayBuffer
型別,最後會呼叫aliOSSPutObject
進行檔案上傳操作:
const aliOSSPutObject = ({config, file, buffer, onSuccess, onError, images, content}) => {
let client = new OSS(config);
// 上傳檔名拼接上當前時間
const OSSName = getOSSName(file.name);
// 執行上傳操作
client
.put(OSSName, buffer)
.then((response) => {
const names = file.name.split(".");
names.pop();
const filename = names.join(".");
const image = {
filename, // 名字不變並且去掉字尾
url: response.url,
};
// 插入到文件
if (content) {
writeToEditor({content, image});
}
})
.catch((error) => {
console.log(error);
});
};
上傳成功後會把圖片插入到文件:
function writeToEditor({content, image}) {
const isContainImgName = window.localStorage.getItem(IS_CONTAIN_IMG_NAME) === "true";
let text = "";
// 是否帶上檔名
if (isContainImgName) {
text = `\n![${image.filename}](${image.url})\n`;
} else {
text = `\n![](${image.url})\n`;
}
const {markdownEditor} = content;
// 替換當前選區
const cursor = markdownEditor.getCursor();
markdownEditor.replaceSelection(text, cursor);
content.setContent(markdownEditor.getValue());
}
其他各大平臺的具體上傳邏輯可以參考原始碼:imageHosting.js。
格式化Markdown
markdown-nice
支援格式化Markdown
的功能,也就是美化功能,比如:
美化後:
格式化使用的是prettier:
import prettier from "prettier/standalone";
import prettierMarkdown from "prettier/parser-markdown";
export const formatDoc = (content, store) => {
content = handlePrettierDoc(content);
// 給被中文包裹的`$`符號前後新增空格
content = content.replace(/([\u4e00-\u9fa5])\$/g, "$1 $");
content = content.replace(/\$([\u4e00-\u9fa5])/g, "$ $1");
store.setContent(content);
message.success("格式化文件完成!");
};
// 呼叫prettier進行格式化
const handlePrettierDoc = (content) => {
const prettierRes = prettier.format(content, {
parser: "markdown",
plugins: [prettierMarkdown],
});
return prettierRes;
};
預覽
預覽也就是將Markdown
轉換為html
進行顯示,預覽區域只需要提供一個容器元素,比如div
,然後將轉換後的html
內容使用div.innerHTML = html
方式追加進去即可。
目前將Markdown
轉換為html
的開源庫有很多,比如markdown-it、marked、showdown,markdown-nice
使用的是markdown-it
。
核心程式碼:
const parseHtml = markdownParser.render(this.props.content.content);
return (
<section
dangerouslySetInnerHTML={{
__html: parseHtml,
}}
/>
)
markdownParser
即markdown-it
例項:
import MarkdownIt from "markdown-it";
export const markdownParser = new MarkdownIt({
html: true,// 允許在原始碼中存在HTML標籤
highlight: (str, lang) => {
// 程式碼高亮邏輯,後面再看
},
});
外掛
建立完MarkdownIt
的例項後,接著註冊了很多外掛:
markdownParser
.use(markdownItSpan) // 在標題標籤中新增span
.use(markdownItTableContainer) // 在表格外部新增容器
.use(markdownItMath) // 數學公式
.use(markdownItLinkfoot) // 修改腳註
.use(markdownItTableOfContents, {
transformLink: () => "",
includeLevel: [2, 3],
markerPattern: /^\[toc\]/im,
}) // TOC僅支援二級和三級標題
.use(markdownItRuby) // 注音符號
.use(markdownItImplicitFigures, {figcaption: true}) // 圖示
.use(markdownItDeflist) // 定義列表
.use(markdownItLiReplacer) // li 標籤中加入 p 標籤
.use(markdownItImageFlow) // 橫屏移動外掛
.use(markdownItMultiquote) // 給多級引用加 class
.use(markdownItImsize);
外掛的功能註釋中也體現了。
markdown-it
會把輸入的Markdown
字串轉成一個個token
,然後根據token
生成html
字串,比如# 街角小林
會生成如下的token
列表(刪減部分欄位):
[
{
"type": "heading_open",
"tag": "h1",
"nesting": 1,
"level": 0,
"children": null,
"content": "",
"markup": "#",
"info": "",
"block": true,
},
{
"type": "inline",
"tag": "",
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"nesting": 0,
"level": 0,
"children": null,
"content": "街角小林",
"markup": "",
"info": "",
"block": false,
}
],
"content": "街角小林",
"markup": "",
"info": "",
"block": true,
},
{
"type": "heading_close",
"tag": "h1",
"nesting": -1,
"level": 0,
"children": null,
"content": "",
"markup": "#",
"info": "",
"block": true
}
]
在markdown-it
內部,完成各項工作的是一個個rules
,其實就是一個個函式,解析的rules
分為三類:core
、block
、inline
。
core
包含normalize
、block
、inline
、linkify
、replacements
、smartquotes
這些規則,會對我們傳入的markdown
字串按順序依次執行上述規則,其中就包含著block
和inlnie
型別的規則的執行過程,block
和inline
相關規則就是用來生成一個個token
的,顧名思義,一個負責生成塊級型別的token
,比如標題、程式碼塊、表格、專案列表等,一個負責在塊級元素生成之後再生成內聯型別的token
,比如文字、連結、圖片等。
block
執行時會逐行掃描markdown
字串,對每一行字串都會依次執行所有塊級rule
函式,解析生成塊級token
,內建的block
規則有table
、code
、fence
、blockquote
、hr
、list
、heading
、paragraph
等。
在 block
型別的規則處理完之後,可能會生成一種 type
為 inline
的 token
,這種 token
屬於未完全解析的 token
,所以還需要通過inline
型別的token
再處理一次,也就是對塊級token
的content
欄位儲存的字元進行解析生成內聯token
,內建的inline
規則有text
、link
、image
等。
這些解析規則都執行完後會輸出一個token
陣列,再通過render
相關規則生成html
字串,所以一個markdown-it
外掛如果想幹預生成的token
,那就通過更新、擴充套件、新增不同型別的解析rule
,如果想幹預根據token
生成的html
,那就通過更新、擴充套件、新增渲染rule
。
以上只是粗略的介紹,有興趣深入瞭解的可以閱讀markdown-it
原始碼或下面兩個系列的文章:
markdown-it原始碼分析1-整體流程、markdown-it系列文章
markdown-nice
使用的這麼多外掛,有些是社群的,有些是自己寫的,接下來我們看看其中兩個比較簡單的。
1.markdownItMultiquote
function makeRule() {
return function addTableContainer(state) {
let count = 0;
let outerQuoteToekn;
for (var i = 0; i < state.tokens.length; i++) {
// 遍歷所有token
const curToken = state.tokens[i];
// 遇到blockquote_open型別的token
if (curToken.type === "blockquote_open") {
if (count === 0) {
// 最外層 blockquote 的 token
outerQuoteToekn = curToken;
}
count++;
continue;
}
if (count > 0) {
// 給最外層的加一個類名
outerQuoteToekn.attrs = [["class", "multiquote-" + count]];
count = 0;
}
}
};
}
export default (md) => {
// 在核心規則下增加一個自定義規則
md.core.ruler.push("blockquote-class", makeRule(md));
};
這個外掛很簡單,就是當存在多層巢狀的blockquote
時給最外層的blockquote token
新增一個類名,效果如下:
2.markdownItLiReplacer
function makeRule(md) {
return function replaceListItem() {
// 覆蓋了兩個渲染規則
md.renderer.rules.list_item_open = function replaceOpen() {
return "<li><section>";
};
md.renderer.rules.list_item_close = function replaceClose() {
return "</section></li>";
};
};
}
export default (md) => {
md.core.ruler.push("replace-li", makeRule(md));
};
這個外掛就更簡單了,覆蓋了內建的list_item
規則,效果就是在li
標籤內加了個section
標籤。
外鏈轉腳註
我們都知道公眾號最大的限制就是超連結只允許白名單內的,其他的都會被過濾掉,所以如果不做任何處理,我們的超連結就沒了,解決方法一般都是轉成腳註,顯示在文章末尾,markdown-nice
實現這個的邏輯比較複雜,會先更改Markdown
內容,將:
[理想青年實驗室](http://lxqnsys.com/)
格式化為:
[理想青年實驗室](http://lxqnsys.com/ "理想青年實驗室")
也就是將標題補上了,然後再通過markdown-it
外掛處理token
,生成腳註:
markdownParser
.use(markdownItLinkfoot) // 修改腳註
這個外掛的實現也比較複雜,有興趣的可以閱讀原始碼:markdown-it-linkfoot.js。
其實我們可以選擇另一種比較簡單的思路,我們可以覆蓋掉markdown-it
內部的連結token
渲染規則,同時收集所有的連結資料,最後我們自己來生成html
字串拼接到markdown-it
輸出的html
字串上。
比如我們建立一個markdownItLinkfoot2
外掛,註冊:
// 用來收集所有的連結
export const linkList = []
markdownParser
.use(markdownItLinkfoot2, linkList)
把收集連結的陣列通過選項傳給外掛,接下來是外掛的程式碼:
function makeRule(md, linkList) {
return function() {
// 每次重新解析前都清空陣列和計數器
linkList.splice(0, linkList.length)
let index = 0
let isWeChatLink = false
// 覆蓋a標籤的開標籤token渲染規則
md.renderer.rules.link_open = function(tokens, idx) {
// 獲取當前token
let token = tokens[idx]
// 獲取連結的url
let href = token.attrs[0] ? token.attrs[0][1] : ''
// 如果是微信域名則不需要轉換
if (/^https:\/\/mp.weixin.qq.com\//.test(href)) {
isWeChatLink = true
return `<a href="${href}">`
}
// 後面跟著的是連結內的其他token,我們可以遍歷查詢文字型別的token作為連結標題
token = tokens[++idx]
let title = ''
while(token.type !== 'link_close') {
if (token.type === 'text') {
title = token.content
break
}
token = tokens[++idx]
}
// 將連結新增到陣列裡
linkList.push({
href,
title
})
// 同時我們把a標籤替換成span標籤
return "<span>";
};
// 覆蓋a標籤的閉標籤token渲染規則
md.renderer.rules.link_close = function() {
if (isWeChatLink) {
return "</a>"
}
// 我們會在連結名稱後面加上一個上標,代表它存在腳註,上標就是索引
index++
return `<sup>[${index}]</sup></span>`;
};
};
}
export default (md, linkList) => {
// 在核心的規則鏈上新增我們的自定義規則
md.core.ruler.push("change-link", makeRule(md, linkList));
};
然後我們再自行生成腳註html
字串,並拼接到markdown-it
解析後輸出的html
字串上 :
let parseHtml = markdownParser.render(this.props.content.content);
if (linkList.length > 0) {
let linkFootStr = '<div>引用連結:</div>'
linkList.forEach((item, index) => {
linkFootStr += `<div>[${index + 1}] ${item.title}:${item.href}</div>`
})
parseHtml += linkFootStr
}
效果如下:
再完善一下樣式即可。
同步滾動
編輯區域和預覽區域的同步滾動是一個基本功能,首先繫結滑鼠移入事件,這樣可以判斷滑鼠是在哪個區域觸發的滾動:
// 編輯器
<div id="nice-md-editor" onMouseOver={(e) => this.setCurrentIndex(1, e)}></div>
// 預覽區域
<div id="nice-rich-text" onMouseOver={(e) => this.setCurrentIndex(2, e)}></div>
setCurrentIndex(index) {
this.index = index;
}
然後繫結滾動事件:
// 編輯器
<CodeMirror onScroll={this.handleScroll}></CodeMirror>
// 預覽區域容器
<div
id={BOX_ID}
onScroll={this.handleScroll}
ref={(node) => {
this.previewContainer = node;
}}
>
// 預覽區域
<section
id={LAYOUT_ID}
dangerouslySetInnerHTML={{
__html: parseHtml,
}}
ref={(node) => {
this.previewWrap = node;
}}
</section>
</div>
handleScroll = () => {
if (this.props.navbar.isSyncScroll) {
const {markdownEditor} = this.props.content;
const cmData = markdownEditor.getScrollInfo();
// 編輯器的滾動距離
const editorToTop = cmData.top;
// 編輯器的可滾動高度
const editorScrollHeight = cmData.height - cmData.clientHeight;
// scale = 預覽區域的可滾動高度 / 編輯器的可滾動高度
this.scale = (this.previewWrap.offsetHeight - this.previewContainer.offsetHeight + 55) / editorScrollHeight;
// scale = 預覽區域的滾動距離 / 編輯器的滾動距離 = this.previewContainer.scrollTop / editorToTop
if (this.index === 1) {
// 滑鼠在編輯器上觸發滾動,預覽區域跟隨滾動
this.previewContainer.scrollTop = editorToTop * this.scale;
} else {
// 滑鼠在預覽區域觸發滾動,編輯器跟隨滾動
this.editorTop = this.previewContainer.scrollTop / this.scale;
markdownEditor.scrollTo(null, this.editorTop);
}
}
};
計算很簡單,根據兩個區域的可滾動距離之比等於兩個區域的滾動距離之比,計算出其中某個區域的滾動距離,但是這種計算實際上不會很準確,尤其是當存在大量圖片時:
可以看到上圖中編輯器都滾動到了4.2小節,而預覽區域4.2小節都還看不見。
要解決這個問題單純的計算高度就不行了,需要能將兩邊的元素對應起來,預知詳情,可參考筆者的另外一篇文章:如何實現一個能精確同步滾動的Markdown編輯器。
主題
主題本質上就是css
樣式,markdown
轉成html
後涉及到的標籤並不是很多,只要全都羅列出來定製樣式即可。
markdown-nice
首先建立了四個style
標籤:
1.basic-theme
基礎主題,定義了一套預設的樣式,樣式內容可以在basic.js檔案檢視。
2.markdown-theme
用來插入所選擇的主題樣式,也就是用來覆蓋basic-theme
的樣式,自定義的主題樣式也會插入到這個標籤:
3.font-theme
用來專門插入字型樣式,對應的是這個功能:
// 襯線字型 和 非襯線字型 切換
toggleFont = () => {
const {isSerif} = this.state;
const serif = `#nice {
font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
}`;
const sansSerif = `#nice {
font-family: Roboto, Oxygen, Ubuntu, Cantarell, PingFangSC-light, PingFangTC-light, 'Open Sans', 'Helvetica Neue', sans-serif;
}`;
const choosen = isSerif ? serif : sansSerif;
replaceStyle(FONT_THEME_ID, choosen);
message.success("字型切換成功!");
this.setState({isSerif: !isSerif});
};
4.code-theme
顧名思義,對應的就是用來插入程式碼塊的樣式了,markdown-it
提供了一個highlight
選項來配置程式碼塊高亮,提供一個函式,接收程式碼字元和語言型別,返回一個html
片段,也可以包裹pre
標籤後返回,這樣markdown-it
內部就不會再處理。
markdown-nice
使用的是highlight.js來實現程式碼高亮:
export const markdownParser = new MarkdownIt({
html: true,
highlight: (str, lang) => {
if (lang === undefined || lang === "") {
lang = "bash";
}
// 加上custom則表示自定義樣式,而非微信專屬,避免被remove pre
if (lang && highlightjs.getLanguage(lang)) {
try {
const formatted = highlightjs
.highlight(lang, str, true)
.value.replace(/\n/g, "<br/>") // 換行用br表示
.replace(/\s/g, " ") // 用nbsp替換空格
.replace(/span /g, "span "); // span標籤修復
return '<pre class="custom"><code class="hljs">' + formatted + "</code></pre>";
} catch (e) {
console.log(e);
}
}
// escapeHtml方法會轉義html種的 &<>" 字元
return '<pre class="custom"><code class="hljs">' + markdownParser.utils.escapeHtml(str) + "</code></pre>";
},
});
highlight.js
內建了很多主題:styles,markdown-nice
從中挑了6種:
並且還支援mac
風格,區別就是mac
風格增加了下列樣式:
一鍵複製
markdown-nice
有三個一鍵複製的按鈕,分別是公眾號
、知乎
、掘金
,掘金現在本身編輯器就是markdown
的,所以我們直接忽略。
公眾號:
copyWechat = () => {
const layout = document.getElementById(LAYOUT_ID); // 保護現場
const html = layout.innerHTML;
solveWeChatMath();
this.html = solveHtml();
copySafari(this.html);
message.success("已複製,請到微信公眾平臺貼上");
layout.innerHTML = html; // 恢復現場
};
知乎:
copyZhihu = () => {
const layout = document.getElementById(LAYOUT_ID); // 保護現場
const html = layout.innerHTML;
solveZhihuMath();
this.html = solveHtml();
copySafari(this.html);
message.success("已複製,請到知乎貼上");
layout.innerHTML = html; // 恢復現場
};
主要的區別其實就是solveWeChatMath
和solveZhihuMath
方法,這兩個方法是用來解決公式的問題。markdown-nice
使用MathJax來渲染公式(各位自己看,筆者對MathJax
不熟悉,屬實看不懂~):
try {
window.MathJax = {
tex: {
inlineMath: [["\$", "\$"]],// 行內公式的開始/結束分隔符
displayMath: [["\$\$", "\$\$"]],// 塊級公式的開始/結束分隔符
tags: "ams",
},
svg: {
fontCache: "none",// 不快取svg路徑,不進行復用
},
options: {
renderActions: {
addMenu: [0, "", ""],
addContainer: [
190,
(doc) => {
for (const math of doc.math) {
this.addContainer(math, doc);
}
},
this.addContainer,
],
},
},
};
require("mathjax/es5/tex-svg-full");
} catch (e) {
console.log(e);
}
addContainer(math, doc) {
const tag = "span";
const spanClass = math.display ? "span-block-equation" : "span-inline-equation";
const cls = math.display ? "block-equation" : "inline-equation";
math.typesetRoot.className = cls;
math.typesetRoot.setAttribute(MJX_DATA_FORMULA, math.math);
math.typesetRoot.setAttribute(MJX_DATA_FORMULA_TYPE, cls);
math.typesetRoot = doc.adaptor.node(tag, {class: spanClass, style: "cursor:pointer"}, [math.typesetRoot]);
}
// 內容更新後呼叫下列方法重新渲染公式
export const updateMathjax = () => {
window.MathJax.texReset();
window.MathJax.typesetClear();
window.MathJax.typesetPromise();
};
公式轉換的html
結構如下:
公眾號編輯器不支援公式,所以是通過直接插入svg
:
export const solveWeChatMath = () => {
const layout = document.getElementById(LAYOUT_ID);
// 獲取到所有公式標籤
const mjxs = layout.getElementsByTagName("mjx-container");
for (let i = 0; i < mjxs.length; i++) {
const mjx = mjxs[i];
if (!mjx.hasAttribute("jax")) {
break;
}
// 移除mjx-container標籤上的一些屬性
mjx.removeAttribute("jax");
mjx.removeAttribute("display");
mjx.removeAttribute("tabindex");
mjx.removeAttribute("ctxtmenu_counter");
// 第一個節點為svg節點
const svg = mjx.firstChild;
// 將svg通過屬性設定的寬高改成通過樣式進行設定
const width = svg.getAttribute("width");
const height = svg.getAttribute("height");
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.width = width;
svg.style.height = height;
}
};
知乎編輯器支援公式,所以會直接把公式相關的html
替換為img
標籤:
export const solveZhihuMath = () => {
const layout = document.getElementById(LAYOUT_ID);
const mjxs = layout.getElementsByTagName("mjx-container");
while (mjxs.length > 0) {
const mjx = mjxs[0];
let data = mjx.getAttribute(MJX_DATA_FORMULA);
if (!data) {
continue;
}
if (mjx.hasAttribute("display") && data.indexOf("\\tag") === -1) {
data += "\\\\";
}
// 替換整個公式標籤
mjx.outerHTML = '<img class="Formula-image" data-eeimg="true" src="" alt="' + data + '">';
}
};
處理完公式後接下來會執行solveHtml
方法:
import juice from "juice";
export const solveHtml = () => {
const element = document.getElementById(BOX_ID);
let html = element.innerHTML;
// 將公式的容器標籤替換成span
html = html.replace(/<mjx-container (class="inline.+?)<\/mjx-container>/g, "<span $1</span>");
// 將空格替換成
html = html.replace(/\s<span class="inline/g, ' <span class="inline');
// 同上
html = html.replace(/svg><\/span>\s/g, "svg></span> ");
// 這個標籤上面已經替換過了,這裡為什麼還要再替換一遍
html = html.replace(/mjx-container/g, "section");
html = html.replace(/class="mjx-solid"/g, 'fill="none" stroke-width="70"');
// 去掉公式的mjx-assistive-mml標籤
html = html.replace(/<mjx-assistive-mml.+?<\/mjx-assistive-mml>/g, "");
// 獲取四個樣式標籤內的樣式
const basicStyle = document.getElementById(BASIC_THEME_ID).innerText;
const markdownStyle = document.getElementById(MARKDOWN_THEME_ID).innerText;
const codeStyle = document.getElementById(CODE_THEME_ID).innerText;
const fontStyle = document.getElementById(FONT_THEME_ID).innerText;
let res = "";
try {
// 使用juice庫將樣式內聯到html標籤上
res = juice.inlineContent(html, basicStyle + markdownStyle + codeStyle + fontStyle, {
inlinePseudoElements: true,// 插入偽元素,做法是轉換成span標籤
preserveImportant: true,// 保持!import
});
} catch (e) {
message.error("請檢查 CSS 檔案是否編寫正確!");
}
return res;
};
這一步主要是替換掉公式的相關標籤,然後獲取了四個樣式標籤內的樣式,最關鍵的一步是最後使用juice將樣式內聯到了html
標籤裡,所以預覽的時候樣式是分離的,但是最終我們複製出來的資料是帶樣式的:
html
處理完畢,最後會執行復制到剪貼簿的操作copySafari
:
export const copySafari = (text) => {
// 獲取 input
let input = document.getElementById("copy-input");
if (!input) {
// input 不能用 CSS 隱藏,必須在頁面記憶體在。
input = document.createElement("input");
input.id = "copy-input";
input.style.position = "absolute";
input.style.left = "-1000px";
input.style.zIndex = "-1000";
document.body.appendChild(input);
}
// 讓 input 選中一個字元,無所謂那個字元
input.value = "NOTHING";
input.setSelectionRange(0, 1);
input.focus();
// 複製觸發
document.addEventListener("copy", function copyCall(e) {
e.preventDefault();
e.clipboardData.setData("text/html", text);
e.clipboardData.setData("text/plain", text);
document.removeEventListener("copy", copyCall);
});
document.execCommand("copy");
};
匯出為PDF
匯出為PDF
功能實際上是通過列印功能實現的,也就是呼叫:
window.print();
可以看到列印的內容只有預覽區域,這是怎麼實現的呢,很簡單,通過媒體查詢,在列印模式下隱藏掉不需要列印的其他元素即可:
@media print {
.nice-md-editing {
display: none;
}
.nice-navbar {
display: none;
}
.nice-sidebar {
display: none;
}
.nice-wx-box {
overflow: visible;
box-shadow: none;
width: 100%;
}
.nice-style-editing {
display: none;
}
#nice-rich-text {
padding: 0 !important;
}
.nice-footer-container {
display: none;
}
}
效果就是這樣的:
總結
本文通過原始碼的角度簡單瞭解了一下markdown-nice
的實現原理,整體邏輯比較簡單,有些細節上的實現還是有點麻煩的,比如擴充套件markdown-it
、對數學公式的支援等。擴充套件markdown-it
的場景還是有很多的,比如VuePress大量的功能都是通過寫markdown-it
外掛來實現的,所以有相關的開發需求可以參考一下這些優秀開源專案的實現。