• 使用Codemirror打造Markdown编辑器


    前几天突然想给自己的在线编译器加一个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 = `
    
    ${matchStr}${selectVal}
    
    `
            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 (/^( |	)+/.test(preStr)) {
          // 有序列表标识前也许会有空格或tab缩进
          preBlank = preStr.match(/^( |	)+/)[0]
        }
        curPos && (matchStr = `
    ${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 (/^( |	)+/.test(preStr)) {
            // 有序列表标识前也许会有空格或tab缩进
            preBlank = preStr.match(/^( |	)+/)[0]
            preStr = preStr.trimLeft()
          }
          if (/^d+(.) /.test(preStr)) {
            // 是否以'数字. '开头,找出前面的数字
            preNumber = Number.parseInt(preStr.match(/^d+/)[0])
          }
          let replaceStr = `
    ${preBlank}${preNumber + 1}. ${selectVal}
    `
          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 (/^( |	)+/.test(preStr)) {
          // 有序列表标识前也许会有空格或tab缩进
          preBlank = preStr.match(/^( |	)+/)[0]
          preStr = preStr.trimLeft()
        }
        if (/^d+(.) /.test(preStr)) {
          // 是否以'数字. '开头,找出前面的数字
          preNumber = Number.parseInt(preStr.match(/^d+/)[0])
        }
        let replaceStr = `
    ${preBlank}${preNumber + 1}. `
          cm.replaceSelection(replaceStr)
          cm.setCursor({ line: curLine + 1, ch: replaceStr.length - 1})
      }
    }
    

    来看看效果:

    如果你明白了上面的功能是怎么实现的,那么标题、链接、图片、横线的实现方法我想你也明白了。

    该编辑器还没有编辑窗口和预览窗口同步滚动的功能,马克飞象的同步滚动效果我不知道该如何实现,如果有那位大神知道,望指教。

    这是该编辑器的GitHub以及项目链接

    进入编辑器在点击侧边栏的设置,选择预处理。

    把HTML的预处理语言换成Markdown就可以开启Markdown编辑模式了。

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

  • 相关阅读:
    error: <item> inner element must either be a resource reference or empty.
    PEM routines:PEM_read_bio:no start line
    Android Https双向认证 + GRPC
    git tag用法
    Linux文件查找
    Detected problems with API compatibility(visit g.co/dev/appcompat for more info)
    Android NDK开发调试
    Beyond-Compare 4 -linux 破解
    Ubuntu下Gradle环境配置
    多线程系列一
  • 原文地址:https://www.cnblogs.com/FrankLongger/p/12842764.html
Copyright © 2020-2023  润新知