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