题外话
我以往用的截图功能都是基于cropperjs实现。比如曾发布的一款用于截图上传的组件。
因为包含了cropperjs
和preact
,体积高达66k。当时就有挖坑开发一款轻量级截图库的想法。
需求分析
API没什么好分析的,抄就是了参考cropperjs
。
技术选型上,cropperjs
在预览,拖拽时,是基于DOM。
既然是轮子,总得不一样嘛,所以这个轮子,将使用纯canvas实现所有功能。
整体结构
既然基于canvas,很多在DOM中理所当然的东西都要自己实现,比如事件绑定,获取元素位置等等。另一方面,拖拽,缩放,限制等又涉及到大量计算,如果混在一起就很痛苦了。适当的切割封装非常有必要。
因此代码主要分为4个类。
Cropper
类,暴露出去的主类,它负责接收解析配置,绑定事件监听,绘制图像。ImageModel
类,描述图像位置尺寸信息的类,负责更新图像在缩放,移动之后信息。WindowModel
类,描述了一个截图框,可以在容器内移动缩放,它的内部即最终输出区域Limiter
类,限制器,描述了一个矩形,提供一些方法用于计算坐标,位置。
其中,cropper初始化配置后,将生成一个ImageModel
实例,挂载为model
,还将生成一个WindowModel
实例,挂载为window
。
而这两个实例内部均包含有一个Limiter
实例,Model
的移动,缩放都将受到Limiter
的限制。
cropper同时还将监听鼠标,触碰事件,在相应的时机,更新model
,window
的坐标,尺寸等信息。完成后重新绘制画布。也就是类似MVVM的模式。
另外还有一个参考vuex实现的状态管理
Store
类,提供vuex风格的使用方式.这个不是重点,状态挂在Cropper上一样没问题.
难点分析
缩放
拖拽很容易实现,难点的部分在于缩放。
我们先不考虑太多,可以用drawImage实现,它的函数签名是:
ctx.drawImage(image[, sx, sy, sWidth, sHeight], dx, dy[, dWidth, dHeight]);
下面上代码
ctx.drawImage(img, 0,0,100,100);
// 监听鼠标滚轮,等比放大sWidth, sHeight
ctx.drawImage(img, 0,0,200,200);
图片将放大到原来的4倍大小。
但是有个问题--图像的坐标始终保持在左上角。
而符合人直觉缩放是,图像以鼠标落点为原点进行缩放。
要实现这一点,dx,dy就必须动态计算。
假设鼠标落点position
在50,50的位置,放大到新的尺寸newSize
200*200,那么新的dx,dy就应该是这样计算的:
先将鼠标落点转换为该点在图片内的坐标origin
,计算这个需要用到当前图片的位置current
origin.x = position.x - current.x;
dx=current.x+(origin.x-(origin.x / current.width * newSize.width));
上面的公式仔细分析,就是计算图片扩大后,在原点左侧增长的尺寸,将这个尺寸加到当前的坐标上,就是新的坐标。
代入计算一下。
origin.x=50-0=50
dx=0+(50-(50/100*200))
dx为-50,dy同理,也为-50,将其填入代码。可以发现是正确的。
ctx.drawImage(img, -50,-50,200,200)
ps 实际上放大宽高的缩放是不均匀的。
例如:10*10-->15*15-->20*20-->25*25
面积增长率分别是 125%->78%-->56.2%
表现就是越放大越慢,越缩小越快
解决方式其实也很简单,就是按面积算。
例如面积10*10=100放大10%,宽应增加√1.1即10*√1.1=10.49。
一句话: 尺寸的增长倍率就是面积增长倍率开根
绘制蒙层
这个空心的蒙层其实不难实现,不过挺有意思的,所以专门来说说。
cropperjs
的实现非常巧妙。底部是完整的图片,盖着一层蒙层,截图框是一个可以移动的元素,里面装着与完整图片一致大小的图片,移动时,图片向相反的方向移动,超出部分隐藏。详细可以访问官网审查元素仔细查看
canvas没有元素可用,但canvas的API非常强大。这里介绍两种方式。
- 基于clip实现。
- 基于globalCompositeOperation实现。
clip剪切一段路径之后,再次绘制只会作用在这个被剪切的路径内部,我们可以利用这一点。
- 先绘制图像;
- 再绘制全屏的半透明遮罩;
- 将截图框的轮廓剪切出来;
- 再次绘制图像。
代码
ctx.drawImage(img, 0,0,100,100);
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0,0,WIDTH,HEIGHT);//canvas的尺寸
const {x,y,width, height} = this.window;//截图框
ctx.rect(x, y, width, height);
ctx.clip();
ctx.drawImage(img, 0,0,100,100);
ctx.restore();
可以看出,这种方式需要绘制2次图片,非常地浪费性能。
下面介绍基于globalCompositeOperation
的实现。
理想的情况是我们绘制一个图片,然后在上面盖一个挖掉截图框的蒙层--也就是分图层绘制。
说到图层,就不得不提globalCompositeOperation
,这个属性指示了如何合成两次绘制动作产生的图层。
先看如何实现绘制一个空心蒙层。
这个属性的值很多,仔细看下来,destination-out
正好可以实现。
这个值表示,两次绘制重叠的部分将会变透明。
// renderWindow
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.fillRect(0, 0, WIDTH, HEIGHT);//绘制全屏半透明蒙层
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#000"; // 随便什么颜色都行
const {x, y, width, height} = this.window
ctx.fillRect(x, y, width, height);// 这次绘制的区域将会变透明
半透明蒙层实现了,但如果我们按照上面,先绘制图片,再绘制蒙层的步骤,会发现,图片也被抠掉了。
不过没关系,再次查看文档,destination-over
这个值表示,旧内容将会覆盖在新内容上方。
那只要把绘制顺序调整一下就解决上述问题了。
// 先绘制蒙层
this.renderWindow();
// 指定把新内容绘制到旧内容的底部
ctx.globalCompositeOperation = "destination-over";
// 绘制图片
this.renderModel();
限制器
限制器其实没什么好说的,实际是代码组织问题。
所谓限制器,就是当对象达到某个位置时,禁止它继续移动;
当它缩小或放大到某个尺寸时,禁止它继续缩放。
基本上就是数值的比较。难点在于梳理各个截图模式,梳理清除比较的对象。
以最复杂的window模式为例
首先明确这里涉及到的几个角色。
- 容器,即canvas这个矩形
- model,即图片,它的大小,位置是可以变化的。
- window,即截图框,它的大小,位置同样可以变化。
其中,model受截图框的限制,当它移动时,应被截图框卡住。它的限制器在它内部。
再次,window同时受容器和model的限制,准确说,受两者重叠部分限制。它的限制器在它的外部。
当model移动时,以x为例
最小值为 window.x-(model.width-window.width);
最大值为window.x;
当model缩放时,即便达到限制尺寸,也应保持比例,因此需要比较window与model的长宽比,长或宽只能限制一个。
其他的截图模式实际上是window模式的一些阉割。
比如cover模式,实际上可以看作是window与容器重叠,且不可移动不可编辑的状态。
而contain模式就是limiter不做任何限制的状态。
free-window则是展示window但limiter不做任何限制的情况
因为这里跨模块的数据传递有些繁琐。所以我专门参照vuex的风格抽象了一个Store,用于跨模块共享数据,通过defineProperty将store内部数据映射为模块属性。
手指缩放
手指缩放的逻辑与鼠标缩放完全一致,都只需要两个必要条件,缩放的原点,以及缩放的方向(缩小还是放大)。
具体到事件上。只需要在原鼠标事件回调中,检查touches
是否存在且是否有两个以上的touchPoint存在。
当touchstart时,计算两个point连线的中点,即为缩放原点,同时记录两点距离。
当touchmove时,计算两个point的距离,与起始距离比较,变小就是缩小,变大就是放大
都是初中几何,不详细解释了,直接上代码
获取缩放原点代码如下
const getCenterBetween = (A: Point, B: Point): Point => ({
x: A.x + (B.x - A.x) / 2,
y: A.y + (B.y - A.y) / 2
});
获取两点距离代码如下
export const getDistanceBetween = (A: Point, B: Point): number =>
Math.sqrt((A.x - B.x) ** 2 + (A.y - B.y) ** 2);
代码组织
写到一半的时候,想到如果要在小程序中使用,就得改代码。这种情况其实是可以通过设计模式来避免的--类似这种情况就很适合依赖注入的设计模式。
已有的代码中,模型,限制器等部分可以不用动,Cropper
中的渲染,事件绑定等功能则需要进一步抽象,可以抽象为一个Adapter
类,它负责渲染画面,监听事件等与环境相关的事情。
Cropper
则需要添加一个静态方法use
,使得外部可以注册新的功能模块。Cropper创建子功能模块时,不再new写死import进来的模块,而是使用use方法注册的模块。这样就将控制权交给了使用者。
为了保证使用者传递的模块可用,可以使用ts提供的interface
和implements
来约束使用者必须传入包含指定接口的类。这样也有利于多人开发。
如果做得更加彻底,所有的功能模块完全拆分,也许可以实现按功能构建代码。
比如在不需要限制的截图模式中,则可以相应的去掉limiter模块。
在pc的项目中,则可以加载只包含mouse事件的适配器等等。
嗯,这些都是feature,后续有待跟进😂...
ps.因为作者还没有在生产中用过typescript,是有意地在这种业余的项目中使用,如果有语法不正确或者有待改进的,希望大佬们不吝指教...