canvas2d绘制文字

377 阅读9分钟

目标

也就是需求,是在画布上可以输入文字,可以选中文字再次编辑,图形的基本变换已经实现了,现在只要可以新增文本。二次编辑即可。

方案选择, 编辑文字使用dom文本框,失焦后,文字绘制到画布上, 但是,由于texrtarea不具备contenteditable那样的灵活性,所以我选择使用可编辑的div。 chrome-capture-2023-4-30.gif

初始化

画布、 文本输入框,没了。

        <div class="canvas-wraper">
            <div class="text-input" contenteditable id="text"></div>
            <canvas width="512" height="512" class="canvas2d" id="d2"></canvas>
        </div>

获取一下元素和画布绘图上下文,做好事件监听。

const canvas = document.querySelector('#d2') ;
 
const ctx = canvas.getContext('2d');
const input  = document.getElementById('text');
const dpr = window.devicePixelRatio;
if(dpr !==1){
  // dpr<1用此法无用
  canvas.width = dpr * canvas.clientWidth
  canvas.height = dpr * canvas.clientHeight
  ctx.scale(dpr,dpr)
}

绘制多行文本

canvas绘制文本是不会自动换行的,所以只能将原本的文本用 \n分割开,然后逐行绘制, 每一行的高度就是行高。

文字是从左下角绘制,所以起点要减去行高,虽然如此,还是不对。

  // 绘制文字
    for (let i = 0; i < content.length; i++) {
        // 需要特别的计算
        let x =left ,y= top + lineHeight * (i) + baseline; 
        ctx.fillText(content[i], x , y ,width);
        ctx.fillRect(x , y ,width, 1)
    }

额外的问题, 遇到空行的时候会多出来一个\n。仔细观察发现,这是contenteditable的特性,换行会加一个div。这个问题下面再解决。

image.png

文字对齐的问题

横向很容易对齐,麻烦的是纵向。这里暂时不考虑竖排版,都是横排版。这里说的纵向,仅仅指横排版的纵向。 对齐就只有左中右,虽然css属性还有其它的,但是这里的选项我们是可以控制的。

    const horizontalOffset =
        textAlign === "center"
      ? width / 2
      : textAlign === "right"
      ? width
      : 0;
  ......    
      
let x =left+ horizontalOffset      

行高就是一行文字的高度,仅仅知道行高还不够。 canvas绘制文字是从文字的基线开始的,我需要知道基线相对于行高的某一边的距离, 如此才能做到,和dom中的文本完全重合 。

为了验证自己的计算方式是否正确,我将dom文本框和canvas叠在了一起,左上角对齐。文字加了下划线,下划线就是基线的位置, canvas中则是绘制了一个矩形。

image.png

点击紫色区域即可输入文字。

计算基线的办法

简单来说,基线是和字体相关的,知道一个字体的设计参数就能算出它的基线位置。这里我就简单贴一下,我得出的计算公式。只要知道字体的acsentdescentemsize,就能算出其基线的位置, mac上的叫法可能不同,但是算法是一样的。

image.png

这个办法的缺点就是你得先知道字体的设计参数,虽然确实有像 opentype.js这样的库可以解析字体,但是你如果用的浏览器本身就有的字体,好像也没办法读取字体文件。

所以,可以预设几种字体,提前用软件或者其他方法得到设计参数即可。

更粗暴的办法

虽然,上面已经是很正规的计算基线的办法了,但是如上文所说,只能是预先得到参数。

我们知道css有个属性vertical-align,垂直对齐, 它作用是让行内块元素和文本以某种方式对齐, 默认就是基线。 所以只要测量出行内块元素的下边界的位置,那就是基线。

//这种算法,算的就是基线在一行行高中的位置, 就是第一行文本的起点, 
function measureBaseline  (
    font='',
    lineHeight=22,
  ) {
    container.style.font = font;
    container.style.height = lineHeight+"px";
    container.style.lineHeight = lineHeight+"px";
    document.body.appendChild(container);
  //   这一增一删还是很有必要的, 这样才会让浏览器立即更新dom的尺寸。
    const svg = container.getElementsByTagName('svg')[0]
    const {bottom} = svg.getBoundingClientRect();
    container.remove()
    return bottom;
  };

这个方法相当的粗暴,其实就是利用浏览器的渲染机制,因为本来就是要还原浏览器的绘制结果,所以直接用它的结果作为结果,绝对准确。

缺点就是,只适用浏览器或者实现类似dom机制的渲染器。

顺便解决一下换行多出一行的问题

前面的会多出一个换行符的问题, 是contenteditable的特性导致的, 所以我觉定还是不用它了, 就用文本域,文本域不够灵活的问题, 就用上面那种粗暴的方法去解决。

简单来说就是, 用一个contenteditable的元素,作为测量文本尺寸的元素, 把这个宽高同步给textarea即可。 由于,这里只有尺寸大小的变化, 所以可以用resizeObsever来优化一下。

这里有一个小细节要注意, 那就是逻辑的执行顺序。 我们监听了textarea的输入事件, 文本变化之后,会去修改contenteditable元素的innerHtml, 等observer通知,才能拿到最新的尺寸,去更新textarea的尺寸, 而texterea元素的尺寸又会在下一帧才更新。 所以如果要把文字的尺寸同步到canvas2d的图形里,就要直接去拿测量元素的尺寸,否侧,这个尺寸总是落后的。

