从零实现2D绘图引擎:5.鼠标悬停事件

MiniRender仓库地址参考

好的,我们开始 悬停事件 (Hover Events) 的实现。

这是交互体验中质的飞跃。目前的点击是"瞬间"的,而悬停是"连续"的状态管理。

核心逻辑分析

要实现 mouseover (移入) 和 mouseout (移出),Handler 需要记忆上一帧鼠标在哪一个图形上。

我们定义:

  • target: 当前鼠标下的图形。
  • _hovered: 上一次鼠标所在的图形(缓存状态)。

逻辑如下:

  1. 监听 DOM 的 mousemove
  2. 计算当前鼠标下的 target
  3. 对比 target_hovered
    • 如果 target !== _hovered ,说明发生了状态切换:
      • 如果有 _hovered,对它触发 mouseout
      • 如果有 target,对它触发 mouseover
      • 更新 _hovered = target
  4. 为了更好的体验,当有 target 时,我们将鼠标指针设为手型 (pointer),否则设为默认 (default)。

1. 修改 Handler (src/handler/Handler.ts)

我们需要给 Handler 类添加状态属性,并增加 mousemove 的监听逻辑。

为了代码整洁,我提取了一个 _getEventPoint 方法来复用坐标计算。

typescript 复制代码
// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';

export class Handler {
    storage: Storage;
    painter: Painter;
    dom: HTMLElement;

    // 状态缓存:记录当前正悬停的元素
    private _hovered: Displayable | null = null;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 绑定 this 上下文
        this.dom.addEventListener('click', this._clickHandler.bind(this));
        this.dom.addEventListener('mousemove', this._mouseMoveHandler.bind(this));
        // 这里还可以加 mousedown, mouseup 等
    }

    /**
     * 辅助方法:获取相对于 Canvas 左上角的坐标
     */
    private _getEventPoint(e: MouseEvent) {
        const rect = this.dom.getBoundingClientRect();
        return {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top
        };
    }

    private _clickHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);

        if (target) {
            target.trigger('click', { target, event: e });
        }
    }

    /**
     * 核心:处理鼠标移动,计算 Hover 状态
     */
    private _mouseMoveHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);
        const lastHovered = this._hovered;

        // 如果鼠标下的元素变了
        if (target !== lastHovered) {
            
            // 1. 处理移出 (MouseOut)
            // 如果之前有悬停元素,说明从那个元素出来了
            if (lastHovered) {
                lastHovered.trigger('mouseout', { target: lastHovered, event: e });
            }

            // 2. 处理移入 (MouseOver)
            // 如果当前有元素,说明进入了这个元素
            if (target) {
                target.trigger('mouseover', { target: target, event: e });
            }

            // 3. 更新状态
            this._hovered = target;
        }

        // 4. 处理鼠标移动 (MouseMove)
        // 即使目标没变,也可以触发 move 事件
        if (target) {
            target.trigger('mousemove', { target, event: e });
        }

        // 5. 设置光标样式 (UX 优化)
        if (target) {
            this.dom.style.cursor = 'pointer';
        } else {
            this.dom.style.cursor = 'default';
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            if (el.invisible) continue;
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

2. 验证

现在我们来写一个复杂的 Demo。我们将创建多个矩形,每个矩形都有独立的悬停变色效果。

index.ts:

typescript 复制代码
import { init } from './core/MiniRender';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';

const miniRender = init(document.getElementById('main')!);

// 创建 5 个卡片
for (let i = 0; i < 5; i++) {
    const x = 50 + i * 110;
    const y = 100;

    // --- 创建矩形 ---
    const rect = new Rect({
        shape: { x: x, y: y, width: 100, height: 100, r: 5 },
        style: {
            fill: '#FFF',
            stroke: '#999',
            lineWidth: 2
        }
    });

    // --- 创建文本 ---
    const text = new Text({
        style: {
            text: `Card ${i + 1}`,
            fill: '#666',
            fontSize: 14,
            textAlign: 'center',
            textBaseline: 'middle'
        },
        position: [x + 50, y + 50],
        z: 1,
    });

    // --- 绑定交互事件 ---
    
    // 1. 移入高亮
    rect.on('mouseover', () => {
        console.log(`Mouse Over Rect ${i}`);
        
        // 变色
        rect.style.fill = '#E6F7FF'; // 浅蓝背景
        rect.style.stroke = '#1890FF'; // 深蓝边框
        
        // 放大动画效果(这里手动改 scale)
        // 稍微放大一点,注意 scale 是以 origin 为中心的
        // 我们还没有实现自动计算中心,所以这里手动设
        rect.origin = [x + 50, y + 50]; 
        rect.scale = [1.1, 1.1];

        miniRender.refresh();
    });

    // 2. 移出恢复
    rect.on('mouseout', () => {
        console.log(`Mouse Out Rect ${i}`);
        
        // 恢复颜色
        rect.style.fill = '#FFF';
        rect.style.stroke = '#999';
        
        // 恢复大小
        rect.scale = [1, 1];

        miniRender.refresh();
    });

    miniRender.add(rect);
    miniRender.add(text);
}

// 简单的提示
const tip = new Text({
    style: {
        text: 'Try Hovering on the cards!',
        fill: '#333',
        fontSize: 18,
    },
    position: [50, 30]
});
miniRender.add(tip);

// 渲染
miniRender.refresh();

预期效果

  1. 光标变化:当鼠标移动到卡片(白色矩形)上时,鼠标指针会变成手型。
  2. 高亮反馈
    • 移入时:卡片变大(1.1倍),背景变浅蓝,边框变深蓝。
    • 移出时:卡片恢复原状。
  3. 日志 :控制台会打印出对应的 Mouse OverMouse Out
  4. 状态切换 :如果你快速从卡片 1 移到卡片 2(不经过空白区),你会发现卡片 1 立刻恢复,卡片 2 立刻高亮。这就是 target !== lastHovered 逻辑在起作用。

3.缺陷

在上面的代码中,如果鼠标移到了中间的文字 Card N 上:

  1. 因为 Text 也是个 Displayable,而且在 Rect 上面 (z: 1)。
  2. Handler 会认为 target 变成了 Text
  3. 于是 Rect 会触发 mouseout(变回白色)。
  4. 如果你没给 Text 绑定事件,它就没有反应。

结果:鼠标在卡片边缘是高亮的,一移到文字上,卡片就"灭"了。

解决方案思路 : 在 ZRender 和 ECharts 中,有一个属性叫 silent (静默)。 如果 el.silent = true,则 Handler_findHover 遍历时会直接跳过它 (continue)。这样检测到的 target 就会是底下的 Rect。

  1. 修改 src/graphic/Displayable.ts
typescript 复制代码
export interface DisplayableProps extends ElementProps {
    // ...
    silent?: boolean; // 新增:是否响应交互
}

export abstract class Displayable extends Element {
    // ...
    silent: boolean = false;

    constructor(opts?: DisplayableProps) {
        super(opts);
        // ...
        if (opts && opts.silent != null) this.silent = opts.silent;
    }
}
  1. 修改 src/handler/Handler.ts_findHover
typescript 复制代码
    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            // 增加 !el.silent 判断
            if (el.invisible || el.silent) continue; 
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
  1. 修改 index.ts 中的 Text 创建:
typescript 复制代码
    const text = new Text({
        // ... 样式
        silent: true // 关键!让文字不阻挡鼠标
    });

现在,鼠标移到文字上时,Handler 会忽略文字,直接检测到下面的 Rect,Hover 效果就不会中断了。

相关推荐
恋猫de小郭25 分钟前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端