前言
markdown-it 是一个用来解析 markdown 的库,它能够将 markdown 编译为 html,而后解析时 markdown-it 会依据规定生成 tokens,如果须要自定义,就通过 rules 函数对 token 进行解决
我当初基于 markdown-it 已实现第一版编辑器,现有以下性能:
- 快捷编辑按钮
- 代码块主题切换
- 同步滚动
- 目录列表生成
- 内容状态缓存
预览
目前实现成果如下
预览地址:https://lhrun.github.io/md-editor/
repo:https://github.com/LHRUN/md-editor 欢送 star⭐️
编辑器设计
- 页面布局分四局部,顶部是快捷工具栏,而后主体内容分三局部,编辑区域(textarea)、html 展现区域、目录列表(可展现暗藏),因为我是用 react 开发的,所以 html 字符串我是通过 dangerouslySetInnerHTML 设置
-
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 的官网实现是我能找到并能实现的最佳计划,大抵实现思路是如下
-
首先在编译时对题目元素和段落元素增加行号
/** * 注入行号 */ 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
-
滚动前计算出以后编辑区域每行对应的预览偏移间隔,有标记行号的元素间接计算 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 }
-
编辑区域滚动依据具体行获取需滚动高度
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}) }
-
预览区域滚动依据以后的滚动高度查对应编辑区域的行,而后依据计算滚动高度
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) }) }
同步滚动留神点
- 在扭转编辑内容和窗口大小时需清空计算结果,因为这两个一扭转,每行的偏移间隔就会发生变化,在滚动时须要从新计算
- 同步滚动时会有一个有限触发的问题,因为编辑区域滚动,会触发预览区域的
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