前幾天突然想給自己的在線編譯器加一個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})
}
}
來看看效果:
如果你明白了上面的功能是怎么實現的,那么標題、鏈接、圖片、橫線的實現方法我想你也明白了。
該編輯器還沒有編輯窗口和預覽窗口同步滾動的功能,馬克飛象的同步滾動效果我不知道該如何實現,如果有那位大神知道,望指教。
進入編輯器在點擊側邊欄的設置,選擇預處理。
把HTML的預處理語言換成Markdown就可以開啟Markdown編輯模式了。
我還是個前端小白,如果覺得那些地方需要優化和改進,望指教!