从零实现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 效果就不会中断了。

相关推荐
EnCi Zheng11 分钟前
M5-markconv自定义CSS样式指南 [特殊字符]
前端·css·python
kyriewen15 分钟前
你的网页慢,用户不说直接走——前端性能监控教你“读心术”
前端·性能优化·监控
广州华水科技15 分钟前
北斗GNSS变形监测在大坝安全监测中的应用与优势分析
前端
前端老石人27 分钟前
前端开发中的 URL 完全指南
开发语言·前端·javascript·css·html
CAE虚拟与现实27 分钟前
五一假期闲来无事,来个前段、后端的说明吧
前端·后端·vtk·three.js·前后端
Sarvartha38 分钟前
三目运算符
linux·服务器·前端
晓晨的博客1 小时前
ROS1录制的bag包转换为ROS2格式
前端·chrome
Wect1 小时前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·typescript
donecoding1 小时前
别再让 pnpm 跟着 nvm 跑了!独立安装终极指南
前端·node.js·前端工程化
GISer_Jing1 小时前
AI全栈转型_TS后端学习路线
前端·人工智能·后端·学习