Skip to content

选择框与控制点进阶

在基础篇中,我们实现了单对象的选择框系统。本章将探讨更复杂的场景:

  • 如何处理多对象选择的边界框?
  • 如何实现 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 多选变换的分布式实现

设计目标:拖拽多选边界框的控制点时,所有对象应按比例变换

核心思路

  1. 记录每个对象相对于多选边界框的相对位置和尺寸比例
  2. 变换边界框时,按比例更新所有对象
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 等企业级编辑器中得到验证,能支持:

  • 数万对象的流畅交互
  • 复杂嵌套(组、画板、切片)
  • 插件扩展(自定义控制器)
选择框与控制点进阶 has loaded