Canvas 画布:图形的选中和移动 (上)

951 阅读6分钟

本文会带大家使用 TypeScript 继续封装构造函数 CanvasContainer。以实现直线、矩形、多边形的 选中移动 功能,并处理 海量图形移动 带来的卡顿问题。

一、Canvas 画布:拖动和缩放 👉👉👉 代码演示

二、Canvas 画布:绘制直线、矩形、多边形 👉👉👉 代码演示

三、Canvas 画布:图形的选中和移动 (上) 👉👉👉 代码演示

四、Canvas 画布:图形的选中和移动 (下) 👉👉👉 代码演示

五、Canvas 画布:修改图形各端点的位置 👉👉👉 代码演示

六、Canvas 画布:图形移动时的辅助线和吸附效果 👉👉👉 代码演示

一、优化图形的数据结构

第一步:首先我们需要对图形的数据结构进行扩展,并定义几个用到的变量。

// 给图形新增两个属性:area 表示图形的面积,select 表示图形是否被选中
interface GraphInter {
    id: number;
    type: GraphType;
    points: PointsType;
    area: number;
    select: boolean;
}

class CanvasContainer {
    /** 绘制没有选中的图形 */
    public renderCanvas = null as unknown as HTMLCanvasElement;
    /** 画布的上下文 */
    public renderCtx = null as unknown as CanvasRenderingContext2D;
    ......
    /** 是否移动图形 */
    public isMoveGraph = false;
    /** 当前选中的图形 */
    public selectGraph?: GraphInter = undefined;
    /** 记录鼠标按下时选中图形的位置信息 */
    private mouseDownSelectGraphInfo?: GraphInter = undefined;
}

二、图形的选中

1. 判断鼠标是否选中图形

当鼠标在画布上移动的时候,如果鼠标进入到了图形里面,则表示鼠标选中了图形。我们可以把鼠标看成一个点,这时问题就转为了判断一个点是否在图形内。

/**
 * 判断当前鼠标按下的位置是否有图形
 * @param x 鼠标按下的 X 坐标
 * @param y 鼠标按下的 Y 坐标
 * @returns 按下位置的图形
 */
pointInGraph = (x: number, y: number): GraphInter => {
    let currentGraph: GraphInter = null;
    this.graphs.forEach(graph => {
        const point = {
            x: (-this.offsetX + x) / this.scale,
            y: (-this.offsetY + y) / this.scale,
        };
        // 统一图形的数据格式
        const points = this.normalizationPoint(graph.type, graph.points);
        // 判断点是否在图形内,当前的会覆盖上一个。
        if (this.isPointInGraph(point, points)) {
            currentGraph = graph;
        }
    })
    return sortGraph;
}

2. 选中重叠的图形

根据上面的选中方法,当要选中的图形发生重叠时,选中的图形存在问题:选中的图形一直是最后一个图形

为了解决这个问题,我们采用的方法是:当图形重叠时,一直选中重叠图形中面积最小的那一个

2.1 在绘制图形完成时,记录图形的面积。

// 新增一个工具函数,用于计算面积
getGraphArea = (points: PointsType) => {
    let area = 0;
    // 将多边形分解为三角形
    for (let i = 0; i < points.length; i++) {
        let j = (i + 1) % points.length;
        area += points[i].x * points[j].y;
        area -= points[j].x * points[i].y;
    }

    area = Math.abs(area) / 2;
    return area;
}

// 在 onMouseMoveDraw 函数中添加 线段 和 矩形 的面积
graph.area = this.getGraphArea(
    this.normalizationPoint(this.drawingGraphType, graph.points)
);
// 在 onDblclickDraw 函数中添加 多边形 的面积
graph.area = this.getGraphArea(
    this.normalizationPoint('polygon', graph?.points)
);

2.2 优化选中函数

pointInGraph = (x: number, y: number): GraphInter => {
    let currentGraph: GraphInter[] = [];
    this.graphs.forEach(graph => {
        const point = {
            x: (-this.offsetX + x) / this.scale,
            y: (-this.offsetY + y) / this.scale,
        };
        const points = this.normalizationPoint(graph.type, graph.points);

        if (this.isPointInGraph(point, points)) {
            // 保存所有的选中图形
            currentGraph.push(graph);
        }
    })
    // 给所有选中的图形根据面积大小排序
    const sortGraph = currentGraph.sort((pre, next) => {
        return pre.area > next.area ? 1 : -1;
    })
    // 将面积最小的视为选中图形
    return sortGraph[0];
}

3. 添加鼠标移动事件

给鼠标添加移动事件,用来判断当前鼠标位置是否有图形被选中。如果有图形被选中,则鼠标的样式由 default 变为 move;如果没有图形被选中,则鼠标的样式由 move 变为 default

/** 鼠标移入或移出图形时的样式 */
private onMouseMove = (event: MouseEvent) => {
    if (!this.isMoveGraph) return;

    this.log_move && console.time('move')
    // 图形是否选中
    const selectGraph = this.pointInGraph(event.x, event.y);
    this.log_move && console.timeEnd('move')
    if (selectGraph) {
        this.canvas.style.cursor = 'move';
    } else {
        this.canvas.style.cursor = 'default';
    }
}

鼠标移动事件触发的太频繁,我们给移动事件添加一个防抖函数,让其触发的频率降低,以优化性能。

debounce = (func: Function, wait = 200) => {
    let timer: any = null;
    return (...args: any[]) => {
        if (timer) return;
        timer = setTimeout(() => {
            func(...args);
            clearTimeout(timer);
            timer = null;
        }, wait);
    }
}
this.canvas.addEventListener('mousemove', this.debounce(this.onMouseMove, 100));

