乐趣区

关于前端:如何用canvas实现一个富文本编辑器

富文本编辑器置信大家都用过,相干的开源我的项目也很多,尽管具体的实现不一样,然而大部分都是应用 DOM 实现的,但其实还有一种实现形式,那就是应用 HTML5canvas,本文会带大家应用 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,那么怎么失去文本的高度呢,其实能够通过返回的另外两个字段actualBoundingBoxAscentactualBoundingBoxDescent,这两个返回值的含意是从textBaseline 属性表明的水平线到渲染文本的矩形边界顶部、底部的间隔,示意图如下:

很显著,文本的高度能够通过 actualBoundingBoxAscent + actualBoundingBoxDescent 失去。

当然要精确获取一个文本的宽高,跟它的字号、字体等都相干,所以通过这个办法测量前须要先设置这些文本款式,这个能够通过 font 属性进行设置,font属性是一个复合属性,取值和 cssfont属性是一样的,示例如下:

ctx.font = `italic 400 12px sans-serif`

从左到右顺次是font-stylefont-weightfont-sizefont-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,各个取值的成果如下:

晓得了起因,批改也就不难了,咱们能够批改 fillTexty参数,在后面的根底上加上行的高度:

ctx.fillText(item.value, renderWidth, renderHeight + row.height)

比后面好了,然而仍然有问题,问题出在行高,始终置信 那一行设置了 3 倍的行高,咱们显然是心愿文本在行内垂直居中的,当初还是贴着行的底部,这个能够通过行的理论高度减去文本的最大高度,再除以二,累加到 fillTexty参数上就能够了,然而行数据 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. 监听 canvasmousedown事件,计算出鼠标按下的地位绝对于 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
        }
      }
    }
    // ...
}

当以后光标处没有元素时,先判断是否存在下一个元素,存在的话就应用下一个元素的光标的 yheight信息,避免出现上面这种状况:

如果没有下一个元素,那么代表文档为空,默认返回页面文档内容的起始坐标。

以上尽管思考了行高,然而实际上和编辑后还是会存在偏差。

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 事件,辨别按下的是什么键,而后对数据做对应的解决,最初从新渲染就能够了,当然,光标的地位也须要更新,不过持续之前咱们须要做另一件事:聚焦。

聚焦

如果咱们用的是 inputtextarea 标签,或者是 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事件触发后又失焦了,所以提早一点。

输出

输出咱们抉择监听 textareainput事件,这么做的益处是不必本人辨别是否是按下了可输出按键,能够间接从事件对象的 data 属性获取到输出的字符,如果按下的不是输出按键,那么 data 的值为 null,然而有个小问题,如果咱们输出中文时,即便是在打拼音的阶段也会触发,这是没有必要的,解决办法是能够监听compositionupdatecompositionend事件,当咱们输出拼音阶段会触发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
        )
    }
}

换行

换行也很简略,监听到按下回车键就向光标处插入一个换行符,而后从新渲染更新光标:

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)
    }
}

其实我按了很屡次回车键,然而仿佛只失效了一次,这是为啥呢,其实咱们插入是没有问题的,问题出在一行中如果只有换行符那么这行高度为 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。

退出移动版