基于markdown-it打造的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
  1. 滚动前计算出当前编辑区域每行对应的预览偏移距离,有标记行号的元素直接计算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
}
  1. 编辑区域滚动根据具体行获取需滚动高度
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 })
}
  1. 预览区域滚动根据当前的滚动高度查对应编辑区域的行,然后根据计算滚动高度
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>
)}

总结

可能完成的有点粗糙,以后有时间继续完善细节,有问题欢迎讨论👻

参考资料

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
vsc-markdown-image是一款在Visual Studio Code(VSC)编辑器中用于在Markdown文档中插入图像的插件。Markdown是一种轻量级的标记语言,用于快速编写和格式化文本,而vsc-markdown-image可以帮助我们更方便地将图像添加到Markdown文档中。 使用vsc-markdown-image插件非常简单。首先,在VSC编辑器中打开一个Markdown文档。然后,在你想要插入图像的位置,输入以下代码: ``` ![描述](图像路径) ``` 其中,描述是一个可选的文本,用于描述图像,图像路径是指图像在计算机中的位置。你可以将图像路径指向本地文件系统或者网络上的图像URL。 插入图像后,VSC编辑器会自动将该图像显示在Markdown文档中,并使用描述作为图像的替代文本。你可以通过将鼠标悬停在图像上来查看描述,或者点击图像来进一步查看。 此外,vsc-markdown-image还提供了一系列功能,如自动完成和语法高亮。它能够快速识别和显示图像路径,减少了手动输入的错误。此外,它还可以自动调整图像的大小和位置,以匹配你的Markdown文档的布局。 总之,vsc-markdown-image是一款非常实用的插件,可以帮助你在Markdown文档中轻松插入和管理图像。它提供了简单易用的功能,使你的文档更加丰富和有吸引力。无论你是要创建一个技术文档、一篇博客还是一本电子书,vsc-markdown-image都能提供便利的图像插入和管理方式。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值