阅读 119

使用Codemirror打造Markdown编辑器

前几天突然想给自己的在线编译器加一个Markdown编辑和预览功能,于是花了两三天敲敲打打初步实现了这个功能。

编辑功能

  • 粗体
  • 斜体
  • 中划线
  • 标题
  • 链接
  • 图片
  • 引用
  • 代码
  • 有序列表
  • 无序列表
  • 横线

看上去想实现这些功能有点复杂,但是Codemirror提供了很多API可以更方便地修改编辑内容。

在阐述我是如何实现这些功能前,我先将实现时用到的API列出来。

cm.somethingSelected()

是否选中编辑器内的任何文本。

cm.listSelections()

选中的文本信息。

cm.getRange(from: {line, ch}, to: {line, ch}, ?separator: string)

在编辑器中的给定点之间获取文本。

cm.replaceRange(replacement: string, from: {line, ch}, to: {line, ch}, ?origin: string)

用replacement替换给定点之间的文本 。

cm.setCursor(pos: {line, ch}|number, ?ch: number, ?options: object)

设置光标位置。

cm.getCursor(?start: string)

获取光标位置 。

cm.setSelection(anchor: {line, ch}, ?head: {line, ch}, ?options: object)

设置一个选择范围。

cm.getLine(n: integer)

获取某行文本内容。

上面的API中,cm为Codemirror实例,也就是编辑器实例。line为行数,ch为列数(该行第几个字符)。

功能实现

首先是粗体,斜体,中划线和代码,这四个功能实现的方法是相同的。

当用户触发添加粗体、斜体、中划线或代码事件时,流程如下:

如上图所示,先来说说光标没选中文本时的处理:

  • 使用cm.getCursor()找到光标位置
  • 使用cm.getRange()判断前后是否有匹配字符串(匹配字符串代表粗体、斜体、中划线或和代码的字符串:***~~``) 。
    • 前面或后面有匹配字符串
      • 使用cm.replaceRange()清除匹配字符串
    • 前面或后面没有匹配字符串
      • 使用cm.replaceSelection()添加匹配字符串

具体代码和注释如下:

const changePos = matchStr.length
let preAlready = false, aftAlready = false // 前后是否已经有相应样式标识,如**,`,~等   
const cursor = cm.getCursor()
const { line: curLine, ch: curPos } = cursor // 获取光标位置
// 判断前后是否有matchStr
cm.getRange({ line: curLine, ch: curPos - changePos }, cursor) ===
  matchStr && (preAlready = true)
cm.getRange(cursor, { line: curLine, ch: curPos + changePos }) ===
  matchStr && (aftAlready = true)
// 去除前后的matchStr
if (aftAlready && preAlready) {
  cm.replaceRange('', cursor, { line: curLine, ch: curPos + changePos })
  cm.replaceRange('', { line: curLine, ch: curPos - changePos }, cursor)
  cm.setCursor({ line: curLine, ch: curPos - changePos })
} else if (!preAlready && !aftAlready) {
  // 前后都没有matchStr
  cm.replaceSelection(matchStr + matchStr)
  cm.setCursor({ line: curLine, ch: curPos + changePos})
}
cm.focus()
复制代码

来看看效果:

在光标选中文本的情况下,处理过程相对来说要复杂一些:

  • 使用cm.listSelections()[0]获取第一组选中的文本,返回光标的起始位置与结束位置
  • 判断所选文字的开头和结尾的位置,因为光标的起始位置是相对位置而不是绝对位置,也就是说当你从上到下,从左到右来选择文本的时候,光标起始位置所选文本开头,否则就是末尾。
  • 使用cm.getRange()判断前后是否有匹配字符串
    • 前面或后面有匹配字符串
      • 使用cm.replaceRange()清除匹配字符串
    • 前面或后面没有匹配字符串
      • 使用cm.replaceSelection()添加匹配字符串
  • 更新光标选取位置

具体代码和注释如下:

const changePos = matchStr.length // matchStr为传入参数,可以是'**','*','~~','`'或者其他符合markdown语法的字符串
let preAlready = false,aftAlready = false
if (cm.somethingSelected()) {
    // 如果选中了文本
    const selectContent = cm.listSelections()[0] // 第一个选中的文本
    let { anchor, head } =selectContent // 前后光标位置
    head.line >= anchor.line &&head.sticky === 'before' &&([head, anchor] = [anchor, head])
    let { line: preLine, ch: prePos } = head
    let { line: aftLine, ch: aftPos } = anchor
    // 判断前后是否有matchStr
    cm.getRange({ line: preLine, ch: prePos - changePos }, head) ===
      matchStr && (preAlready = true)
    cm.getRange(anchor, { line: aftLine, ch: aftPos + changePos }) ===
      matchStr && (aftAlready = true)
    // 去除前后的matchStr
    aftAlready &&
      cm.replaceRange('', anchor, { line: aftLine, ch: aftPos + changePos })
    preAlready &&
      cm.replaceRange('', { line: preLine, ch: prePos - changePos }, head)
    if (!preAlready && !aftAlready) {
      // 前后都没有matchStr
      cm.setCursor(anchor)
      cm.replaceSelection(matchStr)
      cm.setCursor(head)
      cm.replaceSelection(matchStr)
      prePos += changePos
      aftPos += aftLine === preLine ? changePos : 0
      cm.setSelection(
        { line: aftLine, ch: aftPos },
        { line: preLine, ch: prePos }
      )
    } else if (!preAlready) {
      // 只有后面有matchStr
      cm.setCursor(head)
      cm.replaceSelection(matchStr)
      prePos += changePos
      aftPos += aftLine === preLine ? changePos : 0
      cm.setSelection(
        { line: aftLine, ch: aftPos },
        { line: preLine, ch: prePos }
      )
    } else if (!aftAlready) {
      // 只有前面有matchStr
      cm.setCursor({ line: aftLine, ch: aftPos - changePos })
      cm.replaceSelection(matchStr)
      prePos -= changePos
      aftPos -= aftLine === preLine ? changePos : 0
      cm.setSelection(
        { line: aftLine, ch: aftPos },
        { line: preLine, ch: prePos }
      )
    }
    cm.focus()
}
复制代码

