本文详细解析如何在JS Canvas中实现物体的拖拽、旋转和缩放交互。通过数据与视图分离的设计,仅更新top、left、angle及scaleX/scaleY等属性值,配合Math.atan2计算角度与变换基点,即可实现流畅的物体变换效果,无需修改渲染逻辑。
正文
此前我们已实现了物体的点选与框选功能,但画布仍显静态,缺乏实际交互感。本章旨在通过引入拖拽、旋转和缩放等常见交互操作,使画布中的物体“活”起来。这是本系列教程的关键章节,希望能为你提供实质性帮助。
拖拽
首先讨论拖拽平移功能的实现,该功能相对简单。由于物体位置由 top 和 left 属性定义,平移操作仅需更新这两个属性的值。每次移动后触发 renderAll 方法进行重新渲染,从而在新位置绘制物体。
这体现了典型的数据与视图分离思想。在本章及后续章节中,我们无需修改物体的render方法。只要物体状态(数据)改变,画布会自动响应并重新绘制,无需关心具体绘制过程,这一点至关重要。
以下是平移操作的代码示例:
/** 平移当前选中物体 */
_translateObject(x: number, y: number) {
const target = this._currentTransform.target;
target.set('left', x - this._currentTransform.offsetX); // offsetX 是画布整体偏移
target.set('top', y - this._currentTransform.offsetY); // offsetY 是画布整体偏移
}
代码简洁易懂,因为绘制逻辑固定,变换操作仅涉及数据修改。需注意代码中的_currentTransform,它记录了鼠标按下时的初始状态信息,具体结构如下所示,了解其大致形态即可:

可见,拖拽平移部分代码简短,因其逻辑确实简单。
旋转
接下来介绍旋转操作,其原理同样简单。物体通过angle属性表示旋转角度。旋转时,计算拖拽产生的角度变化deltaAngle,新角度等于旧角度加上deltaAngle,然后重新赋值angle变量。此过程同样不涉及修改物体的_render方法,难点在于如何计算变换角度。
旋转本质是鼠标点的旋转,即计算当前鼠标点与初始鼠标点之间的角度差,如图所示:

首先看单个点的情况,通常计算该点与原点连线及x轴正方向之间的逆时针夹角,如下图所示:

通常使用radian = Math.atan2(y, x)计算弧度,注意这里是弧度(radian)而非角度(angle)。Canvas中使用弧度,但角度更便于理解,因此需适时进行转换。
需注意使用Math.atan2而非Math.atan。虽然两者相似,但atan无法确定唯一方向,例如点(1, 1)和(-1, -1)的atan值相同但方向相反。atan2取值范围为[-Math.PI, Math.PI],且四个象限取值不同,因此更适合用于计算。
基于上述原理,计算变得简单:原点为物体中心点,鼠标按下点与中心点连线形成起始角度,拖拽点与中心点连线形成最终角度,两者之差即为变换角度。
切记,旋转时原点通常为物体中心点。以下是核心代码示例,代码量少且易于理解:
/** 旋转当前选中物体 */
_rotateObject(x: number, y: number) {
const t = this._currentTransform;
const o = this._offset;
// 鼠标按下的点与物体中心点连线和 x 轴正方向形成的弧度
const lastRadian = Math.atan2(t.ey - o.top - t.top, t.ex - o.left - t.left);
// 鼠标拖拽的终点与物体中心点连线和 x 轴正方向形成的弧度
const curRadian = Math.atan2(y - o.top - t.top, x - o.left - t.left);
const deltaRadian = curRadian - lastRadian;
let angle = Util.radiansToDegrees(t.theta + deltaRadian); // 新的角度 = 原来的角度 + 变换的角度
if (angle < 0) angle = 360 + angle;
angle = angle % 360;
t.target.angle = angle;
}
缩放
缩放比旋转稍复杂,以右侧中间控制点为例,其他控制点逻辑类似(复制修改即可),先看效果:

