canvas事件模拟

一. demo预览

image

二.前置知识

关于canvas事件模拟方式罗列

1. isPointInPath + Path2D API (存在极大的兼容性)

  • CanvasRenderingContext2D.isPointInPath()是 Canvas 2D API 用于判断在当前路径中是否包含检测点的方法。
  • 方法为: CanvasRenderingContext2D.isPointInPath(x, y, fillRule, path)
  • 参数:
    1. x : 检测点的X坐标
    2. y : 检测点的Y坐标
    3. fillRule: 用来决定点在路径内还是在路径外的算法。允许的值:"nonzero": 非零环绕规则 ,默认的规则。"evenodd": 奇偶环绕原则 。
    4. path: Path2D应用的路径,或者当前绘制的路径。
  • 返回值: 一个Boolean值,当检测点包含在当前或指定的路径内,返回 true;否则返回 false。

2. 角度法

  • 说明:如果一个点在多边形内部,则该点与多边形所有顶点两两构成的夹角,相加应该刚好等于360°。
  • 局限性: 图形必须是凸多边形,其他类型的图形都不可以。
image

3. 射线法

  • 说明:判断点与多边形一侧的交点个数为奇数,则点在多边形内部。
  • 该方法不局限于图形的类型,凸多边形,凹多边形,环形等都可以,边界条件处理方式预览具体情况具体分析
  • 难度:每个图形都需要有相应的函数判断射线边界
image

4.像素法

  • canvas中的图形分别离屏绘制,通过判断事件的位置数据(getImageData()方法获取),是否跟事件的唯一id一致来dispatch事件

  • 当前文章demo使用方式为像素法

5. 其他...

三. 一些特别注明

1. OffscreenCanvas
  • 构造函数OffscreenCanvas 创建一个新的OffscreenCanvas对象。 提供了一个可以脱离屏幕渲染的canvas对象。它在窗口环境和web worker环境均有效。
  • 存在兼容性,并且该API之后可能废弃,demo未作兼容处理,兼容性处理方式可以是用一个隐藏的Canvas对象代替 new OffscreenCanvas()
2. getImageData
  • CanvasRenderingContext2D.getImageData(sx, sy, sw, sh) 返回一个ImageData对象,用来描述canvas区域隐含的像素数据,这个区域通过矩形表示,起始点为(sx, sy)、宽为sw、高为sh。
  • 参数:sx, sy:将要被提取的图像数据矩形区域的左上角 x,y 坐标。 sw, sh:将要被提取的图像数据矩形区域的宽度, 高度
  • 注意这里getImageData().data 的取值范围为(0,255)所以这里 rgba中 a 按照0-> 0 , 1->255的范围。

3.正多边形绘制方式

  • 原理是中心点到所有角顶点的集合加起来为360度

4.五角星绘制方式

  • 可以理解成内部一个正五边形,外部一个正五边形,并且每个角度固定

5.心绘制方式

  • 公式: x = 16 * (sint)**3; y = 13cost - 5cons2t - 2cos3t - cos4t

6. 关于demo中取名

  • 画的图案小部件取名为 widget
  • 舞台取名为 Mural
  • 隐藏canvas实例为 hideCtx

四.设计思路以及具体代码

  • canvas事件模拟的原理是,我们知道用户事件在哪个目标canvas绘制的图形之中触发, 所以我们只需要判断在canvas 节点上触发event的x,y坐标值,所对应的图案是否有绑定事件,如果有那么促发该事件.
    于是 可以写出触发事件的伪代码.
import { Widget, Mural } from './canvasEvent'

const Mural = new Mural(canvas对象)

const widget1 = new Widget(options)
const widget2 = new Widget(options)
const widget3 = new Widget(options)

widget1.on('事件名1', callback1)
widget2.on('事件名2', callback2)
widget3.on('事件名3', callback3)
    
Mural.add(widget1) // 如果在widget1上促发事件1 调用callback1
Mural.add(widget2) // 如果在widget2上促发事件2 调用callback2
Mural.add(widget3) // 如果在widget3上促发事件3 调用callback3

这里的widget是很多各种类型所要监听图案实例的总称,所以这里可以设计一个base类,抽离公共方法, 子类继承父类的方法,并且自定义方法形成多种形态. 贴出wiget Base类的代码.

export class Base {

  constructor(props){
    this.id = createId()
    this.listeners = {}
    this.isAnimation = props.isAnimation || false // 这个元素是否需要移动位置,以及是否需要重叠
  }

  draw (){
    throw new Error('this widget not have draw methods')
  }

  on(eventName, listenerFn) {
    if(this.listeners[eventName]){
      this.listeners[eventName].push(listenerFn)
    }else{
      this.listeners[eventName] = [listenerFn]
    }
  }

  getListeners() {
    return this.listeners
  }

