dialog、notification 与 contextMenu

这三个模块有个共同点:它们跳出你的 HTML 界面,直接调用系统层或宿主应用层的能力。设计插件交互时,网页内元素负责常规操作,这三个模块负责「需要确认」和「需要打断」的场合。

dialog:系统级对话框

eagle.dialog 提供四个方法,语义和 Electron 的 dialog 模块基本对齐。

这四个方法各自的定位:

方法 用途
showOpenDialog 让用户选文件或目录
showSaveDialog 让用户选一个保存目标
showMessageBox 让用户在若干选项中做一个决定
showErrorBox 告知不可继续的错误

showErrorBox 弹出的框在部分平台上关不掉,只应该在真正无法继续时用。

打开文件对话框

let result = await eagle.dialog.showOpenDialog({
    properties: ['openFile', 'openDirectory']
});

// 带过滤器
let filtered = await eagle.dialog.showOpenDialog({
    title: '选择要处理的素材',
    defaultPath: eagle.library.path,
    buttonLabel: '导入',
    filters: [
        { name: 'Images', extensions: ['jpg', 'png', 'gif'] },
        { name: 'All Files', extensions: ['*'] }
    ],
    properties: ['openFile', 'multiSelections']
});

// 返回:{ canceled: false, filePaths: ['/path/a.jpg'] }

properties 支持的值:openFile(选文件)、openDirectory(选文件夹)、multiSelections(多选)、showHiddenFiles(显示隐藏文件)、createDirectory(macOS,创建新目录)、promptToCreate(Windows,输入不存在的路径时提示创建)。

promptToCreate 值得注意:它不是在那个路径上真的创建文件,而是允许返回一个不存在的地址,把创建文件的决定权交给你的程序。这在「导出到指定文件名」的场景里很实用。

保存文件对话框

let result = await eagle.dialog.showSaveDialog({
    properties: ['openDirectory']
});

// 返回:{ canceled: false, filePath: '/path/to/save.png' }
// 取消时 filePath 是 undefined

两个对话框返回结构不同:open 返回 filePaths 数组,save 返回单个 filePath,取消时是 undefined。这个不一致是沿袭了 Electron 的设计,写代码时务必先判断 canceled。

消息框

let result = await eagle.dialog.showMessageBox({
    title: "Messagebox title",
    message: "Message from the Plugin process",
    detail: "Ultra message here",
    buttons: ["确定", "取消"],
    type: "info"
});

console.log(result);   // { response: 0 }

type 可选 none、info、error、question、warning。返回的 response 是用户点击的按钮索引——所以按钮顺序很重要:确认类操作必须把「确认」放第一个,这样按回车默认就是执行。

错误框

await eagle.dialog.showErrorBox("Error box title", "Error message from the Plugin process");

一个规范的确认流程

破坏性操作的标准写法:先确认,再执行,失败要有兜底:

async function confirmAndDelete(items) {
    const result = await eagle.dialog.showMessageBox({
        title: '确认删除',
        message: `即将把 ${items.length} 个素材移到垃圾桶`,
        detail: items.map(i => i.name).join('\n'),
        buttons: ['移到垃圾桶', '取消'],
        type: 'warning'
    });

    // response 是按钮索引,0 为确认
    if (result.response !== 0) return;

    let failed = 0;
    for (const item of items) {
        try {
            await item.moveToTrash();
        }
        catch (err) {
            failed++;
            eagle.log.error(`删除 ${item.id} 失败`);
        }
    }

    if (failed > 0) {
        await eagle.dialog.showErrorBox('操作未完成', `${failed} 个素材未能删除,详情见 Eagle 日志`);
    }
    else {
        await eagle.notification.show({
            title: '处理完成',
            body: `已处理 ${items.length} 个素材`,
            duration: 3000
        });
    }
}

注意 detail 里列出了具体文件名——用户点确认前必须知道自己在删什么,这是审核标准里「执行不可逆操作前让用户明确确认」的要求。

notification:角落通知

await eagle.notification.show({
    title: "Basic Notification",
    body: "Notification from the Plugin process",
    mute: false,
    duration: 3000,
    icon: "https://"
});

四个字段:title 标题必填、body 描述必填、icon 支持链接或 base64 格式、mute 是否静音、duration 自动隐藏时间(毫秒)。后三个可选。

通知适合表达「操作已完成」这类结果性信息,不适合做需要用户决策的提示——用户没法在通知上点按钮。需要决策的必须用 showMessageBox。

contextMenu:原生右键菜单

eagle.contextMenu.open([
    {
        id: "edit",
        label: "编辑",
        submenu: [
            { id: "resize", label: "调整大小", click: () => { /* ... */ } },
            { id: "crop", label: "裁剪", click: () => { /* ... */ } },
            { id: "rotate", label: "旋转", click: () => { /* ... */ } }
        ]
    },
    {
        id: "effects",
        label: "效果",
        submenu: [
            { id: "grayscale", label: "灰度", click: () => { /* ... */ } },
            { id: "sepia", label: "深褐色", click: () => { /* ... */ } },
            { id: "invert", label: "反色", click: () => { /* ... */ } }
        ]
    },
    {
        id: "export",
        label: "导出",
        click: () => { /* ... */ }
    }
])

菜单项的结构是 id、label、submenu,以及 click 回调。顶层数组的每一项是顶级菜单项,嵌套 submenu 形成层级。

实际用法里,菜单项应该按当前选中的素材动态生成,而不是写死一套:

eagle.onPluginCreate(async () => {
    document.querySelector('#btn-menu').addEventListener('click', async () => {
        const items = await eagle.item.getSelected();

        eagle.contextMenu.open([
            {
                id: 'batch-tag',
                label: `为 ${items.length} 个素材打标签`,
                click: async () => {
                    for (const item of items) {
                        item.tags = [...new Set([...item.tags, 'reviewed'])];
                        await item.save();
                    }
                }
            },
            {
                id: 'info',
                label: '查看详情',
                submenu: items.slice(0, 10).map((item, i) => ({
                    id: `detail-${i}`,
                    label: item.name,
                    click: () => { console.log(item.filePath); }
                }))
            }
        ]);
    });
});

菜单项在弹出时就把素材数据捕获进闭包,点击时不用重新查询。但如果菜单弹出后用户切换了资源库,这些捕获的引用就失效了——click 回调里涉及写操作时,最好重新 getById 一次。

开发流程

  1. 先梳理业务里所有「需要用户决策」的点,逐个决定用哪个方法。
  2. 破坏性操作统一走 showMessageBox,在 detail 里列出具体对象。
  3. 长任务完成后用 notification.show 汇报结果,设置合理的 duration。
  4. 菜单项按当前选中素材动态生成,路径自己拼。
  5. 每个对话框返回后第一时间判断 canceled。
  6. 全流程在六套主题下各看一遍,确认弹窗不违和。

常见坑

showOpenDialog 返回后不判断 canceled。 用户点取消时 filePaths 是空数组,直接取 [0] 会拿到 undefined,后续操作报奇怪的错。

把 showErrorBox 当普通提示用。 这个错误框在某些平台上关不掉。用它做「已保存」之类的提示会让插件显得很粗暴,常规结果提示用 notification.show。

延伸注意: * 在 click 回调里用 alert()。 原生菜单弹出时,HTML 页面的 alert 表现可能异常。改用 notification.show 或在页面上更新状态。

整理人:WorkBuddy