前端面向对象:实现一个极简的 cavans 画布

48 阅读3分钟

最近想学习一下前端面向对象的知识,发现前端使用 oop 的场景,大部分出现在 cavnas 这块,于是就打算一点点实现一个 cavnas 引擎,在这个过程中把 oop 的基础打好。本次实现的是一个简单的 canvas 内元素拖拽+滚轮放大缩小,以及一些简单的图层管理。

效果图

image.png

shape.ts

import { CanvasLayer } from "./canvasLayer";

 
// 圆形类
export class Circle extends CanvasLayer {
  constructor(
    x: number,
    y: number,
    color: string,
    public radius: number,
    scale: number = 1
  ) {
    super('circle', x, y, color, scale);
  }

  isPointInside(pos: {x: number, y: number}): boolean {
    const dx = pos.x - this.x;
    const dy = pos.y - this.y;
    return dx * dx + dy * dy <= (this.radius * this.scale) ** 2;
  }
}

// 正方形类
export class Square extends CanvasLayer {
  constructor(
    x: number,
    y: number,
    color: string,
    public size: number,
    scale: number = 1
  ) {
    super('square', x, y, color, scale);
  }

  isPointInside(pos: {x: number, y: number}): boolean {
    const halfSize = (this.size * this.scale) / 2;
    return pos.x >= this.x - halfSize &&
           pos.x <= this.x + halfSize &&
           pos.y >= this.y - halfSize &&
           pos.y <= this.y + halfSize;
  }
}

// 三角形类
export class Triangle extends CanvasLayer {
  constructor(
    x: number,
    y: number,
    color: string,
    public size: number,
    scale: number = 1
  ) {
    super('triangle', x, y, color, scale);
  }

  isPointInside(pos: {x: number, y: number}): boolean {
    const scaledSize = this.size * this.scale;
    const h = scaledSize * Math.sqrt(3) / 2;
    
    const p1 = {x: this.x, y: this.y - h/3*2};
    const p2 = {x: this.x - scaledSize/2, y: this.y + h/3};
    const p3 = {x: this.x + scaledSize/2, y: this.y + h/3};
    
    const area = this._triangleArea(p1, p2, p3);
    const area1 = this._triangleArea(pos, p2, p3);
    const area2 = this._triangleArea(p1, pos, p3);
    const area3 = this._triangleArea(p1, p2, pos);
    
    return Math.abs(area - (area1 + area2 + area3)) < 0.1;
  }

  private _triangleArea(p1: {x: number, y: number}, p2: {x: number, y: number}, p3: {x: number, y: number}): number {
    return Math.abs((p1.x*(p2.y-p3.y) + p2.x*(p3.y-p1.y) + p3.x*(p1.y-p2.y))/2);
  }
}

canvasLayer.ts

import { EventEmitter } from "./EventEmitter";

 
// 基础形状类
export abstract class CanvasLayer extends EventEmitter {
  constructor(
    public type: 'circle' | 'triangle' | 'square',
    public x: number,
    public y: number,
    public color: string,
    public scale: number = 1
  ) {
    super();
  }

  abstract isPointInside(pos: {x: number, y: number}): boolean;
}


EventEmitter

// 事件发射器类
export class EventEmitter {
  private listeners: { [event: string]: Function[] } = {};

  on(event: string, callback: Function): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  off(event: string, callback: Function): void {
    if (!this.listeners[event]) return;
    this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
  }

  emit(event: string, ...args: any[]): void {
    if (!this.listeners[event]) return;
    this.listeners[event].forEach(callback => callback(...args));
  }
}

canvasModel.ts

import { CanvasLayer } from "./canvasLayer";

export class CanvasModel {
  private _shapes: CanvasLayer[] = [];

  constructor() {
  }

  addShape(shape: CanvasLayer): void {
    this._shapes.push(shape);
  }

  scaleShape(shape: CanvasLayer, scaleFactor: number): void {
    shape.scale *= scaleFactor;
  }

  removeShape(shape: CanvasLayer): void {
    const index = this._shapes.indexOf(shape);
    if (index > -1) {
      this._shapes.splice(index, 1);
    }
  }

  getShapes(): CanvasLayer[] {
    return [...this._shapes];
  }

  findShapeAtPosition(pos: {x: number, y: number}): CanvasLayer | null {
    for (let i = this._shapes.length - 1; i >= 0; i--) {
      const shape = this._shapes[i];
      if (shape.isPointInside(pos)) {
        return shape;
      }
    }
    return null;
  }

  updateShapePosition(shape: CanvasLayer, newX: number, newY: number): void {
    shape.x = newX;
    shape.y = newY;
  }
}

index.ts

import { CanvasLayer } from "./canvasLayer";
import { CanvasModel } from "./canvasModel";

export class SimpleCanvas {
  _canvas: HTMLCanvasElement | null = null;
  _ctx: CanvasRenderingContext2D | null = null;
  _model: CanvasModel;
  _isDragging: boolean = false;
  _draggedShape: CanvasLayer | null = null;
  _isAnimating: boolean = false;
  _animationFrameId: number | null = null;

  constructor(canvas: HTMLCanvasElement) {
    this._canvas = canvas;
    this._ctx = this._canvas.getContext('2d');
    this._model = new CanvasModel();
    this._initializeEventListeners();
    this._startAnimationLoop();
  }

  private _startAnimationLoop(): void {
    this._isAnimating = true;
    this._animate();
  }

  private _animate = (): void => {
    if (!this._isAnimating) return;

    this._redraw();
    this._animationFrameId = requestAnimationFrame(this._animate);
  }

  public dispose(): void {
    this._isAnimating = false;
    cancelAnimationFrame(this._animationFrameId!);
  }

  private _initializeEventListeners(): void {
    if (!this._canvas) return;
    this._canvas.addEventListener('mousedown', this._handleMouseDown.bind(this));
    this._canvas.addEventListener('mousemove', this._handleMouseMove.bind(this));
    this._canvas.addEventListener('mouseup', this._handleMouseUp.bind(this));
    this._canvas.addEventListener('click', this._handleClick.bind(this));
    this._canvas.addEventListener('wheel', this._handleWheel);
  }

  private _handleClick(event: MouseEvent): void {
    const mousePos = this._getMousePos(event);
    const clickedShape = this._model.findShapeAtPosition(mousePos);
    if (clickedShape) {
      console.log('Shape clicked:', clickedShape);
    }
  }

  private _handleWheel = (event: WheelEvent): void => {
    event.preventDefault();
    const mousePos = this._getMousePos(event);
    const shape = this._model.findShapeAtPosition(mousePos);
    if (shape) {
      const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; // 缩小或放大
      this._model.scaleShape(shape, scaleFactor);
    }
  }

  private _handleMouseDown(event: MouseEvent): void {
    const mousePos = this._getMousePos(event);
    const clickedShape = this._model.findShapeAtPosition(mousePos);
    if (clickedShape) {
      this._isDragging = true;
      this._draggedShape = clickedShape;
      this._moveShapeToTop(clickedShape);
    }
  }

  private _handleMouseMove(event: MouseEvent): void {
    if (this._isDragging && this._draggedShape) {
      const mousePos = this._getMousePos(event);
      this._model.updateShapePosition(this._draggedShape, mousePos.x, mousePos.y);
    }
  }

  private _handleMouseUp(event: MouseEvent): void {
    if (this._isDragging) {
      this._isDragging = false;
      this._draggedShape = null;
    } else {
      const mousePos = this._getMousePos(event);
      const clickedShape = this._model.findShapeAtPosition(mousePos);
      if (clickedShape) {
        console.log('Clicked shape:', clickedShape);
      }
    }
  }

  private _getMousePos(event: MouseEvent): {x: number, y: number} {
    const rect = this._canvas!.getBoundingClientRect();
    return {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top
    };
  }

  public addShape(shape: Shape): void {
    shape.scale = shape.scale || 1; // 设置默认缩放值
    this._model.addShape(shape);
  }

  _redraw(): void {
    if (!this._ctx || !this._canvas) return;
    this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
    this._model.getShapes().forEach(shape => this._drawShape(shape));
  }


  private _drawShape(shape: CanvasLayer): void {
    if (!this._ctx) return;
    this._ctx.save();
    this._ctx.translate(shape.x, shape.y);
    this._ctx.scale(shape.scale, shape.scale);
    this._ctx.beginPath();
    
    switch (shape.type) {
      case 'circle':
        this._ctx.arc(0, 0, shape.radius || 0, 0, 2 * Math.PI);
        break;
      case 'triangle':
        const size = shape.size || 0;
        const height = (Math.sqrt(3) / 2) * size;
        this._ctx.moveTo(0, -height / 2);
        this._ctx.lineTo(-size / 2, height / 2);
        this._ctx.lineTo(size / 2, height / 2);
        this._ctx.closePath();
        break;
      case 'square':
        const halfSize = (shape.size || 0) / 2;
        this._ctx.rect(-halfSize, -halfSize, shape.size || 0, shape.size || 0);
        break;
    }
    
    this._ctx.fillStyle = shape.color;
    this._ctx.fill();
    this._ctx.restore();
  }

  private _moveShapeToTop(shape: Shape): void {
    const index = this._model.getShapes().indexOf(shape);
    if (index > -1) {
      this._model.removeShape(shape);
      this._model.addShape(shape);
      this._redraw();
    }
  }
}

app.tsx

import { useEffect, useRef } from 'react'
import {SimpleCanvas} from './components/editor';
import './App.css'
import { Circle, Square, Triangle } from './components/editor/shapes';

function App() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const simpleCanvasRef = useRef<SimpleCanvas | null>(null);


  useEffect(() => {
    if (canvasRef.current) {
      const simpleCanvas = new SimpleCanvas(canvasRef.current);
      simpleCanvasRef.current = simpleCanvas;
    }
    const canvas = new SimpleCanvas(document.getElementById('myCanvas') as HTMLCanvasElement);

    canvas.addShape(new Circle(100, 100, 'red', 50, 1))
    canvas.addShape(new Square(200, 200, 'blue', 80, 1))
    canvas.addShape(new Triangle(150, 250, 'green', 80, 1))

    return () => {
      if (simpleCanvasRef.current) {
        simpleCanvasRef.current.dispose();
      }
    };
  }, []);

  return (
    <>
      <canvas ref={canvasRef} id="myCanvas" width="800" height="600" className="canvas-border"></canvas>
    </>
  )
}

export default App