{T}

实现超级面板

## 前言 超级面板又称超级菜单,是系统右击菜单的升级版,唤起方式可以通过快捷键,也可以通过鼠标键触发。超级面板本质也是一个特殊的 BrwoserWindow,只不过这个窗口中包含的都是一些自定义的菜单项。如果你使用过 quicker,那么这个界面你应该不会陌生(点击鼠标中键或快速单击 Ctrl 键即可弹出面板窗口):

如果你使用 uTools,那么你在长按鼠标右键的时候,也会产出 uTools 的超级面板:

这些面板都是系统右击菜单的升级版,用于扩展更多自定义的快捷功能。接下来我们将通过 Rubick 系统插件的方式来实现一个超级面板插件。

实现超级面板

1. 功能简介

超级面板都有一个唤起的触发方式,比如鼠标长按右击、鼠标中键、键盘快捷键……在 Rubick 中,我们将会通过快捷键 Ctrl+W 的方式进行唤起。

超级面板唤起后,会根据用户选中的目标进行匹配不同的功能,接下来我们会分别介绍这多重匹配功能的实现。

2. 初始化插件项目

在 《实战篇:系统插件的加载和取色插件的开发》章节,我们已经教会大家如何搭建一个基于 Vue 3 的插件开发环境。这里我们可以继续利用上面搭建好的项目,再来开发一个超级面板系统插件。

之所以要开发系统插件,是因为我们希望超级面板可以随时随地使用,不依赖 BrwoserView 的插件窗口渲染。

因为是系统插件,所以我们需要在 public/ 目录下再新建一个系统插件的入口文件 main.js,并添加以下代码:

js
// public/main.js
module.exports = () => {
  return {
    // rubick 系统插件的 onReady 钩子函数
    onReady(ctx) {
      //  todo
    }
  }
}

然后修改 public/package.json 文件,添加如下字段:

js
{
  // ...
  // 标明入口文件
  "entry": "main.js",
  // 指明插件类型
  "pluginType": "system",
}

这样我们一个系统插件的项目就初始化完成了。

2. 添加超级面板窗口

接下来,我们需要实现用户触发 Ctrl+W 快捷键唤起超级面板窗口的功能。

js
// public/main.js
const superPanel = (ctx) => {
  const { BrowserWindow } = ctx;
  
  let win;
  
  let init = () => {
    if (win === null || win === undefined) {
      // 创建超级面板窗口
      createWindow();
    }
  };
  
  let createWindow = () => {
    win = new BrowserWindow({
      frame: false,
      autoHideMenuBar: true,
      width: 240,
      height: 50,
      show: false,
      alwaysOnTop: true,
      webPreferences: {
        contextIsolation: false,
        webSecurity: false,
        backgroundThrottling: false,
        nodeIntegration: true,
        preload: path.join(__dirname, 'panel-preload.js'),
      },
    });
    // dev 环境
    win.loadURL(`http://localhost:8003/main`);
    // 生产环境
    // win.loadURL(`file://${__dirname}/main.html`);
    win.on("closed", () => {
      win = undefined;
    });
    // 打包后,失焦隐藏
    win.on("blur", () => {
      win.hide();
    });
  };
  
  let getWindow = () => win;
  
  return {
    init,
    getWindow,
  };
}
 
module.exports = () => {
  return {
    async onReady(ctx) {
      const {screen, globalShortcut} = ctx;
      // 初始化超级面板 window
      const panelInstance = superPanel(ctx);
      panelInstance.init();
      
      globalShortcut.register('Ctrl+W', async () => {
        // 获取鼠标位置
        const { x, y } = screen.getCursorScreenPoint()
        // 初始化超级面板窗口
        let win = panelInstance.getWindow();
        // 将像素位置转换成 windows 屏幕缩放比例后的实际坐标。
        const pos = getPos(screen, {x, y});
        // 设置窗口位置
        win.setPosition(parseInt(pos.x), parseInt(pos.y));
        win.setAlwaysOnTop(true);
        win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
        win.focus();
        win.show();
      });
    },
  }
}

其中 ctx 是 Rubick 为钩子函数注入的上下文对象,在其中我们可以使用一些 Electron 主进程中的模块,然后我们通过 superPanel(ctx).init() 方法创建了一个隐藏的超级面板窗口,当触发快捷键 Ctrl+W 时再获取当前鼠标位置后,将超级面板窗口显示在鼠标位置处。

3. 选中文本目标

如果用户选择了文本目标,则会对文本进行划词翻译,并匹配需要处理文本类型的插件:

