关于javascript:一款开源的Markdown转富文本编辑器的实现原理剖析

37次阅读

共计 18957 个字符,预计需要花费 48 分钟才能阅读完成。

笔者平时写文章应用的都是 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 插件来实现的,所以有相干的开发需要能够参考一下这些优良开源我的项目的实现。

正文完
 0