使用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 = `\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})
  }
}

來看看效果:

如果你明白了上面的功能是怎么實現的,那么標題、鏈接、圖片、橫線的實現方法我想你也明白了。

該編輯器還沒有編輯窗口和預覽窗口同步滾動的功能,馬克飛象的同步滾動效果我不知道該如何實現,如果有那位大神知道,望指教。

這是該編輯器的GitHub以及項目鏈接

進入編輯器在點擊側邊欄的設置,選擇預處理。

把HTML的預處理語言換成Markdown就可以開啟Markdown編輯模式了。

我還是個前端小白,如果覺得那些地方需要優化和改進,望指教!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM