使用Codemirror打造Markdown編輯器
阿新 • • 發佈:2020-05-07
前幾天突然想給自己的線上編譯器加一個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)
我還是個前端小白,如果覺得那些地方需要優化和改進,望