前言
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> )}
总结
可能完成的有点粗糙,以后有时间继续完善细节,有问题欢迎讨论👻