不管是文本匹配插件,还是划词翻译的能力,都强依赖于获取用户选中文本的内容。在 Electron 中,我们可以通过 Clipboard 这个模块来获取剪贴板中的文字。但是没有任何方法获取到用户仅仅选择但未复制到剪贴板中的文字,总不能要求用户先选中文字内容再复制一下,再使用 Ctrl+W 快捷键来唤起超级面板吧,这样链路太长了,也不符合操作习惯。

所以,有没有办法在触发 Ctrl+W 快捷键的时候,就自动将选择的内容复制到剪贴板,然后再从剪贴板中读取呢?答案也是肯定的,我们来看看代码实现:

js
// public/main.js
const { keyboard, Key } = require("@nut-tree/nut-js");
const modifier = isMacOS ? Key.LeftSuper : Key.LeftControl;
 
async function simulateCopy() {
  await keyboard.pressKey(modifier, Key.C);
  await keyboard.releaseKey(modifier, Key.C);
}
 
function getSelectedContent(clipboard) {
  return new Promise(async (resolve) => {
    // 先清空剪贴板
    clipboard.clear();
    // 再执行模拟复制
    await simulateCopy();
    // 延时一定时间才能从剪切板内读取到内容
    setTimeout(() => {
      // 获取剪贴板中的内容
      const text = clipboard.readText('clipboard') || ''
      resolve({
        text,
      })
      // ...
    }, 50);
  })
}

相信你一眼就知道了核心所在,这里采用了一个取巧的方式,我们使用了 nutjs 这个库来实现模拟键盘按下 Ctrl/Command + C 的动作,这个快捷键就是复制。然后再通过 clipboard.readText('clipboard') 来读取剪贴板中的文案,这样便实现了获取用户选择文案的能力。

获取了选择的文案,接下来只需要将文案发送给超级面板渲染进程进行处理:

js
// public/main.js
module.exports = () => {
  return {
    async onReady(ctx) {
       // ...
       const copyResult = await getSelectedContent(clipboard);
       win.webContents.send('trigger-super-panel', {
        ...copyResult,
      });
    },
  }
}

渲染进程进行文本匹配插件处理和翻译:

js
ipcRenderer.on('trigger-super-panel', async (e, args) => {
  if (args.text) {
    const word = args.text;
    // 翻译
    translateStr(word);
    // todo 插件匹配
    // ...
  }
});

翻译功能使用的是有道翻译,可以在有道云上进行注册后获取 appKeysecretKey

js
Translator.prototype.translate = async function (word) {
  let youdaoHost = 'http://openapi.youdao.com/api';
  // 在get请求中,中文需要进行uri编码
  let encodeURIWord = encodeURI(word);
  let salt = this.getRandomN(1000);
  let sign = this.md5(this.config.appKey + word + salt + this.config.secretKey);
  let paramsJson = {
    q: encodeURIWord,
    from: this.config.from,
    to: this.config.to,
    appKey: this.config.appKey,
    salt: salt,
    sign: sign
  }
  let url = youdaoHost + '?' + this.generateUrlParams(paramsJson);
  let result = await request.get({ url: url });
  return result;
}

4. 选中非文本目标

选中非文本目标会有多种情况产生,我们需要根据每种情况展示不同样式的超级面板,绝大多数情况下会出现以下三种情况。

  1. 用户当前鼠标在桌面上,未选择任何文件。

  2. 用户鼠标在 finder.app(Mac) 或 explorer.exe(Windows)文件夹内,但是未选中任何内容。

  3. 用户选中了文件(图片、pdf、excel...)或文件夹。

为了区分这三种情况,我们首先需要先获取当前用户剪贴板中内容,关于文本获取上面已经介绍过了,接下来我们介绍获取剪贴板中的文件路径内容。

对于文件、文件夹而言,要获取选中文件、文件夹的路径还是和上面一样先通过 simulateCopy 函数模拟复制后,再从剪贴板读取复制的文件路径。

对于用户来说,也有可能一次选择了多个文件,使用 Electron 从剪贴板中获取多文件在不同平台有不同的处理方式,以 Windows 和 MacOS 为例:

