react+vite+electron实现托盘消息预览及自定义消息通知和图标闪烁问题

318 阅读3分钟

在写项目的时候想要实现一个类似qq那样的有消息时图标闪烁,鼠标移入图标时预览消息内容,百度了很多,但是大多数博客都是使用原生的Notification,于是就自己动手写一个。把这个封装成了一个类,可以使用原生的消息提醒也可以使用自定义的消息提醒。

原理解析

实现鼠标移入托盘图标显示自定义内容,就是再开辟一个新的窗口,把其定位到图标的位置上方,然后开启一个定时器,隔一段时间去查询一下鼠标是否再图标上或新窗口的位置,如果不在就销毁创建的窗口。electron在加载的时候是可以指定加载的hash路径的,所以我们就可以直接指定hash路径,来达到显示自定义内容的目的。(注意:这里为什么使用hash路径,其主要原因是打包后history路径有问题,会显示白屏)。 图标闪动就是开启一个定时器,隔一段时间去更换托盘的图标照片。

另外需要额外注意的是,我们上边实现鼠标移入显示一个新窗口,监听的事件是托盘的mouse-move(鼠标移入)事件,为什么需要开启一个定时器去查询鼠标位置呢,一方面electron的托盘mouse-leave(鼠标移出)事件并不支持window电脑,另一方面是鼠标在新窗口中,我们仍要把它当作在托盘中,不能销毁新创建的窗口

说了上边的注意事项,我们可以发现,其难点就是实现查询鼠标位置,去销毁窗口,还有就是使用mouse-move事件去实现mouse-move和mouse-leave两个事件

代码

import { is } from '@electron-toolkit/utils'
import {
  BrowserWindow,
  BrowserWindowConstructorOptions,
  Tray,
  screen,
  Notification,
  NotificationConstructorOptions
} from 'electron'
import { join } from 'path'

export type NotifiType = 'Nomal' | 'Custom'
//自定义通知配置
interface OptionsType extends BrowserWindowConstructorOptions {
  // 显示的hash路径
  hash?: string
  // 是否图标闪动
  isFlash?: boolean
  // 是否自动关闭刷新
  removeClose?: boolean
}
// electron自带通知配置
interface NomalOptionsType extends NotificationConstructorOptions {}

interface flashOptions {
  time: number //闪动的时间间隔
}
// 闪动图标,关闭函数
interface flashReturn {
  close: () => void
}

/*
需要传递Tray
内置提醒,配置对象,点击执行函数
自定义提醒(创建一个新窗口),是否闪动图标,新窗口配置对象,新窗口显示的路径
*/
// 默认显示图标
const DEFAULTIMGURL = '../../resources/icon.png'
// 突变闪动时切换的图标
const FLICKERIMGURL = '../../resources/empty.png'
// 默认自定义通知配置
const defaultOptions: OptionsType = {
  width: 300,
  height: 100,
  show: false,
  frame: false,
  alwaysOnTop: true,
  isFlash: true
}
// 默认系统通知配置
const defaultNomalOptions: NomalOptionsType = {
  title: '测试',
  icon: join(__dirname, DEFAULTIMGURL),
  body: '测试body'
}
// 用于记录监听函数,用于实现移除监听事件,避免重复监听
let listenerFn: any = null
export class NotifiCoustom {
  // 是否离开的定时器
  protected leaveInter: any
  // 闪动图标的定时器
  protected flashInter: any
  protected trayBounds: any
  protected point: any
  protected isLeave: boolean = true
  protected tray: Tray
  protected messagePreview: any
  protected options: OptionsType | NomalOptionsType = defaultOptions
  constructor(tray: Tray) {
    this.tray = tray
  }
  // 显示
  show({
    type,
    options,
    clickFn
  }: {
    type: NotifiType
    options?: OptionsType
    clickFn?: (event: any) => void
  }) {
    if (type === 'Nomal') {
      this.options = options ? options : defaultNomalOptions
      this.nomalNotification(clickFn)
    } else {
      this.options = options ? options : defaultOptions
      // 执行自定义(自定义图标闪动是固定的500ms)
      this.customNotification()
    }
  }

  // 闪动图标
  flashIcon(options: flashOptions): flashReturn {
    let count = 0
    if (this.flashInter) {
      clearInterval(this.flashInter)
    }
    this.flashInter = setInterval(() => {
      count += 1
      if (count % 2 === 0) {
        this.tray.setImage(join(__dirname, DEFAULTIMGURL))
      } else {
        this.tray.setImage(join(__dirname, FLICKERIMGURL))
      }
    }, options.time)

    return { close: this.close.bind(this) }
  }
  // 关闭闪动
  close() {
    clearInterval(this.flashInter)
    this.tray.setImage(join(__dirname, DEFAULTIMGURL))
  }
  // 移除监听事件
  removeListener() {
    this.tray.removeListener('mouse-move', listenerFn)
    listenerFn = null
  }

  private nomalNotification(clickFn?: (event: any) => void) {
    new Notification(this.options)
      .on('click', (event) => {
        clickFn && clickFn(event)
      })
      .show()
  }

  private customNotification() {
    this.customInit()
  }

  private listener() {
    // 鼠标移入停止闪烁
    this.close()
    if (this.isLeave) {
      this.messagePreview = new BrowserWindow(this.options)
      if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
        this.messagePreview.loadURL(
          `${process.env['ELECTRON_RENDERER_URL']}/#${(this.options as OptionsType).hash}`
        )
      } else {
        this.messagePreview.loadFile(join(__dirname, '../renderer/index.html'), {
          hash: (this.options as OptionsType).hash
        })
      }
      const position = this.tray.getBounds()
      this.messagePreview.setPosition(position.x, position.y - 100)
      this.messagePreview.show()
      //触发mouse-enter
      this.isLeave = false
      this.checkTrayLeave()
    }
  }

  private customInit() {
    if ((this.options as OptionsType).isFlash) {
      this.flashIcon({ time: 500 })
    }
    // 监听过了,不再监听
    if (listenerFn) {
      this.close()
      return
    }
    listenerFn = this.listener.bind(this)
    this.tray.on('mouse-move', listenerFn)
  }

  private checkTrayLeave() {
    clearInterval(this.leaveInter)
    this.leaveInter = setInterval(() => {
      this.trayBounds = this.tray.getBounds()
      this.point = screen.getCursorScreenPoint()
      // 鼠标移出托盘
      if (
        !(
          this.trayBounds.x < this.point.x &&
          this.trayBounds.y < this.point.y &&
          this.point.x < this.trayBounds.x + this.trayBounds.width &&
          this.point.y < this.trayBounds.y + this.trayBounds.height
        )
      ) {
        // 鼠标移除自定义窗口的位置
        if (
          this.point.x - this.trayBounds.x < 0 ||
          this.point.x - this.trayBounds.x > (this.options as any).width ||
          this.point.y < this.trayBounds.y - (this.options as any).height
        ) {
          this.isLeave = true
          if ((this.options as OptionsType).removeClose) {
            this.removeListener()
          }
          this.messagePreview.close()
          clearInterval(this.leaveInter)
        }
      }
    }, 100)
  }
}