当前位置:网站首页>一款開源的Markdown轉富文本編輯器的實現原理剖析

一款開源的Markdown轉富文本編輯器的實現原理剖析

2022-06-13 04:19:00 街角小林

筆者平時寫文章使用的都是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編輯器的droppaste事件實現的:

<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-itmarkedshowdownmarkdown-nice使用的是markdown-it

核心代碼:

const parseHtml = markdownParser.render(this.props.content.content);

return (
    <section
        dangerouslySetInnerHTML={
    {
    
            __html: parseHtml,
        }}
    />
)

markdownParsermarkdown-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分為三類:coreblockinline

core包含normalizeblockinlinelinkifyreplacementssmartquotes這些規則,會對我們傳入的markdown字符串按順序依次執行上述規則,其中就包含著blockinlnie類型的規則的執行過程,blockinline相關規則就是用來生成一個個token的,顧名思義,一個負責生成塊級類型的token,比如標題、代碼塊、錶格、項目列錶等,一個負責在塊級元素生成之後再生成內聯類型的token,比如文本、鏈接、圖片等。

block運行時會逐行掃描markdown字符串,對每一行字符串都會依次執行所有塊級rule函數,解析生成塊級token,內置的block規則有tablecodefenceblockquotehrlistheadingparagraph等。

block類型的規則處理完之後,可能會生成一種 typeinlinetoken,這種 token 屬於未完全解析的 token,所以還需要通過inline類型的token再處理一次,也就是對塊級tokencontent字段保存的字符進行解析生成內聯token,內置的inline規則有textlinkimage等。

這些解析規則都執行完後會輸出一個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}]&nbsp;&nbsp;&nbsp;${
      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;") // 用nbsp替換空格
          .replace(/span&nbsp;/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內置了很多主題:stylesmarkdown-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; // 恢複現場
};

主要的區別其實就是solveWeChatMathsolveZhihuMath方法,這兩個方法是用來解决公式的問題。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>");
  // 將空格替換成&nbsp;
  html = html.replace(/\s<span class="inline/g, '&nbsp;<span class="inline');
  // 同上
  html = html.replace(/svg><\/span>\s/g, "svg></span>&nbsp;");
  // 這個標簽上面已經替換過了,這裏為什麼還要再替換一遍
  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插件來實現的,所以有相關的開發需求可以參考一下這些優秀開源項目的實現。

原网站

版权声明
本文为[街角小林]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/164/202206130416327738.html