Skip to content

画布管理与视口控制

你正在制作一个设计工具,画布很大(10000x10000),但屏幕只能显示一小部分。如何让用户平移和缩放画布,就像使用 Figma 或 Photoshop 一样?

这需要一个强大的 Canvas 管理系统


##1. 画布 vs 视口

  • 画布(Canvas):整个绘图区域,可能非常大
  • 视口(Viewport):屏幕上可见的部分

类比:画布是一张巨大的纸,视口是一个窗口,你通过窗口看纸的一部分。

┌─────────────────────────────┐
│                             │
│   ┌─────────┐               │  ← 画布(10000x10000)
│   │         │               │
│   │  视口   │               │
│   │         │               │
│   └─────────┘               │
│                             │
└─────────────────────────────┘

2. Canvas 管理类设计

javascript
class CanvasEditor {
  constructor(containerElement, options = {}) {
    this.container = containerElement;
    
    // 创建 Canvas 元素
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d');
    this.container.appendChild(this.canvas);
    
    // 画布尺寸
    this.width = options.width || this.container.clientWidth;
    this.height = options.height || this.container.clientHeight;
    this.setCanvasSize(this.width, this.height);
    
    // 视口变换
    this.viewportX = 0;       // 视口 X 偏移
    this.viewportY = 0;       // 视口 Y 偏移
    this.viewportZoom = 1;    // 缩放级别
    
    // 对象集合
    this.objects = [];
    
    // 渲染状态
    this._dirty = false;
    this._renderRequested = false;
    
    this.initEvents();
  }
  
  setCanvasSize(width, height) {
    this.width = width;
    this.height = height;
    
    // 处理高 DPI 屏幕
    const dpr = window.devicePixelRatio || 1;
    this.canvas.width = width * dpr;
    this.canvas.height = height * dpr;
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    this.ctx.scale(dpr, dpr);
    
    this.requestRender();
  }
  
  requestRender() {
    if (this._renderRequested) return;
    this._renderRequested = true;
    
    requestAnimationFrame(() => {
      this.render();
      this._renderRequested = false;
    });
  }
  
  render() {
    this.ctx.save();
    
    // 清空画布
    this.ctx.clearRect(0, 0, this.width, this.height);
    
    // 应用视口变换
    this.ctx.translate(this.viewportX, this.viewportY);
    this.ctx.scale(this.viewportZoom, this.viewportZoom);
    
    // 绘制所有对象
    this.objects.forEach(obj => {
      if (obj.visible) {
        obj.draw(this.ctx);
      }
    });
    
    this.ctx.restore();
  }
  
  initEvents() {
    // 下一章实现
  }
}

3. 视口平移(Pan)

按住空格键拖动画布:

javascript
class CanvasEditor {
  constructor(containerElement, options = {}) {
    // ... 其他代码 ...
    
    this.isPanning = false;
    this.panStart = { x: 0, y: 0 };
    
    this.initPanEvents();
  }
  
  initPanEvents() {
    this.canvas.addEventListener('mousedown', (e) => {
      if (e.button === 1 || e.shiftKey) {  // 中键或 Shift
        this.startPan(e.clientX, e.clientY);
        e.preventDefault();
      }
    });
    
    this.canvas.addEventListener('mousemove', (e) => {
      if (this.isPanning) {
        this.updatePan(e.clientX, e.clientY);
      }
    });
    
    this.canvas.addEventListener('mouseup', () => {
      this.endPan();
    });
  }
  
  startPan(x, y) {
    this.isPanning = true;
    this.panStart.x = x - this.viewportX;
    this.panStart.y = y - this.viewportY;
    this.canvas.style.cursor = 'grabbing';
  }
  
  updatePan(x, y) {
    this.viewportX = x - this.panStart.x;
    this.viewportY = y - this.panStart.y;
    this.requestRender();
  }
  
  endPan() {
    this.isPanning = false;
    this.canvas.style.cursor = 'default';
  }
}

4. 视口缩放(Zoom)

滚轮缩放画布:

javascript
class CanvasEditor {
  constructor(containerElement, options = {}) {
    // ... 其他代码 ...
    
    this.minZoom = 0.1;
    this.maxZoom = 10;
    
    this.initZoomEvents();
  }
  
  initZoomEvents() {
    this.canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      
      const delta = e.deltaY > 0 ? 0.9 : 1.1;  // 缩小或放大
      const newZoom = this.viewportZoom * delta;
      
      // 限制缩放范围
      if (newZoom < this.minZoom || newZoom > this.maxZoom) return;
      
      // 以鼠标位置为中心缩放
      const mouseX = e.clientX - this.canvas.offsetLeft;
      const mouseY = e.clientY - this.canvas.offsetTop;
      
      this.zoomAt(mouseX, mouseY, newZoom);
    });
  }
  
  zoomAt(x, y, newZoom) {
    // 计算缩放前鼠标在画布上的坐标
    const worldX = (x - this.viewportX) / this.viewportZoom;
    const worldY = (y - this.viewportY) / this.viewportZoom;
    
    // 更新缩放
    this.viewportZoom = newZoom;
    
    // 调整平移,使鼠标位置保持不变
    this.viewportX = x - worldX * this.viewportZoom;
    this.viewportY = y - worldY * this.viewportZoom;
    
    this.requestRender();
  }
  
  // 便捷方法
  zoomIn() {
    this.zoomAt(this.width / 2, this.height / 2, this.viewportZoom * 1.2);
  }
  
  zoomOut() {
    this.zoomAt(this.width / 2, this.height / 2, this.viewportZoom / 1.2);
  }
  
  resetZoom() {
    this.viewportZoom = 1;
    this.viewportX = 0;
    this.viewportY = 0;
    this.requestRender();
  }
}