来看看效果:

接下来我说说如何实现引用,无序列表和有序列表。

我是按照VSCode的markdown插件的机制来处理这三种格式。当用户操作引用,无序列表和有序列表时的处理流程如下:

  • 判断是否选中文本
    • 已经选中文本,找到位置
      • 已经选中多行
        • 循环将每行前面加上>-数字. 使其变为列表项
      • 已经选中单行
        • 将选中文本转换为列表项
    • 没选中文本,找到光标位置
      • 该行已经是列表
        • 将列表向下延伸一行
      • 该行不是列表
        • 无操作

具体代码和注释如下:

function addList (cm, matchStr) {
  // 添加引用和无序列表, matchStr为传入参数,可以是
  if (cm.somethingSelected()) {
    const selectContent = cm.listSelections()[0] // 第一个选中的文本
    let { anchor, head } =selectContent
    head.line >= anchor.line &&head.sticky === 'before' &&([head, anchor] = [anchor, head])
    let preLine = head.line
    let aftLine = anchor.line
    if (preLine !== aftLine) {
      // 选中了多行,在每行前加上匹配字符
      let pos = matchStr.length
      for (let i = preLine;i <= aftLine;i++) {
        cm.setCursor({ line: i, ch: 0 })
        cm.replaceSelection(matchStr)
        i === aftLine && (pos += cm.getLine(i).length)
      }
      cm.setCursor({ line: aftLine, ch: pos })
      cm.focus()
    } else {
      // 检测开头是否有匹配的字符串,有就将其删除
      const preStr = cm.getRange({ line: preLine, ch: 0 }, head)
      if (preStr === matchStr) {
        cm.replaceRange('', { line: preLine, ch: 0 }, head)
      } else {
        const selectVal = cm.getSelection()
        let replaceStr = `\n\n${matchStr}${selectVal}\n\n`
        cm.replaceSelection(replaceStr)
        cm.setCursor({ line: preLine + 2, ch: (matchStr + selectVal).length})
      }
    }
  } else {
    const cursor = cm.getCursor()
    let { line: curLine, ch: curPos } = cursor // 获取光标位置
    let preStr = cm.getRange({ line: curLine, ch: 0 }, cursor)
    let preBlank = ''
    if (/^( |\t)+/.test(preStr)) {
      // 有序列表标识前也许会有空格或tab缩进
      preBlank = preStr.match(/^( |\t)+/)[0]
    }
    curPos && (matchStr = `\n${preBlank}${matchStr}`) && ++curLine
    cm.replaceSelection(matchStr )
    cm.setCursor({ line: curLine, ch: matchStr.length - 1})
  }
  cm.focus()
}
复制代码

来看看效果:

至于有序列表,需要先去除当前行前面的空格和制表符,再判断是否以数字. 开头,如果有,便取出数字 ,下一行的数字逐步递增。其他的地方和无序列表差不多。

具体代码和注释如下:

