目标
也就是需求,是在画布上可以输入文字,可以选中文字再次编辑,图形的基本变换已经实现了,现在只要可以新增文本。二次编辑即可。
方案选择, 编辑文字使用dom文本框,失焦后,文字绘制到画布上, 但是,由于texrtarea
不具备contenteditable
那样的灵活性,所以我选择使用可编辑的div。
初始化
画布、
文本输入框,没了。
<div class="canvas-wraper"><div class="text-input" contenteditable id="text"></div><canvas width="512" height="512" class="canvas2d" id="d2"></canvas></div><div class="canvas-wraper"> <div class="text-input" contenteditable id="text"></div> <canvas width="512" height="512" class="canvas2d" id="d2"></canvas> </div><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.clientWidthcanvas.height = dpr * canvas.clientHeightctx.scale(dpr,dpr)}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) }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)}// 绘制文字 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) }// 绘制文字 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。这个问题下面再解决。
文字对齐的问题
横向很容易对齐,麻烦的是纵向。这里暂时不考虑竖排版,都是横排版。这里说的纵向,仅仅指横排版的纵向。 对齐就只有左中右,虽然css属性还有其它的,但是这里的选项我们是可以控制的。
const horizontalOffset =textAlign === "center"? width / 2: textAlign === "right"? width: 0;......let x =left+ horizontalOffsetconst horizontalOffset = textAlign === "center" ? width / 2 : textAlign === "right" ? width : 0; ...... let x =left+ horizontalOffsetconst horizontalOffset = textAlign === "center" ? width / 2 : textAlign === "right" ? width : 0; ...... let x =left+ horizontalOffset
行高就是一行文字的高度,仅仅知道行高还不够。 canvas绘制文字是从文字的基线开始的,我需要知道基线相对于行高的某一边的距离, 如此才能做到,和dom中的文本完全重合 。
为了验证自己的计算方式是否正确,我将dom文本框和canvas叠在了一起,左上角对齐。文字加了下划线,下划线就是基线的位置, canvas中则是绘制了一个矩形。
点击紫色区域即可输入文字。
计算基线的办法
简单来说,基线是和字体相关的,知道一个字体的设计参数就能算出它的基线位置。这里我就简单贴一下,我得出的计算公式。只要知道字体的acsent
和descent
和emsize
,就能算出其基线的位置, mac上的叫法可能不同,但是算法是一样的。
这个办法的缺点就是你得先知道字体的设计参数,虽然确实有像 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;};//这种算法,算的就是基线在一行行高中的位置, 就是第一行文本的起点, 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; };//这种算法,算的就是基线在一行行高中的位置, 就是第一行文本的起点, 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])ctx.fillText(text, x, y, [maxWidth])ctx.fillText(text, x, y, [maxWidth])
同步变换到dom
基线有了, 基本上就能准确绘制了。如果还有一点不重合的地方,大概是因为还有border和padding。
但是,那是在没有对文本元素进行旋转缩放操作的时候,我希望在二次编辑文字的时候,文本框能同步之前的变换。 我之所以有这个想法,是因为我已经看到excalidraw实现了,可惜并不能完全参照hen它的。
但是基本思路是差不多的, 图形的变换肯定少不了矩阵,这里假设已经实现了一套对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()}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() }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() }
可以看到,我们给canvas2d的变换已经生效了, 再给dom也加上。 getcssMatrix
是一个简单的工具方法,下面再说,其作用就是把matrix3转为css能使用的矩阵字符串。
const cssTransform = getcssMatrix(mat3)input.style.transform = cssTransform;const cssTransform = getcssMatrix(mat3) input.style.transform = cssTransform;const cssTransform = getcssMatrix(mat3) input.style.transform = cssTransform;
由于,canvas2d的旋转方法是顺时针,所以这里和用three的matrix3的结果刚好相反,需要处理一下。
mat3.rotate(-angle) ;mat3.rotate(-angle) ;mat3.rotate(-angle) ;
这下,旋转角度是对了,但是位置似乎不对。 位置为什么不对, 看下去就知道了。
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]})`}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]})` }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);。。。})const resizeObserver = new ResizeObserver((entries)=> { const {width, height} =entries[0].contentRect 。。。。 const offset = [width/2, height/2] ; input.style.transform = getcssMatrix(mat3, offset); 。。。 })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'input.style.transformOrigin='left top'input.style.transformOrigin='left top'
效果如下,点击框框即可输入,修改代码的变换,以查看效果。 封面的canvas2d变换,是用别人写好的库实现的,代码量也不小,不太方便搬到码上掘金。
结束
这里绘制文本的主要思路是,使用文本域输入, 文本域失去焦点后消失,显示canvas2d图形,双击canvas2d图形,显示文本域,这里建议隐藏canvas2d图形。
上面做了这么多麻烦的操作, 其目的就是减少,在切换文本域和canvas2d的突兀感。 如果不介意的话,可以不必做这么多了。 或者直接试试在canvas2d里模拟这个编辑操作, 已经有很多优秀的人实现了。
本文讲述了canvas2d绘制多行文本的一般办法,以及测量文本基线、计算文本尺寸的小手段和css变换和canvas变换的转换。