记一个轻量截图库的实现

1,749 阅读9分钟

代码地址 git仓库地址 npm地址 liveDemo

题外话

我以往用的截图功能都是基于cropperjs实现。比如曾发布的一款用于截图上传的组件

因为包含了cropperjspreact,体积高达66k。当时就有挖坑开发一款轻量级截图库的想法。

需求分析

API没什么好分析的,抄就是了参考cropperjs

技术选型上,cropperjs在预览,拖拽时,是基于DOM。
既然是轮子,总得不一样嘛,所以这个轮子,将使用纯canvas实现所有功能。

整体结构

既然基于canvas,很多在DOM中理所当然的东西都要自己实现,比如事件绑定,获取元素位置等等。另一方面,拖拽,缩放,限制等又涉及到大量计算,如果混在一起就很痛苦了。适当的切割封装非常有必要。

因此代码主要分为4个类。

  1. Cropper类,暴露出去的主类,它负责接收解析配置,绑定事件监听,绘制图像。
  2. ImageModel类,描述图像位置尺寸信息的类,负责更新图像在缩放,移动之后信息。
  3. WindowModel类,描述了一个截图框,可以在容器内移动缩放,它的内部即最终输出区域
  4. 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的位置,放大到新的尺寸newSize200*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)

点击查看liveDemo

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非常强大。这里介绍两种方式。

  1. 基于clip实现。
  2. 基于globalCompositeOperation实现。

clip剪切一段路径之后,再次绘制只会作用在这个被剪切的路径内部,我们可以利用这一点。

  1. 先绘制图像;
  2. 再绘制全屏的半透明遮罩;
  3. 将截图框的轮廓剪切出来;
  4. 再次绘制图像。
    代码
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();

点击查看liveDemo

限制器

限制器其实没什么好说的,实际是代码组织问题。
所谓限制器,就是当对象达到某个位置时,禁止它继续移动;
当它缩小或放大到某个尺寸时,禁止它继续缩放。
基本上就是数值的比较。难点在于梳理各个截图模式,梳理清除比较的对象。
以最复杂的window模式为例

首先明确这里涉及到的几个角色。

  1. 容器,即canvas这个矩形
  2. model,即图片,它的大小,位置是可以变化的。
  3. 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提供的interfaceimplements来约束使用者必须传入包含指定接口的类。这样也有利于多人开发。

如果做得更加彻底,所有的功能模块完全拆分,也许可以实现按功能构建代码。
比如在不需要限制的截图模式中,则可以相应的去掉limiter模块。
在pc的项目中,则可以加载只包含mouse事件的适配器等等。
嗯,这些都是feature,后续有待跟进😂...

ps.因为作者还没有在生产中用过typescript,是有意地在这种业余的项目中使用,如果有语法不正确或者有待改进的,希望大佬们不吝指教...

Git源码

最后再贴下仓库地址,那个,你懂的~~~😝😂😘
git仓库地址
npm地址