基于markdown-it打造的markdown编辑器

前言

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>
    )}

总结

可能完成的有点粗糙,以后有时间继续完善细节,有问题欢迎讨论👻

参考资料