Skip to content

悬浮球功能

功能特性

  • ✅ 始终置顶显示
  • ✅ 透明背景,圆形外观
  • ✅ 可自由拖动位置
  • ✅ 位置自动保存和恢复
  • ✅ 点击恢复主窗口
  • ✅ 不显示在任务栏
  • ✅ 支持快捷键切换

悬浮球服务

初始化

javascript
class FloatingBallService {
  /**
   * 初始化悬浮球服务
   * @param {BrowserWindow} mainWindow - 主窗口实例
   */
  static initialize(mainWindow) {
    this.mainWindow = mainWindow;
    logger.info('悬浮球服务初始化完成');
  }
}

// 使用
FloatingBallService.initialize(mainWindow);

创建悬浮球窗口

javascript
static create() {
  // 如果窗口已存在且未销毁,先销毁
  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    logger.info('销毁旧的悬浮球窗口');
    this.floatingWindow.destroy();
    this.floatingWindow = null;
  }

  const { width, height } = screen.getPrimaryDisplay().workAreaSize;

  // 使用上次保存的位置,或默认位置
  const defaultX = width - 80;
  const defaultY = height / 2;

  this.floatingWindow = new BrowserWindow({
    width: 75,
    height: 75,
    x: this.lastPosition?.x || defaultX,
    y: this.lastPosition?.y || defaultY,
    frame: false,           // 无边框
    transparent: true,      // 透明背景
    alwaysOnTop: true,      // 始终置顶
    skipTaskbar: true,      // 不显示在任务栏
    resizable: false,       // 不可调整大小
    movable: true,          // 可移动
    hasShadow: true,        // 显示阴影
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: false,
      preload: path.join(app.getAppPath(), 'src/preload/preload-ball.js'),
    },
  });

  // 根据环境加载不同的 URL
  const url = app.isPackaged
    ? path.join(process.resourcesPath, 'renderer', 'ball.html')
    : 'http://localhost:3000/ball.html';

  this.floatingWindow.loadURL(url);

  // 监听窗口移动,保存位置
  this.floatingWindow.on('move', () => {
    if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
      const [x, y] = this.floatingWindow.getPosition();
      this.lastPosition = { x, y };
    }
  });

  // 窗口关闭时清理
  this.floatingWindow.on('closed', () => {
    logger.info('悬浮球窗口已关闭');
    this.floatingWindow = null;
  });

  logger.info('悬浮球窗口创建成功');
}

窗口配置说明

配置项说明
width / height75x75悬浮球尺寸
framefalse无边框窗口
transparenttrue透明背景
alwaysOnToptrue始终置顶
skipTaskbartrue不在任务栏显示
resizablefalse禁止调整大小
movabletrue允许拖动
hasShadowtrue显示阴影效果

核心功能

1. 拖拽移动

javascript
/**
 * 拖拽移动窗口
 * @param {number} screenX 鼠标屏幕X坐标
 * @param {number} screenY 鼠标屏幕Y坐标
 */
static dragMove(screenX, screenY) {
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) return;

  const [width, height] = this.floatingWindow.getSize();
  const newX = screenX - width / 2;
  const newY = screenY - height / 2;

  this.floatingWindow.setPosition(Math.round(newX), Math.round(newY));
  this.lastPosition = { x: newX, y: newY };
}

实现原理:

  • 获取鼠标屏幕坐标
  • 计算窗口中心位置
  • 实时更新窗口位置
  • 自动保存最后位置

2. 显示主窗口

javascript
/**
 * 显示主窗口并隐藏悬浮球
 */
static showMainWindow() {
  if (this.mainWindow && !this.mainWindow.isDestroyed()) {
    this.mainWindow.show();
    this.mainWindow.focus();
  }

  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.hide();
  }
}

3. 隐藏到悬浮球

javascript
/**
 * 隐藏主窗口并显示悬浮球
 */
static hideMainWindow() {
  if (this.mainWindow && !this.mainWindow.isDestroyed()) {
    this.mainWindow.hide();
  }

  // 如果悬浮球不存在,先创建
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) {
    this.create();
  }

  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.show();
  }
}

4. 显示悬浮球

javascript
/**
 * 显示悬浮球(不隐藏主窗口)
 */
static showFloatingBall() {
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) {
    this.create();
  }

  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.show();
  }
}

5. 隐藏悬浮球

javascript
/**
 * 隐藏悬浮球
 */
static hideFloatingBall() {
  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.hide();
  }
}

6. 关闭悬浮球

javascript
/**
 * 关闭悬浮球窗口
 */
static closeFloatingBall() {
  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.close();
    this.floatingWindow = null;
  }
}

IPC 通信

IPC Handler 注册

