关于前端:基于markdownit打造的markdown编辑器

81次阅读

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

前言

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

正文完
 0