function addOrderList (cm) {
  // 添加有序列表
  if (cm.somethingSelected()) {
    const selectContent = cm.listSelections()[0] // 第一个选中的文本
    let { anchor, head } = selectContent
    head.line >= anchor.line &&head.sticky === 'before' &&([head, anchor] = [anchor, head])
    let preLine = head.line
    let aftLine = anchor.line
    if (preLine !== aftLine) {
      // 选中了多行,在每行前加上匹配字符
      let preNumber = 0
      let pos = 0
      for (let i = preLine;i <= aftLine;i++) {
        cm.setCursor({ line: i, ch: 0 })
        const replaceStr = `${++preNumber}. `
        cm.replaceSelection(replaceStr)
        if (i === aftLine) {
          pos += (replaceStr + cm.getLine(i)).length
        }
      }
      cm.setCursor({ line: aftLine, ch: pos })
      cm.focus()
    } else {
      const selectVal = cm.getSelection()
      let preStr = cm.getRange({ line: preLine, ch: 0 }, head)
      let preNumber = 0
      let preBlank = ''
      if (/^( |\t)+/.test(preStr)) {
        // 有序列表标识前也许会有空格或tab缩进
        preBlank = preStr.match(/^( |\t)+/)[0]
        preStr = preStr.trimLeft()
      }
      if (/^\d+(\.) /.test(preStr)) {
        // 是否以'数字. '开头,找出前面的数字
        preNumber = Number.parseInt(preStr.match(/^\d+/)[0])
      }
      let replaceStr = `\n${preBlank}${preNumber + 1}. ${selectVal}\n`
      cm.replaceSelection(replaceStr)
      cm.setCursor({ line: preLine + 1, ch: replaceStr.length})
    }
  } else {
    const cursor = cm.getCursor()
    let { line: curLine, ch: curPos } = cursor // 获取光标位置
    let preStr = cm.getRange({ line: curLine, ch: 0 }, cursor)
    let preNumber = 0
    let preBlank = ''
    if (/^( |\t)+/.test(preStr)) {
      // 有序列表标识前也许会有空格或tab缩进
      preBlank = preStr.match(/^( |\t)+/)[0]
      preStr = preStr.trimLeft()
    }
    if (/^\d+(\.) /.test(preStr)) {
      // 是否以'数字. '开头,找出前面的数字
      preNumber = Number.parseInt(preStr.match(/^\d+/)[0])
    }
    let replaceStr = `\n${preBlank}${preNumber + 1}. `
      cm.replaceSelection(replaceStr)
      cm.setCursor({ line: curLine + 1, ch: replaceStr.length - 1})
  }
}
复制代码

来看看效果:

同步滚动

说完了编辑,再说说预览功能,一个支持markdown的编辑器怎么能没有同步滚动呢?

想要实现同步滚动有两种方案:

均匀滚动

均匀滚动就是计算编辑窗口和预览窗口的滚动条高度以及这两个高度的比例,比方说编辑窗口的滚动条高度为2000px,预览窗口滚动条高度为4000px

那么每当编辑窗口的滚动条滚动1px,那么预览窗口就滚动2px

这样写的好处是方便且性能好,但缺陷很明显:

如果我在文档中加入了图片,并且图片很大,那么就会导致编辑窗口中显示的代码和预览窗口中的元素是不对应的,在代码很多,滚动条拉的越下的时候效果越明显。我这里拿Editor.md这个工具来举个例子:

1634094569(1).jpg

我把滚动条拉的很下,我们可以发现编辑窗口显示的是Emoji的内容,但预览窗口显示的却是与之无关的公式和流程图。而公式和流程图所对应的代码实际上在Emoji的下面,这就比较影响编辑体验了。

考虑到这个弊端,我决定使用元素顶部定位方法。

元素顶部定位

元素顶部定位的原理是这样的:

Codemirror可以利用编辑器滚动条的位置来获取当前显示在最顶部的行的行号:

const scrollInfo = cm.getScrollInfo()
const lineNumber = cm.lineAtHeight(scrollInfo.top, 'local')
复制代码

获取了行号,就可以获取该行及以上的所有代码。

然后我们使用 marked 工具将代码编译成 HTML 字符串,并使用 DOMParser 将其转换成真正的DOM元素:

const range = cm.getRange({ line: 0, ch: null }, { line: lineNumber, ch: null })
const marked = require('marked')
const doc = new DOMParser().parseFromString(marked(range), 'text/html')
复制代码

为了考虑性能,我们不能匹配所有标签,因此需要制定一个匹配标签的集合:

matchTagsStr = 'p, h1, h2, h3, h4, h5, h6, li, pre, blockquote, hr, table, code>span'
复制代码

上面的这些就是我们必须要匹配的标签和选择器,我们将 doc 中符合匹配条件的元素选出来:

const matchEleList = doc.body.querySelectorAll(matchTagsStr)
复制代码

那么 matchEleList 中最后的元素就是我们在预览视口中看到的第一个元素。

接下来的过程非常简单:

const cm = this.cm
// 监听codemirror编辑器滑动事件
cm.on('scroll', () => {
  // 获取之前代码解析出来的匹配列表长度 
  const length = matchEleList.length
  
  if (length) {
    // 如果length不为0,获取iframe中符合matchTagsStr选择器的元素列表
    const matchIframeEleList = window.document.body.querySelectorAll(matchTagsStr)
    
    if (length === 1) length--
    // 获取matchIframeEleList中第length个元素并将视口滑动到该元素的位置
    if (matchIframeEleList.length) {
      const target = matchIframeEleList[length]
      target.scrollIntoView()
    }
  } else {
    window.scrollTo(0, 0)
  }
})
复制代码

上述代码只是为了帮助理解,实际代码比上面的要复杂。

到此我们就实现了滚动编辑窗口,预览窗口也滚动到对应位置的效果了:

GIF.gif

这样,一个简单但好用的markdown编辑器就完成啦!

总结

Codemirror是一个扩展性很强的代码编辑工具,通过Codemirror提供的api我们可以实现很多功能。

这是我的在线编辑器地址和github仓库地址:

JS-Encoder离线版

JS-Encoder github

我还是个前端小白,如果觉得那些地方需要优化和改进,望指教!

文章分类
前端
文章标签