javascript
// packages/main/src/ipc-handlers/floating-ball-ipc-handler.js
class FloatingBallIpcHandler {
  static async register() {
    // 显示悬浮球
    ipcMain.handle('show-floating-ball', async () => {
      FloatingBallService.showFloatingBall();
    });

    // 隐藏悬浮球
    ipcMain.handle('hide-floating-ball', async () => {
      FloatingBallService.hideFloatingBall();
    });

    // 隐藏主窗口到悬浮球
    ipcMain.handle('hide-main-window', async () => {
      FloatingBallService.hideMainWindow();
    });

    // 拖动悬浮球
    ipcMain.on('drag-floating-ball', (event, { screenX, screenY }) => {
      FloatingBallService.dragMove(screenX, screenY);
    });

    // 点击悬浮球恢复主窗口
    ipcMain.on('floating-ball-click', () => {
      FloatingBallService.showMainWindow();
    });
  }

  static removeHandlers() {
    ipcMain.removeHandler('show-floating-ball');
    ipcMain.removeHandler('hide-floating-ball');
    ipcMain.removeHandler('hide-main-window');
    ipcMain.removeAllListeners('drag-floating-ball');
    ipcMain.removeAllListeners('floating-ball-click');
  }
}

渲染进程调用

javascript
// 显示悬浮球
await window.electronAPI.invoke('show-floating-ball');

// 隐藏到悬浮球
await window.electronAPI.invoke('hide-main-window');

// 拖动悬浮球
window.electronAPI.send('drag-floating-ball', {
  screenX: event.screenX,
  screenY: event.screenY,
});

// 点击悬浮球
window.electronAPI.send('floating-ball-click');

Preload 脚本

悬浮球窗口使用专用的 preload 脚本:

javascript
// packages/main/src/preload/preload-ball.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('ballAPI', {
  // 拖动悬浮球
  dragMove: (screenX, screenY) => {
    ipcRenderer.send('drag-floating-ball', { screenX, screenY });
  },

  // 点击悬浮球
  onClick: () => {
    ipcRenderer.send('floating-ball-click');
  },

  // 双击悬浮球
  onDoubleClick: () => {
    ipcRenderer.send('floating-ball-double-click');
  },
});

悬浮球 HTML

html
<!-- packages/renderer/ball.html -->
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>悬浮球</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        width: 75px;
        height: 75px;
        overflow: hidden;
        -webkit-app-region: drag; /* 允许拖动 */
        user-select: none;
      }

      .floating-ball {
        width: 75px;
        height: 75px;
        border-radius: 50%;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: transform 0.2s;
      }

      .floating-ball:hover {
        transform: scale(1.1);
      }

      .floating-ball:active {
        transform: scale(0.95);
      }

      .icon {
        width: 40px;
        height: 40px;
        color: white;
        font-size: 32px;
      }
    </style>
  </head>
  <body>
    <div class="floating-ball" id="ball">
      <div class="icon">⚡</div>
    </div>

    <script>
      const ball = document.getElementById('ball');

      // 点击恢复主窗口
      ball.addEventListener('click', () => {
        window.ballAPI.onClick();
      });

      // 双击事件
      ball.addEventListener('dblclick', () => {
        window.ballAPI.onDoubleClick();
      });

      // 拖动支持
      let isDragging = false;

      ball.addEventListener('mousedown', () => {
        isDragging = true;
      });

      document.addEventListener('mousemove', (e) => {
        if (isDragging) {
          window.ballAPI.dragMove(e.screenX, e.screenY);
        }
      });

      document.addEventListener('mouseup', () => {
        isDragging = false;
      });
    </script>
  </body>
</html>

集成方式

1. 托盘菜单集成

javascript
// packages/main/src/config/menu-config.js
{
  label: '收起到悬浮球',
  click: () => {
    FloatingBallService.showFloatingBall();
    win.hide();
  }
}

2. 快捷键集成

javascript
// packages/main/src/shortcut/global-shortcut.js
globalShortcut.register('CmdOrCtrl+Alt+H', () => {
  FloatingBallService.hideMainWindow();
});

3. 窗口控制集成

javascript
// packages/main/src/handlers/other-action-handler.js
class OtherActionHandler {
  constructor(win, FloatingBallService) {
    this.win = win;
    this.FloatingBallService = FloatingBallService;

    // 监听最小化到悬浮球请求
    ipcMain.on('minimize-to-floating-ball', () => {
      FloatingBallService.hideMainWindow();
    });
  }
}

位置管理

默认位置

javascript
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const defaultX = width - 80; // 右侧边缘
const defaultY = height / 2; // 垂直居中

位置保存

javascript
// 监听窗口移动事件
this.floatingWindow.on('move', () => {
  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    const [x, y] = this.floatingWindow.getPosition();
    this.lastPosition = { x, y }; // 保存到内存
  }
});

位置恢复

javascript
// 创建窗口时使用保存的位置
this.floatingWindow = new BrowserWindow({
  x: this.lastPosition?.x || defaultX,
  y: this.lastPosition?.y || defaultY,
  // ...
});

持久化存储(可选)

javascript
// 保存到本地存储
const fs = require('fs');
const positionFile = path.join(app.getPath('userData'), 'floating-ball-position.json');