  getId(){
    return this.id
  }

  getIsAnimation(){
    return this.isAnimation
  }
}
  • 在base类的基础上,我们可以定义各种形态的widget,列如最简单的rect.
import { Base } from './Base';
export class Rect extends Base {
  constructor(props) {
    super(props);
    this.options = {
      x: props.x,
      y: props.y,
      width: props.width,
      height: props.height,
      fillColor: props.fillColor || '#fff',
      strokeColor: props.strokeColr || '#000',
      strokeWidth: props.strokeWidth || 1
    };
  }

  draw(ctx, hideCtx) {
    const { x, y, width, height, fillColor, strokeColor, strokeWidth } = this.options;
    ctx.save();
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.fillStyle = fillColor;
    ctx.rect(x, y, width, height);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
    ....
  }
}

写出Mural代码的架构.
export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊问题
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
    this.ctx.scale(dpr, dpr); // 根据dpr 缩放画布

    this.canvas.addEventListener('mousedown', callback);
    this.canvas.addEventListener('mouseup', callback);
    this.canvas.addEventListener('mousemove', callback);
  }

  add(widget) {
    widget.draw(this.ctx);
  }
}

那么怎么通过 this.canvas.addEventListener('事件名', callback); 促发widget.on中的回调函数呢? 于是有下一步代码.

Mural


export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊问题
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    // 如果无法使用这个API可以画在一个隐藏的canvas上
    this.hidecanvas = new OffscreenCanvas(canvas.width, canvas.height);

    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
    this.hideCtx = this.hidecanvas.getContext('2d');
    this.ctx.scale(dpr, dpr); // 根据dpr 缩放画布
    this.hideCtx.scale(dpr, dpr); // 根据dpr 缩放画布
    this.dpr = dpr;

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionTypes.down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionTypes.up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionTypes.move));

    this.widgets = new Set(); // 将所有的部件放入Set容器中

    this.eventAnglogies = new EventAnglogies();
  }

  add(widget) {
    const id = widget.getId();
    this.eventAnglogies.addListeners(id, widget.getListeners());
    this.widgets.add(id);
    widget.draw(this.ctx, this.hideCtx);
  }

  handleCreator = (type) => (ev) => {
    const x = ev.offsetX;
    const y = ev.offsetY;
    const id = this.getHideId(x, y);
    this.eventAnglogies.addAction({ type, id }, ev);
  };

  getHideId(x, y) {
    const rgba = [ ...this.hideCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data ];

    const id = rgbaToId(rgba);

    return this.widgets.has(id) ? id : undefined;
  }
}

Rect

import { idToRgba } from '../lib/helper';
import { Base } from './Base';

export class Rect extends Base {
  constructor(props) {
    super(props);
    this.options = {
      x: props.x,
      y: props.y,
      width: props.width,
      height: props.height,
      fillColor: props.fillColor || '#fff',
      strokeColor: props.strokeColr || '#000',
      strokeWidth: props.strokeWidth || 1
    };
  }

  draw(ctx, hideCtx) {
    const { x, y, width, height, fillColor, strokeColor, strokeWidth } = this.options;
    ctx.save();
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.fillStyle = fillColor;
    ctx.rect(x, y, width, height);
    ctx.fill();
    ctx.stroke();
    ctx.restore();

    const [ r, g, b, a ] = idToRgba(this.getId());

    hideCtx.save();
    hideCtx.beginPath();
    hideCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    hideCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    hideCtx.rect(x, y, width, height);
    hideCtx.fill();
    hideCtx.stroke();
    hideCtx.restore();
  }
}

helper.js

export const rgbaToId = (rgba) => rgba.join('-');

// 这里最多可以绘制图形 256*256*256个  16,777,216 约1600万个
const idPool = {};

export const createId = () => {
  let id = createOnceId();

  while (idPool[id]) {
    id = createOnceId();
  }
  // console.log(id)
  return id;
};

export const createOnceId = () => Array(3).fill(0).map(() => Math.ceil(Math.random() * 255)).concat(255).join('-');

// 判断两个set容器相等,注意这里只判断字符串类型的set容器
export const equalSet = (a, b)=> [...a].join('') === [...b].join('')

// set容器的差值
export const diffSet = (a, b) => new Set([...a].filter(x => !b.has(x)));
  • 不难发现有一个核心的关键点,通过在隐藏画布上画纯色的rgba值,然后通过事件得到x,y坐标,在隐藏的画布上获取x,y坐标的rgba值,这里的rgba值就是对应的id值,就可以通过该id值,和事件绑定比较,从而触发函数。

  • 那么如何解决多个图案重叠的问题,以及当图案需要变化的问题?这里采用了绘制多个离屏canvas方案,在多个离屏canvas画布中画固定rgba值,通过比较促发的idSet容器,得到所要促发的事件。