js
const getFilePathFromClipboard = (clipboard) => {
  // 用于存放剪切板上的文件路径集合
  let filePath = [];
  // 若当前在mac系统中
  if (process.platform === 'darwin') {
    // 若存在多个文件
    if (clipboard.has('NSFilenamesPboardType')) {
      filePath =
        clipboard
          .read('NSFilenamesPboardType')
          .match(/<string>.*</string>/g)
          ?.map(item => item.replace(/<string>|</string>/g, '')) || [];
    } else {
      // 仅单个文件的时候
      const clipboardImage = clipboard.readImage('clipboard');
      // 若此文件为图片
      if (!clipboardImage.isEmpty()) {
        const png = clipboardImage.toPNG();
        // 转成图片文件
        const fileInfo = {
          buffer: png,
          mimetype: 'image/png',
          originalname: uuid() + '.png'
        };
        filePath = [fileInfo];
      } else {
        filePath = [clipboard.read('public.file-url').replace('file://', '')].filter(item => item);
      }
    }
  } else {
    // 若当前在 windows 系统中,且剪切板上有多个文件
    if (clipboard.has('CF_HDROP')) {
      const rawFilePathStr = clipboard.read('CF_HDROP') || '';
      let formatFilePathStr = [...rawFilePathStr]
        .filter((_, index) => rawFilePathStr.charCodeAt(index) !== 0)
        .join('')
        .replace(/\/g, '\');
      
      const drivePrefix = formatFilePathStr.match(/[a-zA-Z]:\/);
      
      if (drivePrefix) {
        const drivePrefixIndex = formatFilePathStr.indexOf(drivePrefix[0]);
        if (drivePrefixIndex !== 0) {
          formatFilePathStr = formatFilePathStr.substring(drivePrefixIndex);
        }
        filePath = formatFilePathStr
          .split(drivePrefix[0])
          .filter(item => item)
          .map(item => drivePrefix + item);
      }
    } else {
      // 若为单个文件
      const clipboardImage = clipboard.readImage('clipboard');
      // 图片处理
      if (!clipboardImage.isEmpty()) {
        const png = clipboardImage.toPNG();
        const fileInfo = {
          buffer: png,
          mimetype: 'image/png',
          originalname: uuid() + '.png'
        };
        filePath = [fileInfo];
      } else {
        filePath = [
          clipboard
            .readBuffer('FileNameW')
            .toString('ucs2')
            .replace(RegExp(String.fromCharCode(0), 'g'), '')
        ].filter(item => item);
      }
    }
  }
  return filePath;
}

Electron 读取剪贴板中的内容核心用到的是 clipboard.read(format) 这个 API,其中 format 传入不同的值会有不同的含义:

  • 在 MacOS 系统中,通过 clipboard.read('NSFilenamesPboardType') 读取被复制的文件或文件夹列表的 XML 格式描述文本。
  • 在 Windows 系统中,通过 clipboard.read('CF_HDROP') 读取剪切板的上的文件名数组。
  • 在 MacOS 系统中,通过 clipboard.read('public.file-url') 读取剪切板上的单个文件。
  • 在 Windows 系统中能通过 clipboard.read('FileNameW') 读取剪切板上单个文件。
  • 通过 clipboard.readImage('clipboard') 能获取剪切板上的图片。

接下来就是将获取到的剪贴板中的内容信息传递给超级面板的渲染进程,由渲染进程进行处理。

js
ipcRenderer.on('trigger-super-panel', async (e, args) => {
  // 如果 fileUrl === null 表示当前在桌面上
  if (args.fileUrl === null) {
    // ...
  }
  // 如果 不存在 args.fileUrl 且从剪贴板中获取到了 文本信息 表示当前是文本选择
  else if (!args.fileUrl && args.text) {
    const word = args.text;
    // 翻译
    translateStr(word);
    // todo 插件匹配
    // ...
  } 
  // 如果fileUrl 是以这样的路径结尾:["explorer.exe", "SearchApp.exe", "SearchHost.exe", "FESearchHost.exe", "Finder.app"];
  // 比如:C://xxx/explorer.exe
  // 表示当前是在一个聚焦在文件夹内,且未选中任何内容  
  else if (finders.includes(args.fileUrl.split('/').pop()) || finders.includes(args.fileUrl.split('\').pop())) {
    // 在文件中,但是未选择任何文件
    // ...
  }
  else {
    // 有文件选择
    // ...
  }
});

其中 args 是插件主进程获取剪贴板内容传递给渲染进程的参数,包含了 fileUrltext 两个字段,分别代表从剪贴板中获取的文件路径和文本内容。上面代码核心就是根据这两个字段来进行用户选择判断。

至此一个超级面板插件的核心功能已经完成。

完整代码见:https://gitee.com/rubick-center/rubick-super-x

总结

通过以上内容介绍,我们再来回顾一下要实现一个超级面板的核心步骤:

  1. 先通过 globalShortcut.register 来注册一个快捷触发方式。
  2. 当用户选中目标进行快捷键唤起的时候,触发 Ctrl/Command + C 把用户选中的内容复制到系统剪贴板。
  3. 通过 Electron Clipboard 模块来读取剪贴板中的内容,并传递给渲染进程处理。
  4. 渲染进程收到内容后,开始对用户选中目标类型做判断,给出不同的面板交互形式。