笔者平时写文章应用的都是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-it、marked、showdown,markdown-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内置了很多主题: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; // 复原现场};

次要的区别其实就是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插件来实现的,所以有相干的开发需要能够参考一下这些优良开源我的项目的实现。