进一步Mural代码

import { EventAnglogies, ActionTypes } from './EventAnglogies';
import { rgbaToId } from './lib/helper';

export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊问题
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;



    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
    this.ctx.scale(dpr, dpr); // 根据dpr 缩放画布

    // 创建一个隐藏的ctx 如果无法使用这个API可以画在一个隐藏的canvas上
    this.hideCtx = this.createHideCtx(canvas.width, canvas.height, dpr)


    this.dpr = dpr;
    // 需要即时移动的canvas隐藏画布
    this.moveHideCtxMap = new Map()

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionTypes.down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionTypes.up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionTypes.move));

    this.widgets = new Set(); // 将所有静态部件放入Set容器中
    this.widgetsMap = new Map()

    this.eventAnglogies = new EventAnglogies();
  }

  createHideCtx(width, height, dpr) {
    const hidecanvas = new OffscreenCanvas(width, height);
    const hideCtx = hidecanvas.getContext('2d');
    hideCtx.scale(dpr, dpr);
    return hideCtx
  }


  add(widget, isOld = false) {
    // 这里代表了动画,或者其他,就是事件已经绑定好了,只是一些位置发生改变
    if(isOld){
      this.drawAll(widget)
      return
    }
    const id = widget.getId();
    const isAnimation = widget.getIsAnimation()
    this.eventAnglogies.addListeners(id, widget.getListeners());
    this.widgets.add(id);
    this.widgetsMap.set(id, widget)
    let hideCtx = this.hideCtx

    // 如果该widget需要移动的话或者覆盖, 存在的话加上,不存在的话new, 防止用户多次add
    if (isAnimation) {
      if (this.moveHideCtxMap.get(id)) hideCtx = this.moveHideCtxMap.get(id)
      else {
        hideCtx = this.createHideCtx(this.canvas.width, this.canvas.height, this.dpr)
        this.moveHideCtxMap.set(id, hideCtx)
      }
    }

    widget.draw(this.ctx, hideCtx);
  }

  handleCreator = (type) => (ev) => {
    const x = ev.offsetX;
    const y = ev.offsetY;
    const idSet = this.getHideIdSet(x, y);
    // 不能在这里遍历idSet
    this.eventAnglogies.dispatchAction({ type, idSet }, ev)
  };

  getHideIdSet(x, y) {
    const rgba = [...this.hideCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data];
    const staticRgbaToId = rgbaToId(rgba);

    const staticId = this.widgets.has(staticRgbaToId) ? staticRgbaToId :[]

    let animationId = []
    
    this.moveHideCtxMap.forEach((hCtx, id)=>{
      if(rgbaToId([...hCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data]) === id){
        animationId.push(id)
      }
    })
    // 获取到所有当前位置的关于动静态id的组合
    return new Set(animationId.concat(staticId))
  }

  // 产生动画重绘所有的图案
  drawAll(moveWidget){
    // 清空视口画布
    this.ctx.clearRect(0, 0 , this.canvas.height, this.canvas.width)
    this.widgetsMap.forEach((widget, id)=>{
      const hideCtx = this.moveHideCtxMap.get(id) || this.hideCtx
      // 如果不是当前widget 直接画,如果是当前widget 清空隐藏的Rect
      // 因为重新draw之后又会有一次hideCtx记录
      if(moveWidget !== widget) widget.draw(this.ctx, hideCtx);
      else hideCtx.clearRect(0, 0 , this.canvas.height, this.canvas.width)
    })
    const moveId = moveWidget.getId();
    const moveCtx = this.moveHideCtxMap.get(moveId)
    moveWidget.draw(this.ctx, moveCtx)
  }
}

EventAnglogies.js

import { equalSet, diffSet } from './lib/helper'

export const ActionTypes = {
  down: 'down',
  up: 'up',
  move: 'move'
};

export const EventNames = {
  click: 'click',
  mousedown: 'mousedown',
  mousemove: 'mousemove',
  mouseup: 'mouseup',
  mouseenter: 'mouseenter',
  mouseleave: 'mouseleave'
};

export class EventAnglogies {
  listenersMap = {};
  lastDownIdSet = new Set(); // 最后一个按下的一堆idSet
  lastMoveIdSet = new Set(); // move的idSet

