前言

markdown-it是一个用来解析markdown的库,它能够将markdown编译为html,而后解析时markdown-it会依据规定生成tokens,如果须要自定义,就通过rules函数对token进行解决
我当初基于markdown-it已实现第一版编辑器,现有以下性能:

  1. 快捷编辑按钮
  2. 代码块主题切换
  3. 同步滚动
  4. 目录列表生成
  5. 内容状态缓存

预览

目前实现成果如下

预览地址:https://lhrun.github.io/md-editor/
repo:https://github.com/LHRUN/md-editor 欢送star⭐️

编辑器设计

  1. 页面布局分四局部,顶部是快捷工具栏,而后主体内容分三局部,编辑区域(textarea)、html展现区域、目录列表(可展现暗藏),因为我是用react开发的,所以html字符串我是通过dangerouslySetInnerHTML设置
  2. markdown-it初始化

    export const MD = new MarkdownIt({  html: true, // 在源码中启用HTML标签  linkify: true, // 将相似URL的文本主动转换为链接  breaks: true, // 转换段落里的 '\n' 到 <br>  highlight: function (str, lang) {   return highlightFormatCode(str, lang)  }})  .use(MarkdownItSub)  .use(MarkdownItSup)  .use(MarkdownItMark)  .use(MarkdownItDeflist)  .use(MarkdownItTaskLists)  .use(markdownItAbbr)  .use(markdownItFootnote)  // 其余的markdownIt插件...const highlightFormatCode = (str: string, lang: string): string => {  if (lang && hljs.getLanguage(lang)) {   try {     return codeBlockStyle(hljs.highlight(lang, str, true).value)   } catch (e) {     console.error(e)   }  }  return codeBlockStyle(MD.utils.escapeHtml(str))}const codeBlockStyle = (val: string): string => {  return `<pre class="hljs" style="padding: 10px;border-radius: 10px;"><code>${val}</code></pre>`}

快捷编辑按钮

快捷便捷按钮次要是通过判断textarea的光标地位,而后通过光标地位扭转编辑器文本内容,比方增加图片

// 获取光标地位export const getCursorPosition = (editor: HTMLTextAreaElement) => {  const { selectionStart, selectionEnd } = editor  return [selectionStart, selectionEnd]}export const addImage = (  editor: HTMLTextAreaElement,  source: string,  setSource: (v: string) => void) => {  const [start, end] = getCursorPosition(editor)  let val = source  if (start === end) {    val = `${source.slice(0, start)}\n![图片形容](url)\n${source.slice(end)}`  } else {    val = `${source.slice(0, start)}\n![${source.slice(      start,      end    )}](url)\n${source.slice(end)}`  }  setSource(val)}

代码块主题切换

  • 代码块高亮我是采纳了highlight.js,因为这个库提供了很多主题款式,所以主题切换,我只须要扭转css link即可

    // codeTheme就是已选的主题名字useEffect(() => {  if (codeTheme) {    switchLink(      'code-style',      `https://cdn.bootcdn.net/ajax/libs/highlight.js/11.6.0/styles/${codeTheme}.min.css`    )  }}, [codeTheme])/** * 切换html css link * @param key link key 指定惟一标识,用于切换link * @param href link href */export const switchLink = (key: string, href: string) => {  const head = document.head  const oldLink = head.getElementsByClassName(key)  if (oldLink.length) head.removeChild(oldLink[0])  const newLink = document.createElement('link')  newLink.setAttribute('rel', 'stylesheet')  newLink.setAttribute('type', 'text/css')  newLink.setAttribute('class', key)  newLink.setAttribute('href', href)  newLink.onerror = (e) => {    console.error(e)    message.error('获取css link失败')  }  head.appendChild(newLink)}

同步滚动

同步滚动是我认为最难搞的一个性能,因为我不想仅仅通过百分比来计算滚动间隔,因为这样的话如果编辑区域增加了一堆图片,预览就会有十分大的高度差。 我在网上找了许多计划,最初发现markdown-it的官网实现是我能找到并能实现的最佳计划,大抵实现思路是如下

  1. 首先在编译时对题目元素和段落元素增加行号

    /** * 注入行号 */const injectLineNumbers: Renderer.RenderRule = (   tokens,   idx,   options,   _env,   slf) => {   let line   if (tokens[idx].map && tokens[idx].level === 0) {     line = (tokens[idx].map as [number, number])[0]     tokens[idx].attrJoin('class', 'line')     tokens[idx].attrSet('data-line', String(line))   }   return slf.renderToken(tokens, idx, options)}MD.renderer.rules.heading_open = MD.renderer.rules.paragraph_open = injectLineNumbers
  2. 滚动前计算出以后编辑区域每行对应的预览偏移间隔,有标记行号的元素间接计算offset,未标记行号的元素就等比计算

    /** * 获取编辑区域每行对应的预览偏移间隔 * @param editor 编辑元素 * @param review 预览元素 * @returns number[] */const buildScrollMap = (  editor: HTMLTextAreaElement,  review: HTMLDivElement) => {  const lineHeightMap: number[] = []  let linesCount = 0 // 编辑区总行数  /*** 长期创立元素获取每次换行之间的总行数*/  const sourceLine = document.createElement('div')  sourceLine.style.position = 'absolute'  sourceLine.style.visibility = 'hidden'  sourceLine.style.height = 'auto'  sourceLine.style.width = `${editor.clientWidth}px`  sourceLine.style.fontSize = '15px'  sourceLine.style.lineHeight = `${LINE_HEIGHT}px`  document.body.appendChild(sourceLine)  let acc = 0  editor.value.split('\n').forEach((str) => {   lineHeightMap.push(acc)   if (str.length === 0) {     acc++     return   }   sourceLine.textContent = str   const h = sourceLine.offsetHeight   acc += Math.round(h / LINE_HEIGHT)  })  sourceLine.remove()  lineHeightMap.push(acc)  linesCount = acc  // 最终输入的偏移map  const _scrollMap: number[] = new Array(linesCount).fill(-1)  /*** 获取标记行号的offset间隔*/  const nonEmptyList = []  nonEmptyList.push(0)  _scrollMap[0] = 0  document.querySelectorAll('.line').forEach((el) => {     let t: string | number = el.getAttribute('data-line') as string     if (t === '') {       return     }     t = lineHeightMap[Number(t)]     if (t !== 0) {       nonEmptyList.push(t)     }     _scrollMap[t] = Math.round((el as HTMLElement).offsetTop - review.offsetTop)  })  nonEmptyList.push(linesCount)  _scrollMap[linesCount] = review.scrollHeight /**  * 未标记行号的元素等比计算  */  let pos = 0  for (let i = 1; i < linesCount; i++) {   if (_scrollMap[i] !== -1) {     pos++     continue   }   const a = nonEmptyList[pos]   const b = nonEmptyList[pos + 1]   _scrollMap[i] = Math.round(     (_scrollMap[b] * (i - a) + _scrollMap[a] * (b - i)) / (b - a)   )  }  return _scrollMap}
  3. 编辑区域滚动依据具体行获取需滚动高度

    export const editorScroll = (  editor: HTMLTextAreaElement,  preview: HTMLDivElement) => { if (!scrollMap) {   scrollMap = buildScrollMap(editor, preview) } const lineNo = Math.floor(editor.scrollTop / LINE_HEIGHT) const posTo = scrollMap[lineNo] preview.scrollTo({ top: posTo })}
  4. 预览区域滚动依据以后的滚动高度查对应编辑区域的行,而后依据计算滚动高度

    export const previewScroll = (  editor: HTMLTextAreaElement,  preview: HTMLDivElement) => { if (!scrollMap) {   scrollMap = buildScrollMap(editor, preview) } const lines = Object.keys(scrollMap) if (lines.length < 1) {   return } let line = lines[0] for (let i = 1; i < lines.length; i++) {   if (scrollMap[Number(lines[i])] < preview.scrollTop) {     line = lines[i]     continue   }   break } editor.scrollTo({ top: LINE_HEIGHT * Number(line) })}

同步滚动留神点

  1. 在扭转编辑内容和窗口大小时需清空计算结果,因为这两个一扭转,每行的偏移间隔就会发生变化,在滚动时须要从新计算
  2. 同步滚动时会有一个有限触发的问题,因为编辑区域滚动,会触发预览区域的scrollTo(),而后预览区域的滚动监听办法就会被触发,而后这样就会有限触发上来,所以须要一个变量记住以后的手动滚动的区域,进行限度

目录列表生成

目录列表通过rules的heading_open办法,获取以后题目的token,而后通过token得出题目的具体内容进行拼接,最初依据level计算字体大小

  • 获取题目内容

    const getTitle = (tokens: Token[], idx: number) => {  const { children } = tokens[idx + 1]  const { markup } = tokens[idx]  const val = children?.reduce((acc, cur) => `${acc}${cur.content}`, '') || ''  toc.push({    val,    level: markup.length  })}
  • html展现

    {showToc && (  <div className={styles.toc}>    <div className={styles.tocTitle}>目录</div>    <div>      {tocList.map(({ val, level }, index) => {        const fontSize = ((7 - level) / 10) * 40        return (          <div            style={{              marginLeft: `${level * 10}px`,              fontSize: `${fontSize > 12 ? fontSize : 12}px`            }}            key={index}          >            {val}          </div>        )      })}    </div>  </div>)}

总结

可能实现的有点毛糙,当前有工夫持续欠缺细节,有问题欢送探讨

参考资料

  • 手把手带你10分钟手撸一个繁难的Markdown编辑器
  • markdown-it.github.io