三、图形的移动

要使图形移动,首先我们要在鼠标按下的时候判断选中的是那个图形,然后记录这个图形的位置,最后为了方便我们确认选中的是那个图形,我们还要修改图形的颜色,使其更好区分。

1. 准备移动

在图形移动前,我们要做两件事情:

  1. 选中图形,并记录位置

    // 判断图形是否可以移动 
    if (this.isMoveGraph) {
        // 取消上次选中的图形的选中状态
        this.graphs.forEach(item => item.select = false)
        // 获取当前的选中图形
        this.selectGraph = this.pointInGraph(event.x, event.y);
        // 判断当前的选中图形是否存在
        if (this.selectGraph) {
            this.selectGraph.select = true;
            // 记录鼠标按下时,当前选中图形的状态
            this.mouseDownSelectGraphInfo = JSON.parse(JSON.stringify(this.selectGraph));
        }
    }
    
  2. 修改图形颜色

    // 将选中图形的框变为红色
    if (graph.select) {
        ctx.strokeStyle = '#f00';
    }
    

2. 移动图形

在鼠标按下后并开始移动时,鼠标移动到的位置 相对于 鼠标按下时的位置 就是图形所要移动的距离。

if (this.isMoveGraph && this.selectGraph && this.mouseDownSelectGraphInfo) {
    // 图形最新的位置 = 图形在鼠标按下时的位置 + ( 鼠标当前的位置 - 鼠标按下时鼠标在画布上的偏移量 ) / 画布的缩放比例
    this.selectGraph.points = this.mouseDownSelectGraphInfo?.points.map(item => {
        // item.x                      鼠标按下时,图形的 X 坐标
        // event.x                     鼠标当前位置的 X 坐标
        // this.mouseDownOffsetX       鼠标按下时鼠标在画布上的偏移量
        // this.scale                  画布的缩放比例
        return {
            x: item.x + (event.x - this.mouseDownOffsetX) / this.scale,
            y: item.y + (event.y - this.mouseDownOffsetY) / this.scale,
        }
    })
}

四、性能优化

在图形移动的过程中,我们只移动了我们选中的图形,但是,我们依然会渲染画布可视区所有的图形。这带来了很多额外的渲染开销,我们需要优化它。

我们使用 动静分离 的方式来优化这个问题。具体就是将画布拆分为两个,底下的画布用来绘制所有没有发生移动的图形,只绘制一次。上面的画布用来绘制我们选中的图形,我们只需要在上面的画布渲染一个图形,可以极大的降低性能开销。

1. 创建第二张画布

// 在初始化的时候,创建第二张画布,用于绘制移动的图形
// 1. 动态创建一个 Canvas 画布
this.renderCanvas = document.createElement('canvas');
// 2. 设置这个 Canvas 画布的 CSS 样式
this.renderCanvas.style.position = 'absolute';
this.renderCanvas.style.top = '0px';
this.renderCanvas.style.left = '0px';
this.renderCanvas.style.width = this.width + 'px';
this.renderCanvas.style.height = this.height + 'px';
// 3. 将两张画布的设置为相同大小
this.renderCanvas.width = this.width * this.ratio;
this.renderCanvas.height = this.height * this.ratio;
// 4. 获取上下文
this.renderCtx = this.renderCanvas.getContext('2d') as CanvasRenderingContext2D;
this.renderCtx.scale(this.ratio, this.ratio);
// 5. 将这张画布添加到页面,并作为上层画布,用来绘制选中的移动中的图形
this.canvas.parentElement?.insertBefore(this.renderCanvas, this.canvas)

2. 同步两张画布的渲染状态

/** 渲染函数 */
render = () => {
    this.log_render && console.time('render')
    /** 清空画布 */
    this.renderCtx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio);
    this.ctx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio);
    /** 保存画布状态 */
    this.renderCtx.save();
    this.ctx.save();
    /** 平移画布 */
    this.renderCtx.translate(this.offsetX, this.offsetY);
    this.ctx.translate(this.offsetX, this.offsetY);
    /** 缩放画布 */
    this.renderCtx.scale(this.scale, this.scale);
    this.ctx.scale(this.scale, this.scale);
    /** 渲染不移动的图形 */
    this.renderGraph();
    /** 移除画布状态 */
    this.renderCtx.restore();
    this.ctx.restore();
    this.log_render && console.timeEnd('render')
}

3. 渲染图形

  • 渲染不是移动状态的图形,将其渲染到 renderCanvas 画布上。
    this.graphs.forEach((graph) => {
        let isShow = false;
        // 判断图形上是否有端点在可视区
        graph.points.forEach(point => {
            if (this.isPointInGraph(point, points)) {
                isShow = true;
            }
        })
        // 如果所有端点都没有在画布可视区,不渲染
        // 如果当前图形是选中状态,不渲染
        if (!isShow || graph.select) return;
    
        this.drawGraph(this.renderCtx, graph);
    })
    
  • 渲染要移动的图形,将其渲染到 canvas 画布上。
    renderActive = (selectGraph = this.selectGraph) => {
        this.log_render && console.time('render')
        if (selectGraph) {
            this.ctx.clearRect(0, 0, this.width * this.ratio, this.height * this.ratio);
            this.ctx.save();
            this.ctx.translate(this.offsetX, this.offsetY);
            this.ctx.scale(this.scale, this.scale);
            this.drawGraph(this.ctx, selectGraph);
            this.ctx.restore();
        }
        this.log_render && console.timeEnd('render')
    }