富文本編輯器相信大家都用過,相關的開源項目也很多,雖然具體的實作不一樣,但是大部分都是使用DOM實作的,但其實還有一種實作方式,那就是使用HTML5的canvas,本文會帶大家使用canvas簡單實作一個類似Word的富文本編輯器,話不多說,開始吧。
最終效果搶先看:https://wanglin2.github.io/canvas-editor-demo/。
基本資料結構
首先要說明我們渲染的資料不是html字元串,而是結構化的json資料,為了簡單起見,暫時隻支援文本,完整的結構如下:
[
{
value: '理',// 文字内容
color: '#000',// 文字顔色
size: 16// 文字大小
},
{
value: '想',
background: 'red',// 文字背景顔色
lineheight: 1// 行高,倍數
},
{
value: '青',
bold: true// 加粗
},
{
value: '\n'// 換行
},
{
value: '年',
italic: true// 斜體
},
{
value: '實',
underline: true// 下劃線
},
{
value: '驗',
linethrough: true// 中劃線
},
{
value: '室',
fontfamily: ''// 字型
}
]
可以看到我們把文本的每個字元都作為一項,value屬性儲存字元,其他的文字樣式通過各自的屬性儲存。
我們的canvas編輯器原理很簡單,實作一個渲染方法render,能夠将上述的資料渲染出來,然後監聽滑鼠的點選事件,在點選的位置渲染一個閃爍的光标,再監聽鍵盤的輸入事件,根據輸入、删除、回車等不同類型的按鍵事件更新我們的資料,再将畫布清除,重新渲染即可達到編輯的效果,實際上就是資料驅動視圖,相信大家都很熟悉了。
繪制頁面樣式
我們模拟的是Word,先來看一下Word的頁面樣式:
基本特征如下:
- 支援分頁
- 四周存在内邊距
- 四個角有個直角折線
當然,還支援頁眉、頁腳、頁碼,這個我們就不考慮了。
是以我們的編輯器類如下:
class CanvasEditor {
constructor(container, data, options = {}) {
this.container = container // 容器元素
this.data = data // 資料
this.options = Object.assign(
{
pageWidth: 794, // 紙張寬度
pageHeight: 1123, // 紙張高度
pagePadding: [100, 120, 100, 120], // 紙張内邊距,分别為:上、右、下、左
pageMargin: 20,// 頁面之間的間隔
pagePaddingIndicatorSize: 35, // 紙張内邊距訓示器的大小,也就是四個直角的邊長
pagePaddingIndicatorColor: '#BABABA', // 紙張内邊距訓示器的顔色,也就是四個直角的邊顔色
},
options
)
this.pageCanvasList = [] // 頁面canvas清單
this.pageCanvasCtxList = [] // 頁面canvas繪圖上下文清單
}
}
接下來添加一個建立頁面的方法:
class CanvasEditor {
// 建立頁面
createPage() {
let { pageWidth, pageHeight, pageMargin } = this.options
let canvas = document.createElement('canvas')
canvas.width = pageWidth
canvas.height = pageHeight
canvas.style.cursor = 'text'
canvas.style.backgroundColor = '#fff'
canvas.style.boxShadow = '#9ea1a566 0 2px 12px'
canvas.style.marginBottom = pageMargin + 'px'
this.container.appendChild(canvas)
let ctx = canvas.getContext('2d')
this.pageCanvasList.push(canvas)
this.pageCanvasCtxList.push(ctx)
}
}
很簡單,建立一個canvas元素,設定寬高及樣式,然後添加到容器元素,最後收集到清單中。
接下來是繪制四個直角的方法:
class CanvasEditor {
// 繪制頁面四個直角訓示器
renderPagePaddingIndicators(pageNo) {
let ctx = this.pageCanvasCtxList[pageNo]
if (!ctx) {
return
}
let {
pageWidth,
pageHeight,
pagePaddingIndicatorColor,
pagePadding,
pagePaddingIndicatorSize
} = this.options
ctx.save()
ctx.strokeStyle = pagePaddingIndicatorColor
let list = [
// 左上
[
[pagePadding[3], pagePadding[0] - pagePaddingIndicatorSize],
[pagePadding[3], pagePadding[0]],
[pagePadding[3] - pagePaddingIndicatorSize, pagePadding[0]]
],
// 右上
[
[pageWidth - pagePadding[1], pagePadding[0] - pagePaddingIndicatorSize],
[pageWidth - pagePadding[1], pagePadding[0]],
[pageWidth - pagePadding[1] + pagePaddingIndicatorSize, pagePadding[0]]
],
// 左下
[
[pagePadding[3], pageHeight - pagePadding[2] + pagePaddingIndicatorSize],
[pagePadding[3], pageHeight - pagePadding[2]],
[pagePadding[3] - pagePaddingIndicatorSize, pageHeight - pagePadding[2]]
],
// 右下
[
[pageWidth - pagePadding[1], pageHeight - pagePadding[2] + pagePaddingIndicatorSize],
[pageWidth - pagePadding[1], pageHeight - pagePadding[2]],
[pageWidth - pagePadding[1] + pagePaddingIndicatorSize, pageHeight - pagePadding[2]]
]
]
list.forEach(item => {
item.forEach((point, index) => {
if (index === 0) {
ctx.beginPath()
ctx.moveTo(...point)
} else {
ctx.lineTo(...point)
}
if (index >= item.length - 1) {
ctx.stroke()
}
})
})
ctx.restore()
}
}
代碼很多,但是邏輯很簡單,就是先計算出每個直角的三個端點坐标,然後調用canvas繪制線段的方法繪制出來即可。效果如下:
渲染内容
接下來就到我們的核心了,把前面的資料通過canvas繪制出來,當然繪制出來是結果,中間需要經過一系列步驟。我們的大緻做法大緻如下:
1.周遊資料清單,計算出每項資料的字元寬高
2.根據頁面寬度,計算出每一行包括的資料項,同時計算出每一行的寬度和高度,高度即為這一行中最高的資料項的高度
3.逐行進行繪制,同時根據頁面高度判斷,如果超出目前頁,則繪制到下一頁
計算行資料
canvas提供了一個measureText方法用來測量文本,但是傳回隻有width,沒有height,那麼怎麼得到文本的高度呢,其實可以通過傳回的另外兩個字段actualBoundingBoxAscent、actualBoundingBoxDescent,這兩個傳回值的含義是從textBaseline屬性标明的水準線到渲染文本的矩形邊界頂部、底部的距離,示意圖如下:
很明顯,文本的高度可以通過actualBoundingBoxAscent + actualBoundingBoxDescent得到。
當然要準确擷取一個文本的寬高,跟它的字号、字型等都相關,是以通過這個方法測量前需要先設定這些文本樣式,這個可以通過font屬性進行設定,font屬性是一個複合屬性,取值和css的font屬性是一樣的,示例如下:
ctx.font = `italic 400 12px sans-serif`
從左到右依次是font-style、font-weight、font-size、font-family。
是以我們需要寫一個方法來拼接這個字元串:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.options = Object.assign(
{
// ...
color: '#333',// 文字顔色
fontSize: 16,// 字号
fontFamily: 'Yahei',// 字型
},
options
)
// ...
}
// 拼接font字元串
getFontStr(element) {
let { fontSize, fontFamily } = this.options
return `${element.italic ? 'italic ' : ''} ${element.bold ? 'bold ' : ''} ${
element.size || fontSize
}px ${element.fontfamily || fontFamily} `
}
}
需要注意的是即使在font中設定了行高在canvas中也不會生效,因為canvas規範強制把它設成了normal,無法修改,那麼怎麼實作行高呢,很簡單,自己處理就好了,比如行高1.5,那麼就是文本實際的高度就是文本高度 * 1.5。
this.options = Object.assign(
{
// ...
lineHeight: 1.5,// 行高,倍數
},
options
)
現在可以來周遊資料進行計算了:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.rows = [] // 渲染的行資料
}
// 計算行渲染資料
computeRows() {
let { pageWidth, pagePadding, lineHeight } = this.options
// 實際内容可用寬度
let contentWidth = pageWidth - pagePadding[1] - pagePadding[3]
// 建立一個臨時canvas用來測量文本寬高
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// 行資料
let rows = []
rows.push({
width: 0,
height: 0,
elementList: []
})
this.data.forEach(item => {
let { value, lineheight } = item
// 實際行高倍數
let actLineHeight = lineheight || lineHeight
// 擷取文本寬高
let font = this.getFontStr(item)
ctx.font = font
let { width, actualBoundingBoxAscent, actualBoundingBoxDescent } =
ctx.measureText(value)
// 尺寸資訊
let info = {
width,
height: actualBoundingBoxAscent + actualBoundingBoxDescent,
ascent: actualBoundingBoxAscent,
descent: actualBoundingBoxDescent
}
// 完整資料
let element = {
...item,
info,
font
}
// 判斷目前行是否能容納
let curRow = rows[rows.length - 1]
if (curRow.width + info.width <= contentWidth && value !== '\n') {
curRow.elementList.push(element)
curRow.width += info.width
curRow.height = Math.max(curRow.height, info.height * actLineHeight)
} else {
rows.push({
width: info.width,
height: info.height * actLineHeight,
elementList: [element]
})
}
})
this.rows = rows
}
}
建立一個臨時的canvas來測量文本字元的寬高,周遊所有資料,如果目前行已滿,或者遇到換行符,那麼新建立一行。行高由這一行中最高的文字的高度和行高倍數相乘得到。
渲染行資料
得到了行資料後,接下來就可以繪制到頁面上了。
class CanvasEditor {
// 渲染
render() {
this.computeRows()
this.renderPage()
}
// 渲染頁面
renderPage() {
let { pageHeight, pagePadding } = this.options
// 頁面内容實際可用高度
let contentHeight = pageHeight - pagePadding[0] - pagePadding[2]
// 從第一頁開始繪制
let pageIndex = 0
let ctx = this.pageCanvasCtxList[pageIndex]
// 目前頁繪制到的高度
let renderHeight = 0
// 繪制四個角
this.renderPagePaddingIndicators(pageIndex)
this.rows.forEach(row => {
if (renderHeight + row.height > contentHeight) {
// 目前頁繪制不下,需要建立下一頁
pageIndex++
// 下一頁沒有建立則先建立
let page = this.pageCanvasList[pageIndex]
if (!page) {
this.createPage()
}
this.renderPagePaddingIndicators(pageIndex)
ctx = this.pageCanvasCtxList[pageIndex]
renderHeight = 0
}
// 繪制目前行
this.renderRow(ctx, renderHeight, row)
// 更新目前頁繪制到的高度
renderHeight += row.height
})
}
}
很簡單,周遊行資料進行渲染,用一個變量renderHeight記錄目前頁已經繪制的高度,如果超出一頁的高度,那麼建立下一頁,複位renderHeight,重複繪制步驟直到所有行都繪制完畢。
繪制行資料調用的是renderRow方法:
class CanvasEditor {
// 渲染頁面中的一行
renderRow(ctx, renderHeight, row) {
let { color, pagePadding } = this.options
// 内邊距
let offsetX = pagePadding[3]
let offsetY = pagePadding[0]
// 目前行繪制到的寬度
let renderWidth = offsetX
renderHeight += offsetY
row.elementList.forEach(item => {
// 跳過換行符
if (item.value === '\n') {
return
}
ctx.save()
// 渲染文字
ctx.font = item.font
ctx.fillStyle = item.color || color
ctx.fillText(item.value, renderWidth, renderHeight)
// 更新目前行繪制到的寬度
renderWidth += item.info.width
ctx.restore()
})
}
}
跟繪制頁的邏輯是一樣的,也是通過一個變量來記錄目前行繪制到的距離,然後調用fillText繪制文本,背景、下劃線、删除線我們待會再補充,先看一下目前效果:
從第一行可以發現一個很明顯的問題,文本繪制位置不對,超出了内容區域,繪制到了内邊距裡,難道是我們計算位置出了問題了,我們不妨渲染一根輔助線來看看:
renderRow(ctx, renderHeight, row) {
// ...
// 輔助線
ctx.moveTo(pagePadding[3], renderHeight)
ctx.lineTo(673, renderHeight)
ctx.stroke()
}
可以看到輔助線的位置是正确的,那麼代表我們的位置計算是沒有問題的,這其實跟canvas繪制文本時的文本基線有關,也就是textBaseline屬性,預設值為alphabetic,各個取值的效果如下:
知道了原因,修改也就不難了,我們可以修改fillText的y參數,在前面的基礎上加上行的高度:
ctx.fillText(item.value, renderWidth, renderHeight + row.height)
比前面好了,但是依然有問題,問題出在行高,始終相信那一行設定了3倍的行高,我們顯然是希望文本在行内垂直居中的,現在還是貼着行的底部,這個可以通過行的實際高度減去文本的最大高度,再除以二,累加到fillText的y參數上就可以了,但是行資料row上隻儲存了最終的實際高度,文本高度并沒有儲存,是以需要先修改一下computeRows方法:
computeRows() {
rows.push({
width: 0,
height: 0
originHeight: 0, // 沒有應用行高的原始高度
elementList: []
})
if (curRow.width + info.width <= contentWidth && value !== '\n') {
curRow.elementList.push(element)
curRow.width += info.width
curRow.height = Math.max(curRow.height, info.height * actLineHeight) // 儲存目前行實際最高的文本高度
curRow.originHeight = Math.max(curRow.originHeight, info.height) // 儲存目前行原始最高的文本高度
} else {
rows.push({
width: info.width,
height: info.height * actLineHeight,
originHeight: info.height,
elementList: [element]
})
}
}
我們增加一個originHeight來儲存沒有應用行高的原始高度,這樣我們就可以在renderRow方法裡使用了:
ctx.fillText(
item.value,
renderWidth,
renderHeight + row.height - (row.height - row.originHeight) / 2
)
可以看到雖然正常一些了,但仔細看還是有問題,文字還是沒有在行内完全居中,這其實也跟文字基線textBaseline有關,因為有的文字部分會繪制到基線下面,導緻整體偏下,你可能覺得把textBaseline設為bottom就可以了,但實際上這樣它又會偏上了:
怎麼辦呢,可以這麼做,在計算行資料時,再增加一個字段descent,用來儲存該行元素中最大的descent值,然後在繪制該行文字y坐标在前面的基礎上再減去row.descent,修改computeRows方法:
computeRows() {
// ...
rows.push({
width: 0,
height: 0,
originHeight: 0,
descent: 0,// 行内元素最大的descent
elementList: []
})
this.data.forEach(item => {
// ...
if (curRow.width + info.width <= contentWidth && value !== '\n') {
// ...
curRow.descent = Math.max(curRow.descent, info.descent)// 儲存目前行最大的descent
} else {
rows.push({
// ...
descent: info.descent
})
}
})
// ...
}
然後繪制文本時減去該行的descent:
ctx.fillText(
item.value,
renderWidth,
renderHeight + row.height - (row.height - row.originHeight) / 2 - row.descent
)
效果如下:
接下來補充繪制背景、下劃線、删除線的邏輯:
renderRow(ctx, renderHeight, row) {
// 渲染背景
if (item.background) {
ctx.save()
ctx.beginPath()
ctx.fillStyle = item.background
ctx.fillRect(
renderWidth,
renderHeight,
item.info.width,
row.height
)
ctx.restore()
}
// 渲染下劃線
if (item.underline) {
ctx.save()
ctx.beginPath()
ctx.moveTo(renderWidth, renderHeight + row.height)
ctx.lineTo(renderWidth + item.info.width, renderHeight + row.height)
ctx.stroke()
ctx.restore()
}
// 渲染删除線
if (item.linethrough) {
ctx.save()
ctx.beginPath()
ctx.moveTo(renderWidth, renderHeight + row.height / 2)
ctx.lineTo(renderWidth + item.info.width, renderHeight + row.height / 2)
ctx.stroke()
ctx.restore()
}
// 渲染文字
// ...
}
很簡單,無非就是繪制矩形和線段:
到這裡,渲染的功能就完成了,當然,如果要重新渲染,還要一個擦除的功能:
class CanvasEditor {
// 清除渲染
clear() {
let { pageWidth, pageHeight } = this.options
this.pageCanvasCtxList.forEach(item => {
item.clearRect(0, 0, pageWidth, pageHeight)
})
}
}
周遊所有頁面,調用clearRect方法進行清除,render方法也需要修改一下,每次渲染前都先進行清除及一些複位工作:
render() {
this.clear()
this.rows = []
this.positionList = []
this.computeRows()
this.renderPage()
}
渲染光标
要渲染光标,首先要計算出光标的位置,以及光标的高度,具體來說,步驟如下:
1.監聽canvas的mousedown事件,計算出滑鼠按下的位置相對于canvas的坐标
2.周遊rows,周遊rows.elementList,判斷滑鼠點選在哪個element内,然後計算出光标坐标及高度
3.定位并渲染光标
為了友善我們後續的周遊和計算,我們添加一個屬性positionList,用來儲存所有元素,并且預先計算一些資訊,省去後面周遊時重複計算。
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.positionList = []// 定位元素清單
// ...
}
}
在renderRow方法裡進行收集:
class CanvasEditor {
// 新增兩個參數,代表目前所在頁和行數
renderRow(ctx, renderHeight, row, pageIndex, rowIndex) {
// ...
row.elementList.forEach(item => {
// 收集positionList
this.positionList.push({
...item,
pageIndex, // 所在頁
rowIndex, // 所在行
rect: {// 包圍框
leftTop: [renderWidth, renderHeight],
leftBottom: [renderWidth, renderHeight + row.height],
rightTop: [renderWidth + item.info.width, renderHeight],
rightBottom: [
renderWidth + item.info.width,
renderHeight + row.height
]
}
})
// ...
})
// ...
}
}
儲存每個元素所在的頁、行、包圍框位置資訊。
計算光标坐标
先給canvas綁定mousedown事件,可以在建立頁面的時候綁定:
class CanvasEditor {
// 新增了要建立的頁面索引參數
createPage(pageIndex) {
// ...
canvas.addEventListener('mousedown', (e) => {
this.onMousedown(e, pageIndex)
})
// ...
}
}
建立頁面時需要傳遞要建立的頁面索引,這樣友善在事件裡能直接擷取到點選的頁面,滑鼠的事件位置預設是相對于浏覽器視窗的,需要轉換成相對于canvas的:
class CanvasEditor {
// 将相對于浏覽器視窗的坐标轉換成相對于頁面canvas
windowToCanvas(e, canvas) {
let { left, top } = canvas.getBoundingClientRect()
return {
x: e.clientX - left,
y: e.clientY - top
}
}
}
接下來計算滑鼠點選位置所在的元素索引,周遊positionList,判斷點選位置是否在某個元素包圍框内,如果在的話再判斷是否是在這個元素的前半部分,是的話點選元素就是前一個元素,否則就是該元素;如果不在,那麼就判斷點選所在的那一行是否存在元素,存在的話,點選元素就是這一行的最後一個元素;否則點選就是這一頁的最後一個元素:
class CanvasEditor {
// 頁面滑鼠按下事件
onMousedown(e, pageIndex) {
let { x, y } = this.windowToCanvas(e, this.pageCanvasList[pageIndex])
let positionIndex = this.getPositionByPos(x, y, pageIndex)
}
// 擷取某個坐标所在的元素
getPositionByPos(x, y, pageIndex) {
// 是否點選在某個元素内
for (let i = 0; i < this.positionList.length; i++) {
let cur = this.positionList[i]
if (cur.pageIndex !== pageIndex) {
continue
}
if (
x >= cur.rect.leftTop[0] &&
x <= cur.rect.rightTop[0] &&
y >= cur.rect.leftTop[1] &&
y <= cur.rect.leftBottom[1]
) {
// 如果是目前元素的前半部分則點選元素為前一個元素
if (x < cur.rect.leftTop[0] + cur.info.width / 2) {
return i - 1
}
return i
}
}
// 是否點選在某一行
let index = -1
for (let i = 0; i < this.positionList.length; i++) {
let cur = this.positionList[i]
if (cur.pageIndex !== pageIndex) {
continue
}
if (y >= cur.rect.leftTop[1] && y <= cur.rect.leftBottom[1]) {
index = i
}
}
if (index !== -1) {
return index
}
// 傳回目前頁的最後一個元素
for (let i = 0; i < this.positionList.length; i++) {
let cur = this.positionList[i]
if (cur.pageIndex !== pageIndex) {
continue
}
index = i
}
return index
}
}
接下來就是根據這個計算出具體的光标坐标和高度,高度有點麻煩,比如下圖:
我們首先會覺得應該和文字高度一緻,但是如果是标點符号呢,完全一緻又太短了,那就需要一個最小高度,但是這個最小高度又是多少呢?
// 擷取光标位置資訊
getCursorInfo(positionIndex) {
let position = this.positionList[positionIndex]
let { fontSize } = this.options
// 光标高度在字号的基礎上再高一點
let height = position.size || fontSize
let plusHeight = height / 2
let actHeight = height + plusHeight
// 元素所在行
let row = this.rows[position.rowIndex]
return {
x: position.rect.rightTop[0],
y:
position.rect.rightTop[1] +
row.height -
(row.height - row.originHeight) / 2 -
actHeight +
(actHeight - Math.max(height, position.info.height)) / 2,
height: actHeight
}
}
我們沒有把文字的實際高度作為光标高度,而是直接使用文字的字号,另外你仔細觀察各種編輯器都可以發現光标高度是會略高于文字高度的,是以我們還額外增加了高度的1/2,光标位置的y坐标計算有點複雜,可以對着下面的圖進行了解:
我們先用canvas繪制線段的方式來測試一下:
當然目前考慮到的是正常情況,還有兩種特殊情況:
1.頁面為空、或者頁面不為空,但是點選的是第一個元素的前半部分
這類情況的共同點是計算出來的positionIndex = -1,目前我們還沒有處理這個情況:
getCursorInfo(positionIndex) {
let position = this.positionList[positionIndex]
let { fontSize, pagePadding, lineHeight } = this.options
let height = (position ? position.size : null) || fontSize
let plusHeight = height / 2
let actHeight = height + plusHeight
if (!position) {
// 目前光标位置處沒有元素
let next = this.positionList[positionIndex + 1]
if (next) {
// 存在下一個元素
let nextCursorInfo = this.getCursorInfo(positionIndex + 1)
return {
x: pagePadding[3],
y: nextCursorInfo.y,
height: nextCursorInfo.height
}
} else {
// 不存在下一個元素,即文檔為空
return {
x: pagePadding[3],
y: pagePadding[0] + (height * lineHeight - actHeight) / 2,
height: actHeight
}
}
}
// ...
}
當目前光标處沒有元素時,先判斷是否存在下一個元素,存在的話就使用下一個元素的光标的y和height資訊,避免出現下面這種情況:
如果沒有下一個元素,那麼代表文檔為空,預設傳回頁面文檔内容的起始坐标。
以上雖然考慮了行高,但是實際上和編輯後還是會存在偏差。
2.點選的是一行的第一個字元的前半部分
當我們點選的是一行第一個字元的前半部分,目前顯示會有點問題:
和後一個字元重疊了,這是因為我們計算的問題,前面的計算行資料的邏輯沒有區分換行符,是以計算出來換行符也存在寬度,是以可以修改前面的計算邏輯,也可以直接在getCursorInfo方法判斷:
getCursorInfo(positionIndex) {
// ...
// 是否是換行符
let isNewlineCharacter = position.value === '\n'
return {
x: isNewlineCharacter ? position.rect.leftTop[0] : position.rect.rightTop[0],
// ...
}
}
渲染光标
光标可以使用canvas渲染,也可以使用DOM元素渲染,簡單起見,我們使用DOM元素來渲染,光标元素也是添加到容器元素内,容器元素設定為相對定位,光标元素設定為絕對定位:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.cursorEl = null // 光标元素
}
// 設定光标
setCursor(left, top, height) {
if (!this.cursorEl) {
this.cursorEl = document.createElement('div')
this.cursorEl.style.position = 'absolute'
this.cursorEl.style.width = '1px'
this.cursorEl.style.backgroundColor = '#000'
this.container.appendChild(this.cursorEl)
}
this.cursorEl.style.left = left + 'px'
this.cursorEl.style.top = top + 'px'
this.cursorEl.style.height = height + 'px'
}
}
前面我們計算出的光标位置是相對于目前頁面canvas的,還需要轉換成相對于容器元素的:
class CanvasEditor {
// 将相對于頁面canvas的坐标轉換成相對于容器元素的
canvasToContainer(x, y, canvas) {
return {
x: x + canvas.offsetLeft,
y: y + canvas.offsetTop
}
}
}
有了前面這麼多鋪墊,我們的光标就可以出來了:
class CanvasEditor {
onMousedown(e, pageIndex) {
// 滑鼠按下位置相對于頁面canvas的坐标
let { x, y } = this.windowToCanvas(e, this.pageCanvasList[pageIndex])
// 計算該坐标對應的元素索引
let positionIndex = this.getPositionByPos(x, y, pageIndex)
// 根據元素索引計算出光标位置和高度資訊
let cursorInfo = this.getCursorInfo(positionIndex)
// 渲染光标
let cursorPos = this.canvasToContainer(
cursorInfo.x,
cursorInfo.y,
this.pageCanvasList[pageIndex]
)
this.setCursor(cursorPos.x, cursorPos.y, cursorInfo.height)
}
}
當然,現在光标還是不會閃爍的,這個簡單:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.cursorTimer = null// 光标元素閃爍的定時器
}
setCursor(left, top, height) {
clearTimeout(this.cursorTimer)
// ...
this.cursorEl.style.opacity = 1
this.blinkCursor(0)
}
// 光标閃爍
blinkCursor(opacity) {
this.cursorTimer = setTimeout(() => {
this.cursorEl.style.opacity = opacity
this.blinkCursor(opacity === 0 ? 1 : 0)
}, 600)
}
}
通過一個定時器來切換光标元素的透明度,達到閃爍的效果。
編輯效果
終于到了萬衆矚目的編輯效果了,編輯大緻就是删除、換行、輸入,是以監聽一下keydown事件,區分按下的是什麼鍵,然後對資料做對應的處理,最後重新渲染就可以了,當然,光标的位置也需要更新,不過繼續之前我們需要做另一件事:聚焦。
聚焦
如果我們用的是input、textarea标簽,或者是DOM元素的contentedit屬性實作編輯,不用考慮這個問題,但是我們用的是canvas标簽,無法聚焦,不聚焦就無法輸入,不然你試試切換輸入語言都不生效,是以當我們點選頁面,渲染光标的同時,也需要手動聚焦,建立一個隐藏的textarea标簽用于聚焦和失焦:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.textareaEl = null // 文本輸入框元素
}
// 聚焦
focus() {
if (!this.textareaEl) {
this.textareaEl = document.createElement('textarea')
this.textareaEl.style.position = 'fixed'
this.textareaEl.style.left = '-99999px'
document.body.appendChild(this.textareaEl)
}
this.textareaEl.focus()
}
// 失焦
blur() {
if (!this.textareaEl) {
return
}
this.textareaEl.blur()
}
}
然後在渲染光标的同時調用聚焦方法:
setCursor(left, top, height) {
// ...
setTimeout(() => {
this.focus()
}, 0)
}
為什麼要在setTimeout0後聚焦呢,因為setCursor方法是在mousedown方法裡調用的,這時候聚焦,mouseup事件觸發後又失焦了,是以延遲一點。
輸入
輸入我們選擇監聽textarea的input事件,這麼做的好處是不用自己區分是否是按下了可輸入按鍵,可以直接從事件對象的data屬性擷取到輸入的字元,如果按下的不是輸入按鍵,那麼data的值為null,但是有個小問題,如果我們輸入中文時,即使是在打拼音的階段也會觸發,這是沒有必要的,解決方法是可以監聽compositionupdate和compositionend事件,當我們輸入拼音階段會觸發compositionstart,然後每打一個拼音字母,觸發compositionupdate,最後将輸入好的中文填入時觸發compositionend,我們通過一個标志位來記錄目前狀态即可。
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.isCompositing = false // 是否正在輸入拼音
}
focus() {
if (!this.textareaEl) {
this.textareaEl.addEventListener('input', this.onInput.bind(this))
this.textareaEl.addEventListener('compositionstart', () => {
this.isCompositing = true
})
this.textareaEl.addEventListener('compositionend', () => {
this.isCompositing = false
})
}
}
// 輸入事件
onInput(e) {
setTimeout(() => {
let data = e.data
if (!data || this.isCompositing) {
return
}
}, 0)
}
}
可以看到在輸入方法裡我們又使用了setTimeout0,這又是為啥呢,其實是因為compositionend事件觸發的比input事件晚,不延遲就無法擷取到輸入的中文。
擷取到了輸入的字元就可以更新資料了,更新顯然是在光标位置處更新,是以我們還需要添加一個字段,用來儲存光标所在元素位置:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.cursorPositionIndex = -1// 目前光标所在元素索引
}
onMousedown(e, pageIndex) {
// ...
let positionIndex = this.getPositionByPos(x, y, pageIndex)
this.cursorPositionIndex = positionIndex
// ...
}
}
那麼插入輸入的字元就很簡單了:
class CanvasEditor {
onInput(e) {
// ...
// 插入字元
let arr = data.split('')
let length = arr.length
this.data.splice(
this.cursorPositionIndex + 1,
0,
...arr.map(item => {
return {
value: item
}
})
)
// 重新渲染
this.render()
// 更新光标
this.cursorPositionIndex += length
this.computeAndRenderCursor(
this.cursorPositionIndex,
this.positionList[this.cursorPositionIndex].pageIndex
)
}
}
computeAndRenderCursor方法就是把前面的計算光标位置、坐标轉換、設定光标的邏輯提取了一下,友善複用。
可以輸入了,但是有個小問題,比如我們是在有樣式的文字中間輸入,那麼預期新輸入的文字也是帶同樣樣式的,但是現在顯然是沒有的:
解決方法很簡單,插入新元素時複用目前元素的樣式資料:
onInput(e) {
// ...
let cur = this.positionList[this.cursorPositionIndex]
this.data.splice(
this.cursorPositionIndex + 1,
0,
...arr.map(item => {
return {
...(cur || {}),// ++
value: item
}
})
)
}
删除
删除很簡單,判斷按下的是否是删除鍵,是的話從資料中删除光标目前元素即可:
class CanvasEditor {
focus() {
// ...
this.textareaEl.addEventListener('keydown', this.onKeydown.bind(this))
// ...
}
// 按鍵事件
onKeydown(e) {
if (e.keyCode === 8) {
this.delete()
}
}
// 删除
delete() {
if (this.cursorPositionIndex < 0) {
return
}
// 删除資料
this.data.splice(this.cursorPositionIndex, 1)
// 重新渲染
this.render()
// 更新光标
this.cursorPositionIndex--
let position = this.positionList[this.cursorPositionIndex]
this.computeAndRenderCursor(
this.cursorPositionIndex,
position ? position.pageIndex : 0
)
}
}
換行
換行也很簡單,監聽到按下Enter鍵就向光标處插入一個換行符,然後重新渲染更新光标:
class CanvasEditor {
// 按鍵事件
onKeydown(e) {
if (e.keyCode === 8) {
this.delete()
} else if (e.keyCode === 13) {
this.newLine()
}
}
// 換行
newLine() {
this.data.splice(this.cursorPositionIndex + 1, 0, {
value: '\n'
})
this.render()
this.cursorPositionIndex++
let position = this.positionList[this.cursorPositionIndex]
this.computeAndRenderCursor(this.cursorPositionIndex, position.pageIndex)
}
}
其實我按了很多次Enter鍵,但是似乎隻生效了一次,這是為啥呢,其實我們插入是沒有問題的,問題出在一行中如果隻有換行符那麼這行高度為0,是以渲染出來沒有效果,修改一下計算行資料的computeRows方法:
computeRows() {
let { fontSize } = this.options
// ...
this.data.forEach(item => {
// ...
// 尺寸資訊
let info = {
width: 0,
height: 0,
ascent: 0,
descent: 0
}
if (value === '\n') {
// 如果是換行符,那麼寬度為0,高度為字号
info.height = fontSize
} else {
// 其他字元
ctx.font = font
let { width, actualBoundingBoxAscent, actualBoundingBoxDescent } =
ctx.measureText(value)
info.width = width
info.height = actualBoundingBoxAscent + actualBoundingBoxDescent
info.ascent = actualBoundingBoxAscent
info.descent = actualBoundingBoxDescent
}
// ...
})
// ...
}
如果是換行符,那麼高度預設為字号,否則還是走之前的邏輯,同時我們把換行符存在寬度的問題也一并修複了。
到這裡,基本的編輯就完成了,接下來實作另一個重要的功能:選區。
選區
選區其實就是我們滑鼠通過拖拽選中文檔的一部分,就是一段區間,可以通過一個數組來儲存:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.range = [] // 目前選區,第一個元素代表選區開始元素位置,第二個元素代表選區結束元素位置
// ...
}
}
如果要支援多段選區的話可以使用二維數組。
然後渲染的時候判斷是否存在選區,存在的話再判斷目前繪制到的元素是否在選區内,是的話就額外繪制一個矩形作為選區。
計算選區
選擇選區肯定是在滑鼠按下的時候進行的,是以需要添加一個标志代表滑鼠目前是否處于按下狀态,然後監聽滑鼠移動事件和松開事件,這兩個事件我們綁定在body上,因為滑鼠是可以移出頁面的。滑鼠按下時需要記錄目前所在的元素索引:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.isMousedown = false // 滑鼠是否按下
document.body.addEventListener('mousemove', this.onMousemove.bind(this))
document.body.addEventListener('mouseup', this.onMouseup.bind(this))
// ...
}
// 滑鼠按下事件
onMousedown(e, pageIndex) {
// ...
this.isMousedown = true
let positionIndex = this.getPositionByPos(x, y, pageIndex)
this.range[0] = positionIndex
// ...
}
// 滑鼠移動事件
onMousemove(e) {
if (!this.isMousedown) {
return
}
}
// 滑鼠松開事件
onMouseup() {
this.isMousedown = false
}
}
因為前面我們寫的windowToCanvas方法需要知道是在哪個頁面,是以還得新增一個方法用于判斷一個坐标在哪個頁面:
class CanvasEditor {
// 擷取一個坐标在哪個頁面
getPosInPageIndex(x, y) {
let { left, top, right, bottom } = this.container.getBoundingClientRect()
// 不在容器範圍内
if (x < left || x > right || y < top || y > bottom) {
return -1
}
let { pageHeight, pageMargin } = this.options
let scrollTop = this.container.scrollTop
// 滑鼠的y坐标相對于容器頂部的距離
let totalTop = y - top + scrollTop
for (let i = 0; i < this.pageCanvasList.length; i++) {
let pageStartTop = i * (pageHeight + pageMargin)
let pageEndTop = pageStartTop + pageHeight
if (totalTop >= pageStartTop && totalTop <= pageEndTop) {
return i
}
}
return -1
}
}
然後當滑鼠移動時實時判斷目前移動到哪個元素,并且重新渲染達到選區的選擇效果:
onMousemove(e) {
if (!this.isMousedown) {
return
}
// 滑鼠目前所在頁面
let pageIndex = this.getPosInPageIndex(e.clientX, e.clientY)
if (pageIndex === -1) {
return
}
// 滑鼠位置相對于頁面canvas的坐标
let { x, y } = this.windowToCanvas(e, this.pageCanvasList[pageIndex])
// 滑鼠位置對應的元素索引
let positionIndex = this.getPositionByPos(x, y, pageIndex)
if (positionIndex !== -1) {
this.range[1] = positionIndex
this.render()
}
}
mousemove事件觸發頻率很高,是以為了性能考慮可以通過節流的方式減少觸發次數。
計算滑鼠移動到哪個元素和光标的計算是一樣的。
渲染選區
選區其實就是一個矩形區域,和元素背景沒什麼差別,是以可以在渲染的時候判斷是否存在選區,是的話給在選區中的元素繪制選區的樣式即可:
class CanvasEditor {
constructor(container, data, options = {}) {
// ...
this.options = Object.assign(
{
// ...
rangeColor: '#bbdfff', // 選區顔色
rangeOpacity: 0.6 // 選區透明度
}
)
// ...
}
renderRow(ctx, renderHeight, row, pageIndex, rowIndex) {
let { rangeColor, rangeOpacity } = this.options
// ...
row.elementList.forEach(item => {
// ...
// 渲染選區
if (this.range.length === 2 && this.range[0] !== this.range[1]) {
let range = this.getRange()
let positionIndex = this.positionList.length - 1
if (positionIndex >= range[0] && positionIndex <= range[1]) {
ctx.save()
ctx.beginPath()
ctx.globalAlpha = rangeOpacity
ctx.fillStyle = rangeColor
ctx.fillRect(renderWidth, renderHeight, item.info.width, row.height)
ctx.restore()
}
}
// ...
})
// ...
}
}
調用了getRange方法擷取選區,為什麼還要通過方法來擷取呢,不就是this.range嗎,非也,滑鼠按下的位置和滑鼠實時的位置是存在前後關系的,位置不一樣,實際的選區範圍也不一樣。
如下圖,如果滑鼠實時位置在滑鼠按下位置的後面,那麼按下位置的元素實際上是不包含在選區内的:
如下圖,如果滑鼠實時位置在滑鼠按下位置的前面,那麼滑鼠實時位置的元素實際上是不需要包含在選區内的:
是以我們需要進行一下判斷:
class CanvasEditor {
// 擷取選區
getRange() {
if (this.range.length < 2) {
return []
}
if (this.range[1] > this.range[0]) {
// 滑鼠結束元素在開始元素後面,那麼排除開始元素
return [this.range[0] + 1, this.range[1]]
} else if (this.range[1] < this.range[0]) {
// 滑鼠結束元素在開始元素前面,那麼排除結束元素
return [this.range[1] + 1, this.range[0]]
} else {
return []
}
}
}
效果如下:
處理和光标的沖突
到這裡結束了嗎,沒有,目前選擇選區時光标還是在的,并且單擊後選區也沒有消失。接下來解決一下這兩個問題。
解決第一個問題很簡單,在選擇選區的時候可以判斷一下目前選區範圍是否大于0,是的話就隐藏光标:
class CanvasEditor {
onMousemove(e) {
// ...
let positionIndex = this.getPositionByPos(x, y, pageIndex)
if (positionIndex !== -1) {
this.rangeEndPositionIndex = positionIndex
if (Math.abs(this.range[1] - this.range[0]) > 0) {
// 選區大于1,光标就不顯示
this.cursorPositionIndex = -1
this.hideCursor()
}
// ...
}
}
// 隐藏光标
hideCursor() {
clearTimeout(this.cursorTimer)
this.cursorEl.style.display = 'none'
}
}
第二個問題可以在設定光标時直接清除選區:
class CanvasEditor {
// 清除選區
clearRange() {
if (this.range.length > 0) {
this.range = []
this.render()
}
}
setCursor(left, top, height) {
this.clearRange()
// ...
}
}
編輯選區内容
目前選區隻是繪制出來了,但是沒有實際用處,不能删除,也不能替換,先增加一下删除選區的邏輯:
delete() {
if (this.cursorPositionIndex < 0) {
let range = this.getRange()
if (range.length > 0) {
// 存在選區,删除選區内容
let length = range[1] - range[0] + 1
this.data.splice(range[0], length)
this.cursorPositionIndex = range[0] - 1
} else {
return
}
} else {
// 删除資料
this.data.splice(this.cursorPositionIndex, 1)
// 重新渲染
this.render()
// 更新光标
this.cursorPositionIndex--
}
let position = this.positionList[this.cursorPositionIndex]
this.computeAndRenderCursor(
this.cursorPositionIndex,
position ? position.pageIndex : 0
)
}
很簡單,如果存在選區就删除選區的資料,然後重新渲染光标。
接下來是替換,如果存在選區時我們輸入文字,輸入的文字會替換掉選區的文字,實作上我們可以直接删除:
onInput(e) {
// ...
let range = this.getRange()
if (range.length > 0) {
// 存在選區,則替換選區的内容
this.delete()
}
let cur = this.positionList[this.cursorPositionIndex]
// 原來的輸入邏輯
}
輸入時判斷是否存在選區,存在的話直接調用删除方法删除選區,删除後的光标位置也是正确的,是以再進行原本的輸入不會有任何問題。
總結
到這裡我們實作了一個類似Word的富文本編輯器,支援文字的編輯,支援有限的文字樣式,支援光标,支援選區,當然,這是最基本最基本的功能,随便想想就知道還有很多功能沒實作,比如複制、粘貼、方向鍵切換光标位置、拖拽選區到其他位置、前進後退等,以及支援圖檔、表格、連結、代碼塊等文本之外的元素,是以想要實作一個完整可用的富文本是非常複雜的,要考慮的問題非常多。
本文完整源碼:https://github.com/wanglin2/canvas-editor-demo。
有興趣了解更多的可以參考這個項目:https://github.com/Hufe921/canvas-editor。