前言
markdown-it是一个用来解析markdown的库,它可以将markdown编译为html,然后解析时markdown-it会根据规则生成tokens,如果需要自定义,就通过rules函数对token进行处理
我现在基于markdown-it已完成第一版编辑器,现有以下功能:
- 快捷编辑按钮
- 代码块主题切换
- 同步滚动
- 目录列表生成
- 内容状态缓存
预览
目前实现效果如下
预览地址:lhrun.github.io/md-editor/
repo:github.com/LHRUN/md-ed… 欢迎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\n${source.slice(end)}`
} else {
val = `${source.slice(0, start)}\n\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>
)}
总结
可能完成的有点粗糙,以后有时间继续完善细节,有问题欢迎讨论👻