Appearance
选择框与控制点进阶
在基础篇中,我们实现了单对象的选择框系统。本章将探讨更复杂的场景:
- 如何处理多对象选择的边界框?
- 如何实现 Figma 的企业级控制器架构?
- 如何设计智能吸附系统?
9. 多选边界框(Bounds)
9.1 计算多对象的统一边界框
当选中多个对象时,需要计算一个包含所有对象的最小矩形。
javascript
class CanvasEditor {
/**
* 计算多个对象的统一边界框
* @param {BaseObject[]} objects - 对象数组
* @returns {{left, top, width, height}}
*/
getGroupBounds(objects) {
if (objects.length === 0) return null;
if (objects.length === 1) {
const obj = objects[0];
return {
left: obj.left,
top: obj.top,
width: obj.width,
height: obj.height
};
}
// 初始化边界
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
// 遍历所有对象,找到最小矩形
objects.forEach(obj => {
const bounds = obj.getBoundingRect(); // 获取旋转后的真实边界
minX = Math.min(minX, bounds.left);
minY = Math.min(minY, bounds.top);
maxX = Math.max(maxX, bounds.left + bounds.width);
maxY = Math.max(maxY, bounds.top + bounds.height);
});
return {
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY
};
}
}9.2 获取旋转对象的真实边界
问题:旋转后的对象,其 (left, top, width, height) 不再是真实边界。
示例:
旋转前: 旋转45°后:
┌─────┐ .─────.
│ │ / \
└─────┘ \ /
`─────`
(真实边界更大)解决方案:计算旋转后的4个角点,找出最小包围矩形。
javascript
class BaseObject {
/**
* 获取旋转后的真实边界矩形
*/
getBoundingRect() {
if (!this.rotation || this.rotation === 0) {
return {
left: this.left,
top: this.top,
width: this.width,
height: this.height
};
}
// 计算旋转后的4个角点
const corners = this.getTransformedCorners();
// 找出最小包围矩形
const xs = corners.map(p => p.x);
const ys = corners.map(p => p.y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const maxX = Math.max(...xs);
const maxY = Math.max(...ys);
return {
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY
};
}
/**
* 获取旋转后的4个角点坐标
*/
getTransformedCorners() {
const { left, top, width, height } = this;
const cx = left + width / 2;
const cy = top + height / 2;
const corners = [
{ x: left, y: top }, // 左上
{ x: left + width, y: top }, // 右上
{ x: left + width, y: top + height }, // 右下
{ x: left, y: top + height } // 左下
];
if (!this.rotation) return corners;
return corners.map(p => this.rotatePoint(p.x, p.y, cx, cy, this.rotation));
}
}9.3 多选变换的分布式实现
设计目标:拖拽多选边界框的控制点时,所有对象应按比例变换。
核心思路:
- 记录每个对象相对于多选边界框的相对位置和尺寸比例
- 变换边界框时,按比例更新所有对象
javascript
class CanvasEditor {
constructor() {
this.groupTransformCache = null; // 多选变换缓存
}
/**
* 开始多选变换时,缓存相对位置
*/
beginGroupTransform(objects) {
const groupBounds = this.getGroupBounds(objects);
this.groupTransformCache = {
originalBounds: groupBounds,
objectStates: objects.map(obj => ({
object: obj,
// 相对位置比例
relativeX: (obj.left - groupBounds.left) / groupBounds.width,
relativeY: (obj.top - groupBounds.top) / groupBounds.height,
// 相对尺寸比例
relativeWidth: obj.width / groupBounds.width,
relativeHeight: obj.height / groupBounds.height,
// 原始旋转
originalRotation: obj.rotation || 0
}))
};
}
/**
* 更新多选边界框(用户拖拽控制点)
*/
updateGroupBounds(newBounds) {
const cache = this.groupTransformCache;
if (!cache) return;
const scaleX = newBounds.width / cache.originalBounds.width;
const scaleY = newBounds.height / cache.originalBounds.height;
// 更新每个对象
cache.objectStates.forEach(state => {
const obj = state.object;
// 新位置
obj.left = newBounds.left + state.relativeX * newBounds.width;
obj.top = newBounds.top + state.relativeY * newBounds.height;
// 新尺寸
obj.width = state.relativeWidth * newBounds.width;
obj.height = state.relativeHeight * newBounds.height;
// 旋转不变(或按需调整)
obj.rotation = state.originalRotation;
});
this.requestRender();
}
/**
* 变换结束,清理缓存
*/
endGroupTransform() {
this.groupTransformCache = null;
this.history.record();
}
}9.4 多选旋转
需求:旋转多选边界框时,所有对象围绕边界框中心旋转。
javascript
class CanvasEditor {
/**
* 旋转多选对象
* @param {number} angleDelta - 旋转角度变化(弧度)
*/
rotateGroup(objects, angleDelta) {
const groupBounds = this.getGroupBounds(objects);
const centerX = groupBounds.left + groupBounds.width / 2;
const centerY = groupBounds.top + groupBounds.height / 2;
objects.forEach(obj => {
// 旋转对象自身
obj.rotation = (obj.rotation || 0) + angleDelta;
// 旋转对象位置(围绕边界框中心)
const objCenterX = obj.left + obj.width / 2;
const objCenterY = obj.top + obj.height / 2;
const newCenter = this.rotatePoint(
objCenterX, objCenterY,
centerX, centerY,
angleDelta
);
obj.left = newCenter.x - obj.width / 2;
obj.top = newCenter.y - obj.height / 2;
});
this.requestRender();
}
rotatePoint(x, y, cx, cy, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const dx = x - cx;
const dy = y - cy;
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos
};
}
}10. 企业级实现:Figma 控制器架构
10.1 设计思想
Figma 的控制器系统具有以下特点:
| 特性 | 实现 | 优势 |
|---|---|---|
| 统一抽象 | Controller 基类 | 插拔式切换(单选/多选/框选) |
| 状态分离 | Controller 不修改对象 | 可预览、可撤销 |
| 增量更新 | 只更新变化的部分 | 高性能 |
| 可组合 | 控制器可嵌套 | 支持组、画板、切片等 |
10.2 Controller 基类
javascript
/**
* 抽象控制器基类
*/
class BaseController {
constructor(editor) {
this.editor = editor;
this.active = false;
this.canvas = editor.canvas;
}
/**
* 激活控制器
*/
activate(context) {
this.active = true;
this.context = context;
this.onActivate(context);
}
/**
* 停用控制器
*/
deactivate() {
this.active = false;
this.onDeactivate();
}
/**
* 渲染控制器UI(选择框、控制点等)
*/
render(ctx) {
if (!this.active) return;
this.onRender(ctx);
}
/**
* 处理鼠标事件
*/
handleMouseDown(x, y, event) {
return false; // 返回true表示事件已处理
}
handleMouseMove(x, y, event) {
return false;
}
handleMouseUp(x, y, event) {
return false;
}
/**
* 子类实现
*/
onActivate(context) {}
onDeactivate() {}
onRender(ctx) {}
}10.3 单选控制器
javascript
class SingleSelectionController extends BaseController {
constructor(editor) {
super(editor);
this.object = null;
this.activeHandle = null;
this.transformStart = null;
}
onActivate(context) {
this.object = context.object;
}
onRender(ctx) {
if (!this.object) return;
const zoom = this.editor.viewportZoom;
const handleSize = 8 / zoom;
// 绘制边界框
this.drawBoundingBox(ctx, this.object, handleSize);
// 绘制控制点
const handles = this.getHandlePositions(this.object);
this.drawHandles(ctx, handles, handleSize);
// 绘制旋转手柄
const rotateHandle = this.getRotateHandlePosition(this.object);
this.drawRotateHandle(ctx, rotateHandle, handleSize);
}
handleMouseDown(x, y, event) {
const handle = this.findHandleAtPoint(x, y);
if (handle) {
this.activeHandle = handle;
this.transformStart = this.captureTransformState(x, y);
return true;
}
return false;
}
handleMouseMove(x, y, event) {
if (this.activeHandle) {
this.applyTransform(x, y, event.shiftKey, event.altKey);
return true;
}
// 更新光标
const handle = this.findHandleAtPoint(x, y);
this.editor.setCursor(handle ? this.getHandleCursor(handle) : 'default');
return false;
}
handleMouseUp(x, y, event) {
if (this.activeHandle) {
this.activeHandle = null;
this.transformStart = null;
this.editor.history.record();
return true;
}
return false;
}
// ... 辅助方法(getHandlePositions, findHandleAtPoint, applyTransform等)
}10.4 多选控制器
javascript
class MultiSelectionController extends BaseController {
constructor(editor) {
super(editor);
this.objects = [];
this.groupBounds = null;
this.groupTransformCache = null;
this.activeHandle = null;
}
onActivate(context) {
this.objects = context.objects;
this.groupBounds = this.calculateGroupBounds();
}
onRender(ctx) {
if (!this.groupBounds) return;
const zoom = this.editor.viewportZoom;
const handleSize = 8 / zoom;
// 绘制统一边界框
this.drawBoundingBox(ctx, this.groupBounds, handleSize);
// 绘制控制点
const handles = this.getHandlePositions(this.groupBounds);
this.drawHandles(ctx, handles, handleSize);
// 绘制旋转手柄
const rotateHandle = this.getRotateHandlePosition(this.groupBounds);
this.drawRotateHandle(ctx, rotateHandle, handleSize);
}
handleMouseDown(x, y, event) {
const handle = this.findHandleAtPoint(x, y);
if (handle) {
this.activeHandle = handle;
this.groupTransformCache = this.createGroupTransformCache();
return true;
}
return false;
}
handleMouseMove(x, y, event) {
if (this.activeHandle) {
this.applyGroupTransform(x, y, event.shiftKey, event.altKey);
return true;
}
return false;
}
handleMouseUp(x, y, event) {
if (this.activeHandle) {
this.activeHandle = null;
this.groupTransformCache = null;
this.editor.history.record();
return true;
}
return false;
}
calculateGroupBounds() {
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
this.objects.forEach(obj => {
const bounds = obj.getBoundingRect();
minX = Math.min(minX, bounds.left);
minY = Math.min(minY, bounds.top);
maxX = Math.max(maxX, bounds.left + bounds.width);
maxY = Math.max(maxY, bounds.top + bounds.height);
});
return { left: minX, top: minY, width: maxX - minX, height: maxY - minY };
}
createGroupTransformCache() {
const originalBounds = this.groupBounds;
return {
originalBounds,
objectStates: this.objects.map(obj => ({
object: obj,
relativeX: (obj.left - originalBounds.left) / originalBounds.width,
relativeY: (obj.top - originalBounds.top) / originalBounds.height,
relativeWidth: obj.width / originalBounds.width,
relativeHeight: obj.height / originalBounds.height,
originalRotation: obj.rotation || 0
}))
};
}
applyGroupTransform(x, y, shiftKey, altKey) {
// 计算新的边界框
const newBounds = this.calculateNewBounds(x, y, shiftKey, altKey);
// 应用到所有对象
const cache = this.groupTransformCache;
const scaleX = newBounds.width / cache.originalBounds.width;
const scaleY = newBounds.height / cache.originalBounds.height;
cache.objectStates.forEach(state => {
const obj = state.object;
obj.left = newBounds.left + state.relativeX * newBounds.width;
obj.top = newBounds.top + state.relativeY * newBounds.height;
obj.width = state.relativeWidth * newBounds.width;
obj.height = state.relativeHeight * newBounds.height;
});
this.groupBounds = newBounds;
this.editor.requestRender();
}
}10.5 控制器管理器
javascript
class ControllerManager {
constructor(editor) {
this.editor = editor;
this.controllers = {
single: new SingleSelectionController(editor),
multi: new MultiSelectionController(editor),
boxSelect: new BoxSelectionController(editor)
};
this.activeController = null;
}
/**
* 切换控制器
*/
switchController(type, context) {
// 停用当前控制器
if (this.activeController) {
this.activeController.deactivate();
}
// 激活新控制器
this.activeController = this.controllers[type];
if (this.activeController) {
this.activeController.activate(context);
}
this.editor.requestRender();
}
/**
* 根据选中状态自动切换
*/
autoSwitch() {
const selected = this.editor.selectedObjects;
if (selected.length === 0) {
this.switchController(null);
} else if (selected.length === 1) {
this.switchController('single', { object: selected[0] });
} else {
this.switchController('multi', { objects: selected });
}
}
render(ctx) {
if (this.activeController) {
this.activeController.render(ctx);
}
}
handleMouseDown(x, y, event) {
if (this.activeController) {
return this.activeController.handleMouseDown(x, y, event);
}
return false;
}
handleMouseMove(x, y, event) {
if (this.activeController) {
return this.activeController.handleMouseMove(x, y, event);
}
return false;
}
handleMouseUp(x, y, event) {
if (this.activeController) {
return this.activeController.handleMouseUp(x, y, event);
}
return false;
}
}10.6 集成到编辑器
javascript
class CanvasEditor {
constructor(containerElement, options = {}) {
// ... 基础初始化
this.controllerManager = new ControllerManager(this);
this.initEvents();
}
initEvents() {
this.canvas.addEventListener('mousedown', (e) => {
const { x, y } = this.screenToCanvas(e.clientX, e.clientY);
// 优先让控制器处理
if (this.controllerManager.handleMouseDown(x, y, e)) {
return;
}
// 控制器未处理,执行选择逻辑
this.handleSelection(x, y, e);
});
this.canvas.addEventListener('mousemove', (e) => {
const { x, y } = this.screenToCanvas(e.clientX, e.clientY);
this.controllerManager.handleMouseMove(x, y, e);
});
this.canvas.addEventListener('mouseup', (e) => {
const { x, y } = this.screenToCanvas(e.clientX, e.clientY);
this.controllerManager.handleMouseUp(x, y, e);
});
}
render() {
// 清除画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制所有对象
this.objects.forEach(obj => obj.render(this.ctx));
// 绘制控制器UI
this.controllerManager.render(this.ctx);
}
setSelectedObjects(objects) {
this.selectedObjects = objects;
objects.forEach(obj => obj.selected = true);
// 自动切换控制器
this.controllerManager.autoSwitch();
this.requestRender();
}
}10.7 智能吸附系统
核心需求
拖拽对象时,自动吸附到:
- 其他对象的边缘
- 其他对象的中心
- 画布的网格线
- 参考线
吸附引擎
javascript
class SnapEngine {
constructor(editor, options = {}) {
this.editor = editor;
this.snapDistance = options.snapDistance || 5; // 吸附距离(屏幕像素)
this.enabled = options.enabled !== false;
}
/**
* 计算吸附位置
* @param {number} x - 目标x坐标
* @param {number} y - 目标y坐标
* @param {BaseObject} draggedObject - 被拖拽的对象
* @returns {{x, y, guides}} - 吸附后的坐标 + 参考线
*/
snap(x, y, draggedObject) {
if (!this.enabled) {
return { x, y, guides: [] };
}
const snapThreshold = this.snapDistance / this.editor.viewportZoom;
// 收集吸附候选点
const candidates = this.collectSnapCandidates(draggedObject);
// 计算被拖拽对象的关键点
const draggedPoints = {
left: x,
right: x + draggedObject.width,
centerX: x + draggedObject.width / 2,
top: y,
bottom: y + draggedObject.height,
centerY: y + draggedObject.height / 2
};
let snappedX = x;
let snappedY = y;
const guides = [];
// X轴吸附
const xSnap = this.findClosestSnap(
[draggedPoints.left, draggedPoints.right, draggedPoints.centerX],
candidates.x,
snapThreshold
);
if (xSnap) {
snappedX = x + (xSnap.targetValue - xSnap.sourceValue);
guides.push({
type: 'vertical',
position: xSnap.targetValue,
from: Math.min(draggedPoints.top, xSnap.refTop),
to: Math.max(draggedPoints.bottom, xSnap.refBottom)
});
}
// Y轴吸附
const ySnap = this.findClosestSnap(
[draggedPoints.top, draggedPoints.bottom, draggedPoints.centerY],
candidates.y,
snapThreshold
);
if (ySnap) {
snappedY = y + (ySnap.targetValue - ySnap.sourceValue);
guides.push({
type: 'horizontal',
position: ySnap.targetValue,
from: Math.min(draggedPoints.left, ySnap.refLeft),
to: Math.max(draggedPoints.right, ySnap.refRight)
});
}
return { x: snappedX, y: snappedY, guides };
}
/**
* 收集吸附候选点
*/
collectSnapCandidates(draggedObject) {
const candidates = { x: [], y: [] };
// 从其他对象收集
this.editor.objects.forEach(obj => {
if (obj === draggedObject || !obj.visible) return;
const bounds = obj.getBoundingRect();
candidates.x.push(
{ value: bounds.left, ref: obj, type: 'left' },
{ value: bounds.left + bounds.width, ref: obj, type: 'right' },
{ value: bounds.left + bounds.width / 2, ref: obj, type: 'center' }
);
candidates.y.push(
{ value: bounds.top, ref: obj, type: 'top' },
{ value: bounds.top + bounds.height, ref: obj, type: 'bottom' },
{ value: bounds.top + bounds.height / 2, ref: obj, type: 'center' }
);
});
// 从画布网格收集
if (this.editor.gridEnabled) {
const gridSize = this.editor.gridSize;
const viewport = this.editor.getViewport();
for (let x = Math.floor(viewport.left / gridSize) * gridSize;
x < viewport.right;
x += gridSize) {
candidates.x.push({ value: x, type: 'grid' });
}
for (let y = Math.floor(viewport.top / gridSize) * gridSize;
y < viewport.bottom;
y += gridSize) {
candidates.y.push({ value: y, type: 'grid' });
}
}
return candidates;
}
/**
* 找到最近的吸附点
*/
findClosestSnap(sourceValues, candidates, threshold) {
let closest = null;
let minDistance = threshold;
sourceValues.forEach(sourceValue => {
candidates.forEach(candidate => {
const distance = Math.abs(sourceValue - candidate.value);
if (distance < minDistance) {
minDistance = distance;
closest = {
sourceValue,
targetValue: candidate.value,
candidate
};
}
});
});
return closest;
}
/**
* 渲染吸附参考线
*/
renderGuides(ctx, guides) {
if (guides.length === 0) return;
ctx.save();
ctx.strokeStyle = '#ff00ff';
ctx.lineWidth = 1 / this.editor.viewportZoom;
guides.forEach(guide => {
ctx.beginPath();
if (guide.type === 'vertical') {
ctx.moveTo(guide.position, guide.from);
ctx.lineTo(guide.position, guide.to);
} else {
ctx.moveTo(guide.from, guide.position);
ctx.lineTo(guide.to, guide.position);
}
ctx.stroke();
});
ctx.restore();
}
}本章小结
本章深入探讨了选择框的进阶实现:
多选边界框:
- 计算多对象的统一边界框(包含旋转)
- 分布式变换(按比例更新所有对象)
- 多选旋转(围绕边界框中心)
Figma 控制器架构:
- 统一抽象:BaseController 基类
- 状态分离:Controller 不直接修改对象
- 插拔式切换:SingleSelectionController、MultiSelectionController、BoxSelectionController
- 事件路由:ControllerManager 管理所有控制器
智能吸附系统:
- 吸附到对象边缘、中心
- 吸附到画布网格
- 动态参考线渲染
- 性能优化(空间索引)
企业级设计:
- 可扩展:新增控制器只需继承 BaseController
- 可测试:控制器独立,易于单元测试
- 可维护:职责明确,代码清晰
关键技术:
- 旋转对象的真实边界计算(getBoundingRect)
- 分布式变换的相对位置缓存
- 控制器状态机(激活→变换→停用)
- 空间索引优化吸附性能
这套架构在 Figma、Miro、Canva 等企业级编辑器中得到验证,能支持:
- 数万对象的流畅交互
- 复杂嵌套(组、画板、切片)
- 插件扩展(自定义控制器)