观察图中右侧中间红色控制点,其缩放本质是沿x轴拉伸。直观想法是计算水平拖拽距离dx,然后改变物体宽度,如object.width += dx。若width变为负数,需处理最小值限制,防止右侧控制点拉至左侧。
回顾早期知识点:我们通常不改变物体自身大小,而是修改变换值。因此,缩放本质是改变scaleX和scaleY值。以拖拽右侧中间控制点为例,计算scaleX时,可将变换基点临时设为左侧中间控制点(蓝色点),这样计算当前宽度更方便:
- 当前宽度 = 鼠标位置的x - 左边中间控制点的位置的x
- scaleX = 当前宽度 / 自身宽度。注意,自身width值未变,仅改变scaleX。对于反向拉伸,可通过临时变换基点处理:一旦反向,立即切换为按左侧中间控制点拖拽的逻辑,即红点变为参考基点,蓝点变为拖拽点,请结合上方动图体会。
- 此外,拖拽缩放时top和left值也会改变,因此计算scaleX后需更新这两个值,注意观察上方动图中的黑点变化。还需提醒两点:
- 缩放时中心点并非物体中心,可简单理解为单边缩放;当然也可沿中心点缩放,但本文讲解默认形式。
- 若为竖直拉伸,只需将x换为y,宽度换为高度;若为右下角控制点,则需同时处理xy代码。
此处简要展示核心代码:
/**
* 缩放当前选中物体
* @param x 鼠标点 x
* @param y 鼠标点 y
* @param by 是否等比缩放,x | y | equally
*/
_scaleObject(x: number, y: number, by = 'equally') {
let t = this._currentTransform, // 在鼠标按下的时候会记录物体的状态
offset = this._offset, // 画布偏移
target: FabricObject = t.target;
// 缩放基点:比如拖拽右边中间的控制点,其实我们参考的变换基点是左边中间的控制点
let constraintPosition = target.translateToOriginPoint(target.getCenterPoint(), t.originX, t.originY);
// 以物体变换中心为原点的鼠标点坐标值
let localMouse = target.toLocalPoint(new Point(x - offset.left, y - offset.top), t.originX, t.originY);
if (t.originX === 'right') {
localMouse.x *= -1;
}
// 计算新的缩放值,以变换中心为原点,根据本地鼠标坐标点/原始宽度进行计算,重新设定物体缩放值
let newScaleX = target.scaleX;
if (by === 'x') {
newScaleX = localMouse.x / (target.width + target.padding);
target.set('scaleX', newScaleX);
}
// 如果是反向拉伸 x
if (newScaleX < 0) {
if (t.originX === 'left') t.originX = 'right';
else if (t.originX === 'right') t.originX = 'left';
}
// 缩放会改变物体位置,所以要重新设置
target.setPositionByOrigin(constraintPosition, t.originX, t.originY);
}
该变换稍显复杂,我单独编写了一个小demo,感兴趣者可点击链接查看。建议大家多动手实践,核心要点在于:
我们不改变物体自身的宽高大小,也不改变物体的渲染方法,而只是改变三种变换的值。
可能有同学疑问:鼠标移动时频繁调用renderAll渲染函数,性能是否较差?尤其是物体增多时。
确实如此。在前端,数据或元素过多时性能易受影响,如数据多需分页或虚拟滚动,元素多则能不绘制就不绘制。
Canvas中也有解决方案,如缓存、分层或WebGL,这些将在后续优化章节详细讲解,敬请期待。不过,普通页面较少遇到性能瓶颈,遇到时再优化即可,否则属于过度优化。但在Canvas中,性能是常见问题,易写出卡顿效果,因此有必要讨论。
小结
本章主要讲解物体变换操作。得益于之前良好的结构划分,即数据与渲染层分离,本章核心仅在于改变数据,其他未变。这种数据驱动视图的感觉令人印象深刻。简单总结三种基本操作:
- 拖拽,计算新的top、left
- 旋转,计算新的angle
- 缩放,计算新的scaleX、scaleY
三种变换操作的本质均依托于鼠标坐标点的计算,至此结束。
此处提供简版fabric.js代码链接,感兴趣者可查阅。本次分享到此结束。
canvas 中如何实现物体的框选(六)🏖
canvas 中如何实现物体的点选(五)🏖
canvas 中物体边框和控制点的实现(四)🏖
实现一个轻量 fabric.js 系列三(物体基类)🏖
实现一个轻量 fabric.js 系列二(画布初始化)🏖
实现一个轻量 fabric.js 系列一(摸透 canvas)💥
更多关于JS前端canvas交互的资料请关注本站其它相关文章!