├── .babelrc ├── .gitignore ├── .prettierrc ├── package-lock.json ├── package.json ├── readme.md ├── src ├── canvas-event-system │ ├── EventSimulator.ts │ ├── helpers.ts │ ├── index.ts │ └── shapes │ │ ├── Base.ts │ │ ├── Circle.ts │ │ ├── Path.ts │ │ ├── Rect.ts │ │ ├── index.ts │ │ └── types.ts ├── index.html └── index.ts ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | [ 5 | "@babel/preset-typescript", 6 | { 7 | "isTSX": true, 8 | "allExtensions": true, 9 | "jsxPragma": "h" 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties", 15 | "@babel/plugin-transform-runtime" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 130, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "jsxBracketSameLine": true, 7 | "singleQuote": true, 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-event-system", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/JS-Hao/canvas-event-system.git" 12 | }, 13 | "keywords": [ 14 | "canvas", 15 | "event", 16 | "system", 17 | "listeners" 18 | ], 19 | "author": "JS-Hao", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/JS-Hao/canvas-event-system/issues" 23 | }, 24 | "homepage": "https://github.com/JS-Hao/canvas-event-system#readme", 25 | "devDependencies": { 26 | "@babel/core": "^7.12.3", 27 | "@babel/plugin-proposal-class-properties": "^7.12.1", 28 | "@babel/plugin-transform-runtime": "^7.12.1", 29 | "@babel/preset-env": "^7.12.1", 30 | "@babel/preset-typescript": "^7.12.1", 31 | "babel-loader": "^8.1.0", 32 | "typescript": "^4.0.3", 33 | "webpack": "^4.44.2", 34 | "webpack-cli": "^3.3.2", 35 | "webpack-dev-server": "^3.3.1" 36 | }, 37 | "dependencies": { 38 | "uuid": "^8.3.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## 一个简易的 Canvas 事件系统 2 | 3 | ### 食用方法 4 | 5 | 执行如下命令行: 6 | 7 | ``` 8 | npm start 9 | ``` 10 | 11 | 访问`http://localhost:9000`即可 12 | 13 | ![](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a54e80ebb2184f0fa5fddbde16b60bd3~tplv-k3u1fbpfcp-watermark.image) 14 | -------------------------------------------------------------------------------- /src/canvas-event-system/EventSimulator.ts: -------------------------------------------------------------------------------- 1 | import { Listener, EventNames } from './shapes'; 2 | 3 | export interface Action { 4 | type: ActionType; 5 | id: string; 6 | } 7 | 8 | export enum ActionType { 9 | Down = 'DOWN', 10 | Up = 'Up', 11 | Move = 'MOVE', 12 | } 13 | 14 | export default class EventSimulator { 15 | private listenersMap: { 16 | [id: string]: { 17 | [eventName: string]: Listener[]; 18 | }; 19 | } = {}; 20 | 21 | private lastDownId: string; 22 | private lastMoveId: string; 23 | 24 | addAction(action: Action, evt: MouseEvent) { 25 | const { type, id } = action; 26 | 27 | // mousemove 28 | if (type === ActionType.Move) { 29 | this.fire(id, EventNames.mousemove, evt); 30 | } 31 | 32 | // mouseover 33 | // mouseenter 34 | if (type === ActionType.Move && (!this.lastMoveId || this.lastMoveId !== id)) { 35 | this.fire(id, EventNames.mouseenter, evt); 36 | this.fire(this.lastMoveId, EventNames.mouseleave, evt); 37 | } 38 | 39 | // mousedown 40 | if (type === ActionType.Down) { 41 | this.fire(id, EventNames.mousedown, evt); 42 | } 43 | 44 | // mouseup 45 | if (type === ActionType.Up) { 46 | this.fire(id, EventNames.mouseup, evt); 47 | } 48 | 49 | // click 50 | if (type === ActionType.Up && this.lastDownId === id) { 51 | this.fire(id, EventNames.click, evt); 52 | } 53 | 54 | if (type === ActionType.Move) { 55 | this.lastMoveId = action.id; 56 | } else if (type === ActionType.Down) { 57 | this.lastDownId = action.id; 58 | } 59 | } 60 | 61 | addListeners( 62 | id: string, 63 | listeners: { 64 | [eventName: string]: Listener[]; 65 | }, 66 | ) { 67 | this.listenersMap[id] = listeners; 68 | } 69 | 70 | fire(id: string, eventName: EventNames, evt: MouseEvent) { 71 | if (this.listenersMap[id] && this.listenersMap[id][eventName]) { 72 | this.listenersMap[id][eventName].forEach((listener) => listener(evt)); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/canvas-event-system/helpers.ts: -------------------------------------------------------------------------------- 1 | export function idToRgba(id: string) { 2 | return id.split("-"); 3 | } 4 | 5 | export function rgbaToId(rgba: [number, number, number, number]) { 6 | return rgba.join("-"); 7 | } 8 | 9 | const idPool = {}; 10 | 11 | export function createId(): string { 12 | let id = createOnceId(); 13 | 14 | while (idPool[id]) { 15 | id = createOnceId(); 16 | } 17 | 18 | return id; 19 | } 20 | 21 | function createOnceId(): string { 22 | return Array(3) 23 | .fill(0) 24 | .map(() => Math.ceil(Math.random() * 255)) 25 | .concat(255) 26 | .join("-"); 27 | } 28 | -------------------------------------------------------------------------------- /src/canvas-event-system/index.ts: -------------------------------------------------------------------------------- 1 | import { rgbaToId } from './helpers'; 2 | import { Shape } from './shapes/types'; 3 | import EventSimulator, { ActionType } from './EventSimulator'; 4 | export * from './shapes'; 5 | 6 | export class Stage { 7 | private canvas: HTMLCanvasElement; 8 | private osCanvas: OffscreenCanvas; 9 | private ctx: CanvasRenderingContext2D; 10 | private osCtx: OffscreenCanvasRenderingContext2D; 11 | private dpr: number; 12 | private shapes: Set; 13 | private eventSimulator: EventSimulator; 14 | 15 | constructor(canvas: HTMLCanvasElement) { 16 | const dpr = window.devicePixelRatio; 17 | canvas.width = parseInt(canvas.style.width) * dpr; 18 | canvas.height = parseInt(canvas.style.height) * dpr; 19 | 20 | this.canvas = canvas; 21 | this.osCanvas = new OffscreenCanvas(canvas.width, canvas.height); 22 | 23 | this.ctx = this.canvas.getContext('2d'); 24 | this.osCtx = this.osCanvas.getContext('2d'); 25 | 26 | this.ctx.scale(dpr, dpr); 27 | this.osCtx.scale(dpr, dpr); 28 | this.dpr = dpr; 29 | 30 | this.canvas.addEventListener('mousedown', this.handleCreator(ActionType.Down)); 31 | this.canvas.addEventListener('mouseup', this.handleCreator(ActionType.Up)); 32 | this.canvas.addEventListener('mousemove', this.handleCreator(ActionType.Move)); 33 | 34 | this.shapes = new Set(); 35 | this.eventSimulator = new EventSimulator(); 36 | } 37 | 38 | add(shape: Shape) { 39 | const id = shape.getId(); 40 | this.eventSimulator.addListeners(id, shape.getListeners()); 41 | this.shapes.add(id); 42 | 43 | shape.draw(this.ctx, this.osCtx); 44 | } 45 | 46 | private handleCreator = (type: ActionType) => (evt: MouseEvent) => { 47 | const x = evt.offsetX; 48 | const y = evt.offsetY; 49 | const id = this.hitJudge(x, y); 50 | this.eventSimulator.addAction({ type, id }, evt); 51 | }; 52 | 53 | /** 54 | * Determine whether the current position is inside a certain shape, if it is, then return its id 55 | * @param x 56 | * @param y 57 | */ 58 | private hitJudge(x: number, y: number): string { 59 | const rgba = Array.from(this.osCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data); 60 | 61 | const id = rgbaToId(rgba as [number, number, number, number]); 62 | return this.shapes.has(id) ? id : undefined; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/Base.ts: -------------------------------------------------------------------------------- 1 | import { EventNames, Listener, Shape } from './types'; 2 | import { createId } from '../helpers'; 3 | 4 | export default class Base implements Shape { 5 | private listeners: { [eventName: string]: Listener[] }; 6 | public id: string; 7 | 8 | constructor() { 9 | this.id = createId(); 10 | this.listeners = {}; 11 | } 12 | 13 | draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D): void { 14 | throw new Error('Method not implemented.'); 15 | } 16 | 17 | on(eventName: EventNames, listener: Listener): void { 18 | if (this.listeners[eventName]) { 19 | this.listeners[eventName].push(listener); 20 | } else { 21 | this.listeners[eventName] = [listener]; 22 | } 23 | } 24 | 25 | getListeners(): { [name: string]: Listener[] } { 26 | return this.listeners; 27 | } 28 | 29 | getId(): string { 30 | return this.id; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/Circle.ts: -------------------------------------------------------------------------------- 1 | import { idToRgba } from '../helpers'; 2 | import Base from './Base'; 3 | 4 | interface RectProps { 5 | x: number; 6 | y: number; 7 | radius: number; 8 | strokeWidth?: number; 9 | strokeColor?: string; 10 | fillColor?: string; 11 | } 12 | 13 | export default class Circle extends Base { 14 | constructor(private props: RectProps) { 15 | super(); 16 | this.props.fillColor = this.props.fillColor || '#fff'; 17 | this.props.strokeColor = this.props.strokeColor || '#000'; 18 | this.props.strokeWidth = this.props.strokeWidth || 1; 19 | } 20 | 21 | draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D) { 22 | const { x, y, radius, strokeColor, strokeWidth, fillColor } = this.props; 23 | 24 | ctx.save(); 25 | ctx.beginPath(); 26 | ctx.fillStyle = fillColor; 27 | ctx.strokeStyle = strokeColor; 28 | ctx.lineWidth = strokeWidth; 29 | ctx.arc(x, y, radius, 0, Math.PI * 2); 30 | ctx.fill(); 31 | ctx.stroke(); 32 | ctx.restore(); 33 | 34 | const [r, g, b, a] = idToRgba(this.id); 35 | 36 | osCtx.save(); 37 | osCtx.beginPath(); 38 | osCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`; 39 | osCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`; 40 | osCtx.lineWidth = strokeWidth; 41 | osCtx.arc(x, y, radius, 0, Math.PI * 2); 42 | osCtx.fill(); 43 | osCtx.stroke(); 44 | osCtx.restore(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/Path.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JS-Hao/canvas-event-system/82f8fd95e166a2e3f800a3b57334de4a23a1eb3f/src/canvas-event-system/shapes/Path.ts -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/Rect.ts: -------------------------------------------------------------------------------- 1 | import { idToRgba } from '../helpers'; 2 | import Base from './Base'; 3 | 4 | interface RectProps { 5 | x: number; 6 | y: number; 7 | width: number; 8 | height: number; 9 | strokeWidth?: number; 10 | strokeColor?: string; 11 | fillColor?: string; 12 | } 13 | 14 | export default class Rect extends Base { 15 | constructor(private props: RectProps) { 16 | super(); 17 | this.props.fillColor = this.props.fillColor || '#fff'; 18 | this.props.strokeColor = this.props.strokeColor || '#000'; 19 | this.props.strokeWidth = this.props.strokeWidth || 1; 20 | } 21 | 22 | draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D) { 23 | const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props; 24 | 25 | ctx.save(); 26 | ctx.beginPath(); 27 | ctx.strokeStyle = strokeColor; 28 | ctx.lineWidth = strokeWidth; 29 | ctx.fillStyle = fillColor; 30 | ctx.rect(x, y, width, height); 31 | ctx.fill(); 32 | ctx.stroke(); 33 | ctx.restore(); 34 | 35 | const [r, g, b, a] = idToRgba(this.id); 36 | 37 | osCtx.save(); 38 | osCtx.beginPath(); 39 | osCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`; 40 | osCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`; 41 | osCtx.rect(x, y, width, height); 42 | osCtx.fill(); 43 | osCtx.stroke(); 44 | osCtx.restore(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/index.ts: -------------------------------------------------------------------------------- 1 | import Rect from './Rect'; 2 | import Circle from './Circle'; 3 | 4 | export * from './types'; 5 | export { Rect, Circle }; 6 | -------------------------------------------------------------------------------- /src/canvas-event-system/shapes/types.ts: -------------------------------------------------------------------------------- 1 | export interface Shape { 2 | draw(ctx: CanvasRenderingContext2D, osCtx: OffscreenCanvasRenderingContext2D): void; 3 | 4 | on(name: string, listener: Listener): void; 5 | 6 | getListeners(): { [name: string]: Listener[] }; 7 | 8 | getId(): string; 9 | } 10 | 11 | export interface Listener { 12 | (evt: MouseEvent): void; 13 | } 14 | 15 | export enum EventNames { 16 | click = 'click', 17 | mousedown = 'mousedown', 18 | mousemove = 'mousemove', 19 | mouseup = 'mouseup', 20 | mouseenter = 'mouseenter', 21 | mouseleave = 'mouseleave', 22 | } 23 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | canvas-event-system 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Stage, Rect, Circle, EventNames } from './canvas-event-system'; 2 | 3 | const canvas = document.querySelector('#canvas') as HTMLCanvasElement; 4 | const stage = new Stage(canvas); 5 | 6 | const rect = new Rect({ 7 | x: 50, 8 | y: 50, 9 | width: 250, 10 | height: 175, 11 | fillColor: 'green', 12 | }); 13 | 14 | const circle = new Circle({ 15 | x: 200, 16 | y: 200, 17 | radius: 100, 18 | fillColor: 'red', 19 | }); 20 | 21 | rect.on(EventNames.mousedown, () => console.log('rect mousedown')); 22 | rect.on(EventNames.mouseup, () => console.log('rect mouseup')); 23 | rect.on(EventNames.mouseenter, () => console.log('rect mouseenter')); 24 | rect.on(EventNames.click, () => console.log('rect click')); 25 | 26 | circle.on(EventNames.click, () => console.log('circle click!!')); 27 | circle.on(EventNames.mouseleave, () => console.log('circle mouseleave!')); 28 | 29 | stage.add(rect); 30 | stage.add(circle); 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "ESNext", 5 | "jsx": "react", 6 | "jsxFactory": "h", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | //json 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "baseUrl": ".", 15 | "outDir": "./build" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | entry: { 5 | index: "./src/index.ts", 6 | }, 7 | output: { 8 | filename: "[name].js", 9 | path: path.resolve(__dirname, "dist"), 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(ts|tsx)$/, 15 | use: "babel-loader", 16 | }, 17 | ], 18 | }, 19 | devServer: { 20 | contentBase: path.join(__dirname, "src"), 21 | compress: true, 22 | port: 9000, 23 | host: "0.0.0.0", 24 | }, 25 | resolve: { 26 | extensions: [".ts", ".tsx", ".js", ".jsx"], 27 | }, 28 | }; 29 | --------------------------------------------------------------------------------