乐趣区

关于前端:基于markdownit打造的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>
    )}

总结

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

参考资料

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