  dispatchAction(action, ev) {

    const { type, idSet } = action;
    
    if (type === ActionTypes.move) {
      // mousemove
      this.fire(idSet, EventNames.mousemove, ev);

      // mouseenter
      const enterSet = diffSet(idSet, this.lastMoveIdSet)
      enterSet.size && this.fire(enterSet, EventNames.mouseenter, ev)

      // mouseleave
      const leaveSet = diffSet(this.lastMoveIdSet, idSet)
      leaveSet && this.fire(leaveSet, EventNames.mouseleave, ev)
    }

    // mousedown
    if (type === ActionTypes.down) {
      this.fire(idSet, EventNames.mousedown, ev);
    }

    // mouseup
    if (type === ActionTypes.up) {
      this.fire(idSet, EventNames.mouseup, ev);
    }

    // click
    if (type === ActionTypes.up && equalSet(this.lastDownIdSet, idSet)) {
      this.fire(idSet, EventNames.click, ev);
    }

    if (type === ActionTypes.move) this.lastMoveIdSet = action.idSet;
    else if (type === ActionTypes.down) this.lastDownIdSet = action.idSet;
  }

  addListeners(id, listeners) {
    this.listenersMap[id] = listeners;
  }

  fire(idSet, eventName, ev) {
    idSet.forEach(id => {
      if (this.listenersMap[id] && this.listenersMap[id][eventName]) {
        this.listenersMap[id][eventName].forEach((listener) => listener(ev));
      }
    })
  }
}

到此为止步,再回头看看预览的demo 图示

image

这里再贴出入口文件的代码,就一目了然了。

import { Circle, Mural, Rect, Heart, FivePointedStar, Polygon } from './canvasEvent';
import { EventNames } from './canvasEvent/EventAnglogies';

const canvas = document.querySelector('#canvas');

const mural = new Mural(canvas);

const circle = new Circle({
  x: 350,
  y: 50,
  radius: 50,
  fillColor: 'pink'
});

const rect = new Rect({
  x: 10,
  y: 10,
  width: 120,
  height: 60,
  fillColor: 'yellow'
});

const heart = new Heart({
  x: 200,
  y: 50,
  heartA: 3,
  fillColor: 'red'
});

const polygon = new Polygon({
  x:500,
  y: 50,
  n: 8,
  size: 50,
  fillColor: 'blue',
  isAnimation: true
})

const fivePoint = new FivePointedStar({
  x: 50,
  y: 200,
  minSize: 25,
  maxSize: 50,
  fillColor: 'red',
  isAnimation: true
});

rect.on(EventNames.click, () => {
  alert('点击了矩形');
});

heart.on(EventNames.mouseenter, () => {
  console.log('进入心');
});
heart.on(EventNames.mouseleave, () => {
  console.log('离开心');
});

circle.on(EventNames.click, () => {
  alert('点击了圆');
});

circle.on(EventNames.mouseleave, () => {
  console.log('离开了圆形');
});


polygon.on(EventNames.mousedown, (e) => {
  console.log(polygon)
  let baseX = e.pageX
  let baseY = e.pageY
  document.onmousemove = (event) =>{
    const moveX = event.pageX - baseX
    const moveY = event.pageY - baseY
    baseX = event.pageX
    baseY = event.pageY
    polygon.options.x = polygon.options.x + moveX
    polygon.options.y = polygon.options.y + moveY
    mural.add(polygon);
  }
})

fivePoint.on(EventNames.mouseenter, () => {
  console.log('进入了五角星');
});

fivePoint.on(EventNames.mouseleave, () => {
  console.log('离开了五角星');
});

fivePoint.on(EventNames.mousedown, (e) => {
  let baseX = e.pageX
  let baseY = e.pageY
  document.onmousemove = (event) =>{
    const moveX = event.pageX - baseX
    const moveY = event.pageY - baseY
    baseX = event.pageX
    baseY = event.pageY
    fivePoint.options.x = fivePoint.options.x + moveX
    fivePoint.options.y = fivePoint.options.y + moveY
    mural.add(fivePoint, true);
  }
})


document.addEventListener('mouseup', function() {
  document.onmousemove = null
}, false)

mural.add(circle);
mural.add(rect);
mural.add(heart);
mural.add(polygon);
mural.add(fivePoint);

以上总共解决了canvas事件模拟:

  1. mousedown事件
  2. mouseup事件
  3. mouseenter事件
  4. mousemove事件
  5. click事件
  6. 多个图案重叠事件监听
  7. 图片变动后的事件监听
到这里,canvas事件模拟像素点法就介绍到这,具体业务,要根据实际业务中方案选择。

五.未做的一些兼容性处理

  • e = e || window.event
  • OffscreenCanvas
  • ...

若有不明之处, github地址, 可运行demo调试。

参考文档

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 227,837评论 6 531
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,196评论 3 414
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 175,688评论 0 373
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 62,654评论 1 309
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,456评论 6 406
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 54,955评论 1 321
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,044评论 3 440
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,195评论 0 287
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 48,725评论 1 333
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,608评论 3 354
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 42,802评论 1 369
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,318评论 5 358
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,048评论 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,422评论 0 26
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,673评论 1 281
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,424评论 3 390
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 47,762评论 2 372

推荐阅读更多精彩内容