落后的影响就是, canvas2d绘制的文本可能被拉伸。 我们再看一下绘制文本的方法。最后一个参数maxwidth ,如果不传的话, 它会自行计算最合适的尺寸(总宽度),如果传了话, 那么会将它计算的宽度缩放至mawidth。 我们的目标是还原dom,所以这里还是传一个准确值的好。

ctx.fillText(text, x, y, [maxWidth])

同步变换到dom

基线有了, 基本上就能准确绘制了。如果还有一点不重合的地方,大概是因为还有border和padding。

但是,那是在没有对文本元素进行旋转缩放操作的时候,我希望在二次编辑文字的时候,文本框能同步之前的变换。 我之所以有这个想法,是因为我已经看到excalidraw实现了,可惜我的领悟力有限,并不能完全参照它的。

但是基本思路是差不多的, 图形的变换肯定少不了矩阵,这里假设已经实现了一套对canvas元素的变换,每个图形都有对应的矩阵。 我只要把这个矩阵应用到dom上就可以了。

假设我已经实现图形的基本变换,矩阵库用的是threejs的。 本来这个实验,用图片是最方便的了,但是既然本文是绘制文字,就改成文字吧,不过还是用加个边框,便于观察。

定义一下 旋转缩放 位移的量, 准备好矩阵, 这里先用canvas的变换方法,不直接用矩阵。

import {Matrix3, Matrix4} from '../jsm/three.module.min.js' ;

const {PI,sin ,cos} =Math;

let angle  =PI/4 , position = [100,10] , scale = [2,2];


const mat3 = new Matrix3() ;

mat3.rotate(angle) ;
mat3.scale(...scale)
mat3.translate(...position);
。。。。。


function drawShape(ctx) {
//绘制之前存档一下  省略其他代码
    ctx.save() ;
    ctx.translate(...position);
    ctx.rotate(angle);
    ctx.scale(...scale);
 // 省略代码
   ctx.fillRect(left , y ,width, 1); 
    ctx.strokeRect(left,top, width,height)
    ctx.restore()
}

image.png

可以看到,我们给canvas2d的变换已经生效了, 再给dom也加上。 getcssMatrix是一个简单的工具方法,下面再说,其作用就是把matrix3转为css能使用的矩阵字符串。

const cssTransform = getcssMatrix(mat3)
input.style.transform = cssTransform;

image.png

由于,canvas2d的旋转方法是顺时针,所以这里和用three的matrix3的结果刚好相反,需要处理一下。

mat3.rotate(-angle) ;

image.png

这下,旋转角度是对了,但是位置似乎不对。 位置为什么不对, 看下去就知道了。

css的transform

css的transform属性想必不少人都用过, 其特点是,不会引起重排,原本的占位仍然存在。 这里主要要说的是, 这个属性默认是以元素的中心为变换基点,同样也支持直接传入矩阵。

简单看一下其matrix的写法。 matrix(a, b, c, d, tx, ty) 是 matrix3d(a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, tx, ty, 0, 1) 的简写。 简单的理解,就可以把matrix当做是二维的三阶矩阵。 下面是把three的Matrix3转css的一个方法。

function getcssMatrix(matrix = new Matrix3(), offset = new Vector2()){ 
    const mt1 = new Matrix3()    ,mt = new Matrix3().makeTranslation(offset.x,offset.y) 

        mt1.makeTranslation(-offset.x, -offset.y)
        mt1.multiply(matrix).multiply(mt);
    
    const m = mt1.elements;
    return `matrix(${m[0]},${m[1]},${m[3]},${m[4]},${m[6]},${m[7]})`
}

canvas2d的transform, 是以画布左上角为基点的。也就是说现在dom元素的变换基点和 canvas2d的图形的变换基点,很可能不同。 这样的话,canvas上用的矩阵就能不能直接应用到css上了。 需要做一点处理,这个处理就是改变某个东西的变换中心。 现在我们以canvas2d的矩阵为准的话, 那就是要改变dom元素的基点。

老办法就是, 将dom元素的中心移至基点,进行变换,再移回来。偷懒的办法就是,直接设置css的transform-origin属性为基点 。老办法是通解,偷懒的办法是利用了css的特性。

老办法,上面的代码中已经体现了,就是第三个参数offset。 这个offset是dom元素的变换基点相对于图形的变换基点的偏移。 这里为了获得实时的尺寸,直接就写在尺寸监听里面了,虽然很蹩脚,但是意思到了。

const resizeObserver  = new ResizeObserver((entries)=> { 
  const {width, height}  =entries[0].contentRect
  。。。。 
      const  offset = [width/2, height/2] ;

input.style.transform = getcssMatrix(mat3, offset);
 。。。
})

修改 transform-origin就是直接设为图形变换的基点就可以了。 这个案例中直接修改为左上角即可。

 input.style.transformOrigin='left top'

效果如下,点击框框即可输入,修改代码的变换,以查看效果。 封面的canvas2d变换,是用别人写好的库实现的,代码量也不小,不太方便搬到码上掘金。

结束

这里绘制文本的主要思路是,使用文本域输入, 文本域失去焦点后消失,显示canvas2d图形,双击canvas2d图形,显示文本域,这里建议隐藏canvas2d图形。

上面做了这么多麻烦的操作, 其目的就是减少,在切换文本域和canvas2d的突兀感。 如果不介意的话,可以不必做这么多了。 或者直接试试在canvas2d里模拟这个编辑操作, 已经有很多优秀的人实现了。

本文讲述了canvas2d绘制多行文本的一般办法,以及测量文本基线、计算文本尺寸的小手段和css变换和canvas变换的转换。