5. 坐标转换

视口变换后,鼠标的屏幕坐标需要转换为画布坐标:

javascript
class CanvasEditor {
  // ...
  
  // 屏幕坐标 → 画布坐标
  screenToCanvas(screenX, screenY) {
    const rect = this.canvas.getBoundingClientRect();
    const x = screenX - rect.left;
    const y = screenY - rect.top;
    
    // 减去视口偏移并除以缩放
    const canvasX = (x - this.viewportX) / this.viewportZoom;
    const canvasY = (y - this.viewportY) / this.viewportZoom;
    
    return { x: canvasX, y: canvasY };
  }
  
  // 画布坐标 → 屏幕坐标
  canvasToScreen(canvasX, canvasY) {
    const x = canvasX * this.viewportZoom + this.viewportX;
    const y = canvasY * this.viewportZoom + this.viewportY;
    return { x, y };
  }
}

// 使用
canvas.addEventListener('click', (e) => {
  const { x, y } = editor.screenToCanvas(e.clientX, e.clientY);
  console.log('点击了画布的', x, y);
});

6. 对象管理集成

javascript
class CanvasEditor {
  // ...
  
  add(object) {
    this.objects.push(object);
    object.canvas = this;
    this.requestRender();
  }
  
  remove(object) {
    const index = this.objects.indexOf(object);
    if (index !== -1) {
      this.objects.splice(index, 1);
      object.canvas = null;
      this.requestRender();
    }
  }
  
  clear() {
    this.objects = [];
    this.requestRender();
  }
  
  findObjectAtPoint(x, y) {
    // 从后往前查找(上层优先)
    for (let i = this.objects.length - 1; i >= 0; i--) {
      const obj = this.objects[i];
      if (obj.visible && obj.containsPoint(x, y)) {
        return obj;
      }
    }
    return null;
  }
}

7. 完整使用示例

javascript
const editor = new CanvasEditor(document.getElementById('container'), {
  width: 800,
  height: 600
});

// 添加对象
const rect1 = new Rectangle({
  left: 100,
  top: 100,
  width: 200,
  height: 150,
  fill: 'red'
});

const rect2 = new Rectangle({
  left: 400,
  top: 200,
  width: 150,
  height: 100,
  fill: 'blue'
});

editor.add(rect1);
editor.add(rect2);

// 缩放按钮
document.getElementById('zoom-in').addEventListener('click', () => {
  editor.zoomIn();
});

document.getElementById('zoom-out').addEventListener('click', () => {
  editor.zoomOut();
});

document.getElementById('reset').addEventListener('click', () => {
  editor.resetZoom();
});

// 点击选择对象
editor.canvas.addEventListener('click', (e) => {
  const { x, y } = editor.screenToCanvas(e.clientX, e.clientY);
  const obj = editor.findObjectAtPoint(x, y);
  if (obj) {
    console.log('选中了:', obj.type);
  }
});

8. 高DPI屏幕适配

javascript
setCanvasSize(width, height) {
  this.width = width;
  this.height = height;
  
  // 获取设备像素比
  const dpr = window.devicePixelRatio || 1;
  
  // Canvas 内部分辨率
  this.canvas.width = width * dpr;
  this.canvas.height = height * dpr;
  
  // CSS 显示尺寸
  this.canvas.style.width = width + 'px';
  this.canvas.style.height = height + 'px';
  
  // 缩放上下文
  this.ctx.scale(dpr, dpr);
  
  this.requestRender();
}

在 Retina 屏幕上,devicePixelRatio 为 2,这样画布会更清晰。


9. 性能优化

脏标记

只在视口变换或对象变化时重绘:

javascript
requestRender() {
  if (this._renderRequested) return;  // 避免重复请求
  this._renderRequested = true;
  
  requestAnimationFrame(() => {
    this.render();
    this._renderRequested = false;
  });
}

视口裁剪

只绘制视口内的对象:

javascript
render() {
  this.ctx.save();
  this.ctx.clearRect(0, 0, this.width, this.height);
  
  this.ctx.translate(this.viewportX, this.viewportY);
  this.ctx.scale(this.viewportZoom, this.viewportZoom);
  
  // 计算可见区域
  const visibleLeft = -this.viewportX / this.viewportZoom;
  const visibleTop = -this.viewportY / this.viewportZoom;
  const visibleRight = visibleLeft + this.width / this.viewportZoom;
  const visibleBottom = visibleTop + this.height / this.viewportZoom;
  
  // 只绘制可见对象
  this.objects.forEach(obj => {
    if (obj.visible && this.isInViewport(obj, visibleLeft, visibleTop, visibleRight, visibleBottom)) {
      obj.draw(this.ctx);
    }
  });
  
  this.ctx.restore();
}

isInViewport(obj, left, top, right, bottom) {
  return !(obj.left + obj.width < left ||
           obj.left > right ||
           obj.top + obj.height < top ||
           obj.top > bottom);
}

本章小结

Canvas 管理系统是图形编辑器的核心:

  • 视口变换:平移和缩放改变可见区域
  • 坐标转换:屏幕坐标与画布坐标的转换
  • 渲染循环:高效的 requestAnimationFrame 渲染
  • 高DPI适配:清晰显示在 Retina 屏幕上

下一章,我们将实现对象的选择机制。

画布管理与视口控制 has loaded