├── .gitignore
├── src
├── canvas-event-system
│ ├── shapes
│ │ ├── Path.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ ├── Base.ts
│ │ ├── Rect.ts
│ │ └── Circle.ts
│ ├── helpers.ts
│ ├── EventSimulator.ts
│ └── index.ts
├── index.html
└── index.ts
├── .prettierrc
├── readme.md
├── .babelrc
├── tsconfig.json
├── webpack.config.js
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/src/canvas-event-system/shapes/Path.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | ## 一个简易的 Canvas 事件系统
2 |
3 | ### 食用方法
4 |
5 | 执行如下命令行:
6 |
7 | ```
8 | npm start
9 | ```
10 |
11 | 访问`http://localhost:9000`即可
12 |
13 | 
14 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | canvas-event-system
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------