// 保存位置
fs.writeFileSync(positionFile, JSON.stringify(this.lastPosition));

// 读取位置
if (fs.existsSync(positionFile)) {
  this.lastPosition = JSON.parse(fs.readFileSync(positionFile, 'utf-8'));
}

样式定制

自定义外观

css
/* 圆形悬浮球 */
.floating-ball {
  border-radius: 50%;
}

/* 圆角矩形 */
.floating-ball {
  border-radius: 15px;
}

/* 渐变背景 */
.floating-ball {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

/* 纯色背景 */
.floating-ball {
  background: #4a90e2;
}

/* 图片背景 */
.floating-ball {
  background-image: url('./icon.png');
  background-size: cover;
}

动画效果

css
/* 悬停动画 */
.floating-ball:hover {
  transform: scale(1.1);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}

/* 点击动画 */
.floating-ball:active {
  transform: scale(0.95);
}

/* 呼吸效果 */
@keyframes breathe {
  0%,
  100% {
    opacity: 0.8;
  }
  50% {
    opacity: 1;
  }
}

.floating-ball {
  animation: breathe 2s infinite;
}

性能优化

1. 按需创建

javascript
// 只在需要时创建悬浮球窗口
static showFloatingBall() {
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) {
    this.create();  // 延迟创建
  }
  this.floatingWindow.show();
}

2. 资源清理

javascript
// 窗口关闭时清理资源
this.floatingWindow.on('closed', () => {
  this.floatingWindow = null; // 释放引用
});

3. 避免重复创建

javascript
static create() {
  // 如果已存在,先销毁
  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.destroy();
    this.floatingWindow = null;
  }

  // 创建新窗口
  this.floatingWindow = new BrowserWindow({ /* ... */ });
}

最佳实践

1. 状态检查

javascript
// 操作前检查窗口状态
static showFloatingBall() {
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) {
    this.create();
  }

  if (this.floatingWindow && !this.floatingWindow.isDestroyed()) {
    this.floatingWindow.show();
  }
}

2. 错误处理

javascript
static hideMainWindow() {
  try {
    if (this.mainWindow && !this.mainWindow.isDestroyed()) {
      this.mainWindow.hide();
    }

    this.showFloatingBall();
    logger.info('主窗口已收起到悬浮球');
  } catch (error) {
    logger.error('隐藏主窗口失败:', error);
  }
}

3. 边界处理

javascript
// 确保悬浮球在屏幕范围内
static dragMove(screenX, screenY) {
  if (!this.floatingWindow || this.floatingWindow.isDestroyed()) return;

  const { width: screenWidth, height: screenHeight } =
    screen.getPrimaryDisplay().workAreaSize;
  const [width, height] = this.floatingWindow.getSize();

  // 限制在屏幕范围内
  const newX = Math.max(0, Math.min(screenX - width / 2, screenWidth - width));
  const newY = Math.max(0, Math.min(screenY - height / 2, screenHeight - height));

  this.floatingWindow.setPosition(Math.round(newX), Math.round(newY));
  this.lastPosition = { x: newX, y: newY };
}

4. 初始化时机

javascript
// 在主窗口创建后初始化
app.whenReady().then(() => {
  const mainWindow = createWindow();
  FloatingBallService.initialize(mainWindow);
});

常见问题

Q1: 悬浮球不显示?

可能原因:

  • 窗口被销毁
  • 位置超出屏幕范围
  • 透明度设置不当

解决方案:

javascript
// 检查窗口状态
if (!this.floatingWindow || this.floatingWindow.isDestroyed()) {
  this.create();
}

// 重置到默认位置
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
this.floatingWindow.setPosition(width - 80, height / 2);

Q2: 悬浮球无法拖动?

原因: CSS 未设置 -webkit-app-region: drag

解决方案:

css
body {
  -webkit-app-region: drag;
}

Q3: 点击悬浮球无响应?

检查事项:

  1. Preload 脚本是否正确加载
  2. IPC 通信是否正常
  3. 事件监听是否注册
javascript
// 检查 preload 加载
console.log('ballAPI available:', typeof window.ballAPI !== 'undefined');

Q4: 悬浮球位置不记忆?

实现持久化存储:

javascript
const Store = require('electron-store');
const store = new Store();

// 保存位置
this.floatingWindow.on('move', () => {
  const [x, y] = this.floatingWindow.getPosition();
  store.set('floatingBallPosition', { x, y });
});

// 读取位置
const savedPosition = store.get('floatingBallPosition');
if (savedPosition) {
  this.lastPosition = savedPosition;
}

相关文件

  • packages/main/src/services/floatingball-service.js - 悬浮球服务
  • packages/main/src/ipc-handlers/floating-ball-ipc-handler.js - IPC 处理器
  • packages/main/src/preload/preload-ball.js - Preload 脚本
  • packages/renderer/ball.html - 悬浮球页面

相关文档

基于 MIT 许可发布