1. 程式人生 > >使用Codemirror打造Markdown編輯器

使用Codemirror打造Markdown編輯器

前幾天突然想給自己的線上編譯器加一個Markdown編輯功能,於是花了兩三天敲敲打打初步實現了這個功能。 一個Markdown編輯器需要有如下常用功能: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507135956762-950056850.png) - 粗體 - 斜體 - 中劃線 - 標題 - 連結 - 圖片 - 引用 - 程式碼 - 有序列表 - 無序列表 - 橫線 看上去想實現這些功能有點複雜,但是[Codemirror](https://link.zhihu.com/?target=https%3A//codemirror.net/)提供了很多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為列數(該行第幾個字元)。 ## 功能實現 首先是粗體,斜體,中劃線和程式碼,這四個功能實現的方法是相同的。 當用戶觸發新增粗體、斜體、中劃線或程式碼事件時,流程如下: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507140525310-374872900.png) 如上圖所示,先來說說游標沒選中文字時的處理: - 使用`cm.getCursor()`找到游標位置 - 使用`cm.getRange()`判斷前後是否有匹配字串(匹配字串代表粗體、斜體、中劃線或和程式碼的字串:`**`、`*`、`~~`和'``') 。 - 前面或後面有匹配字串 - 使用`cm.replaceRange()`清除匹配字串 - 前面或後面沒有匹配字串 - 使用`cm.replaceSelection()`新增匹配字串 具體程式碼和註釋如下: ```javascript 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() ``` 來看看效果: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507140813412-2144422748.gif) 在游標選中文字的情況下,處理過程相對來說要複雜一些: - 使用`cm.listSelections()[0]`獲取第一組選中的文字,返回游標的起始位置與結束位置 - 判斷所選文字的開頭和結尾的位置,因為游標的起始位置是相對位置而不是絕對位置,也就是說當你從上到下,從左到右來選擇文字的時候,游標起始位置所選文字開頭,否則就是末尾。 - 使用`cm.getRange()`判斷前後是否有匹配字串 - 前面或後面有匹配字串 - 使用`cm.replaceRange()`清除匹配字串 - 前面或後面沒有匹配字串 - 使用`cm.replaceSelection()`新增匹配字串 - 更新游標選取位置 具體程式碼和註釋如下: ```javascript 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() } ``` 來看看效果: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141012097-326586168.gif) 接下來我說說如何實現引用,無序列表和有序列表。 我是按照VSCode的markdown外掛的機制來處理這三種格式。當用戶操作引用,無序列表和有序列表時的處理流程如下: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141029458-129238232.png) - 判斷是否選中文字 - 已經選中文字,找到位置 - 已經選中多行 - 迴圈將每行前面加上`> `、`- `或`數字. `使其變為列表項 - 已經選中單行 - 將選中文字轉換為列表項 - 沒選中文字,找到游標位置 - 該行已經是列表 - 將列表向下延伸一行 - 該行不是列表 - 無操作 具體程式碼和註釋如下: ```javascript 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() } ``` 來看看效果: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141252156-1528824626.gif) 至於有序列表,需要先去除當前行前面的空格和製表符,再判斷是否以`數字. `開頭,如果有,便取出數字 ,下一行的數字逐步遞增。其他的地方和無序列表差不多。 具體程式碼和註釋如下: ```javascript 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}) } } ``` 來看看效果: ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141339927-357594266.gif) 如果你明白了上面的功能是怎麼實現的,那麼標題、連結、圖片、橫線的實現方法我想你也明白了。 該編輯器還沒有編輯視窗和預覽視窗同步滾動的功能,[馬克飛象](https://maxiang.io/)的同步滾動效果我不知道該如何實現,如果有那位大神知道,望指教。 這是該編輯器的[GitHub](https://github.com/Longgererer/JS-Encoder)以及[專案連結](https://www.lliiooiill.cn/JSEncoderEnhance) 進入編輯器在點選側邊欄的設定,選擇預處理。 ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141538037-1519051057.png) 把HTML的預處理語言換成Markdown就可以開啟Markdown編輯模式了。 ![](https://img2020.cnblogs.com/blog/1368022/202005/1368022-20200507141551443-1486121944.png) 我還是個前端小白,如果覺得那些地方需要優化和改進,望