IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    一种在 Electron 和 Web 环境下显示原生及自定义菜单的通用方法

    Innei发表于 2024-06-27 07:00:43
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus

    本文介绍一种可以在 Electron 应用中显示原生菜单,并且在非 Electron 环境(Web)下也可以显示自定义的上下文菜单的方法。通过封装一个通用组件和调用方法,在两套环境中交互统一。

    调出原生菜单

    在 Electron 中,默认情况下右键并不会弹出类似 Chrome 中的上下文菜单。很多时候我们需要根据自己的业务场景编写相应的上下文菜单。

    我们可以使用 Menu 去构建一个原生的上下文菜单。在主进程中通过 ipcMain 监听事件,通过 Menu.buildFromTemplate 然后 popup 方法显示原生菜单。

    ipcMain.on('show-context-menu', (event) => {
      const template = [
        {
          label: 'Menu Item 1',
          click: () => {
            console.log('Menu Item 1 clicked')
          },
        },
        {
          label: 'Menu Item 2',
          click: () => {
            console.log('Menu Item 2 clicked')
          },
        },
      ]
      const menu = Menu.buildFromTemplate(template)
      menu.popup(BrowserWindow.fromWebContents(event.sender))
    })

    在 Render 进程中我们可以通过 ipcRenderer.send() 发送指定的事件打开菜单。

    const ipcHandle = (): void => window.electron.ipcRenderer.send('show-context-menu')
    <button onContextMenu={ipcHandle}>
     Right click to open menu
    </button>

    效果如下图所示。

    绑定点击事件

    上面的实现中,我们的菜单是写死的,而且点击事件都在 Main 进程中被执行,而很多时候,我们需要在 Render 进程中执行菜单的点击事件。因此我们需要实现一个动态的菜单构造方法。

    现在我们来实现这个方法,我们使用 @egoist/tipc 定义一个类型安全的桥方法。

    export const router = {
      showContextMenu: t.procedure
        .input<{
          items: Array<
            | { type: 'text'; label: string; enabled?: boolean }
            | { type: 'separator' }
          >
        }>()
        .action(async ({ input, context }) => {
          const menu = Menu.buildFromTemplate(
            input.items.map((item, index) => {
              if (item.type === 'separator') {
                return {
                  type: 'separator' as const,
                }
              }
              return {
                label: item.label,
                enabled: item.enabled ?? true,
                click() {
                  context.sender.send('menu-click', index)
                },
              }
            }),
          )
    
          menu.popup({
            callback: () => {
              context.sender.send('menu-closed')
            },
          })
        }),
    }

    这里我们定义了两个事件,一个用来发送点击菜单 item 时候发送,另一个则是菜单被关闭。这个方法在 Main 进程中执行,所以这里的事件接收方都是 Render 进程。那么在 menu-click 事件发送到 Render 进程后根据 index 执行相应的方法。

    在 Render 进程中,定义一个调用菜单的方法。和 Main 进程通过桥通信。在接受到 Main 进程的 menu-click 事件之后,在 Render 进程中执行方法。

    export type NativeMenuItem =
      | {
          type: 'text'
          label: string
          click?: () => void
          enabled?: boolean
        }
      | { type: 'separator' }
    export const showNativeMenu = async (
      items: Array<Nullable<NativeMenuItem | false>>,
      e?: MouseEvent | React.MouseEvent,
    ) => {
      const nextItems = [...items].filter(Boolean) as NativeMenuItem[]
    
      const el = e && e.currentTarget
    
      if (el instanceof HTMLElement) {
        el.dataset.contextMenuOpen = 'true'
      }
    
      const cleanup = window.electron?.ipcRenderer.on('menu-click', (_, index) => {
        const item = nextItems[index]
        if (item && item.type === 'text') {
          item.click?.()
        }
      })
    
      window.electron?.ipcRenderer.once('menu-closed', () => {
        cleanup?.()
        if (el instanceof HTMLElement) {
          delete el.dataset.contextMenuOpen
        }
      })
    
      await tipcClient?.showContextMenu({
        items: nextItems.map((item) => {
          if (item.type === 'text') {
            return {
              ...item,
              enabled: item.enabled ?? item.click !== undefined,
              click: undefined,
            }
          }
    
          return item
        }),
      })
    }

    一个简单的使用方式如下:

    <div
      onContextMenu={(e) => {
        showNativeMenu(
          [
            {
              type: 'text',
              label: 'Rename Category',
              click: () => {
                present({
                  title: 'Rename Category',
                  content: ({ dismiss }) => (
                    <CategoryRenameContent
                      feedIdList={feedIdList}
                      category={data.name}
                      view={view}
                      onSuccess={dismiss}
                    />
                  ),
                })
              },
            },
            {
              type: 'text',
              label: 'Delete Category',
    
              click: async () => {
                present({
                  title: `Delete category ${data.name}?`,
                  content: () => (
                    <CategoryRemoveDialogContent feedIdList={feedIdList} />
                  ),
                })
              },
            },
          ],
          e,
        )
      }}
    ></div>

    在 Web 中显示自定义上下文菜单

    上面的实现中,在 Electron 环境中显示业务自定义的上下文菜单。但是在 Web app 中,无法显示,取而代之的是 Chrome 或者其他浏览器提供的菜单。这样会导致交互不统一并且有关右键菜单的很多操作都无法实现。

    这一节我们利用 radix/context-menu 实现一个上下文菜单的 UI,并且对上面的 showNativeMenu 进行改造,使得这个方法在两个环境中有相同的交互逻辑,那么这样的话,我们就不必修改业务代码,而是在 showContextMenu 中进行抹平。

    首先安装 Radix 组件:

    ni @radix-ui/react-context-menu

    然后可以复制 shadcn/ui 的样式,微调 UI。

    在 App 顶层定义一个全局的上下文菜单 Provider。代码如下:

    export const ContextMenuProvider: Component = ({ children }) => (
      <>
        {children}
        <Handler />
      </>
    )
    
    const Handler = () => {
      const ref = useRef<HTMLSpanElement>(null)
    
      const [node, setNode] = useState([] as ReactNode[] | ReactNode)
      useEffect(() => {
        const fakeElement = ref.current
        if (!fakeElement) return
        const handler = (e: unknown) => {
          const bizEvent = e as {
            detail?: {
              items: NativeMenuItem[]
              x: number
              y: number
            }
          }
          if (!bizEvent.detail) return
    
          if (
            !('items' in bizEvent.detail) ||
            !('x' in bizEvent.detail) ||
            !('y' in bizEvent.detail)
          ) {
            return
          }
          if (!Array.isArray(bizEvent.detail?.items)) return
    
          setNode(
            bizEvent.detail.items.map((item, index) => {
              switch (item.type) {
                case 'separator': {
                  return <ContextMenuSeparator key={index} />
                }
                case 'text': {
                  return (
                    <ContextMenuItem
                      key={item.label}
                      disabled={item.enabled === false || item.click === undefined}
                      onClick={() => {
                        // Here we need to delay one frame,
                        // so it's two raf's, in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
                        // and the page freezes after modal is turned off.
                        nextFrame(() => {
                          item.click?.()
                        })
                      }}
                    >
                      {item.label}
                    </ContextMenuItem>
                  )
                }
                default: {
                  return null
                }
              }
            }),
          )
    
          fakeElement.dispatchEvent(
            new MouseEvent('contextmenu', {
              bubbles: true,
              cancelable: true,
              clientX: bizEvent.detail.x,
              clientY: bizEvent.detail.y,
            }),
          )
        }
    
        document.addEventListener(CONTEXT_MENU_SHOW_EVENT_KEY, handler)
        return () => {
          document.removeEventListener(CONTEXT_MENU_SHOW_EVENT_KEY, handler)
        }
      }, [])
    
      return (
        <ContextMenu>
          <ContextMenuTrigger className="hidden" ref={ref} />
          <ContextMenuContent>{node}</ContextMenuContent>
        </ContextMenu>
      )
    }

    CONTEXT_MENU_SHOW_EVENT_KEY 定义一个事件订阅的 Key,在 showNativeMenu 时,将被发送。转而被顶层 ContextMenuProvider 监听,通过 new MouseEvent("contextmenu") 模拟一个右键操作,设定当前的上下文菜单 Item。

    在 App 顶层挂载:

    export const App = () => {
      return <ContextMenuProvider>
      {...}
      </ContextMenuProvider>
    }

    改造一下 showNativeMenu 方法:

    import { tipcClient } from './client'
    
    export type NativeMenuItem =
      | {
          type: 'text'
          label: string
          click?: () => void
          enabled?: boolean
        }
      | { type: 'separator' }
    export const showNativeMenu = async (
      items: Array<Nullable<NativeMenuItem | false>>,
      e?: MouseEvent | React.MouseEvent,
    ) => {
      const nextItems = [...items].filter(Boolean) as NativeMenuItem[]
    
      const el = e && e.currentTarget
    
      if (el instanceof HTMLElement) {
        el.dataset.contextMenuOpen = 'true'
      }
    
      if (!window.electron) {
        document.dispatchEvent(
          new CustomEvent(CONTEXT_MENU_SHOW_EVENT_KEY, {
            detail: {
              items: nextItems,
              x: e?.clientX,
              y: e?.clientY,
            },
          }),
        )
        return
      }
    
      const cleanup = window.electron?.ipcRenderer.on('menu-click', (_, index) => {
        const item = nextItems[index]
        if (item && item.type === 'text') {
          item.click?.()
        }
      })
    
      window.electron?.ipcRenderer.once('menu-closed', () => {
        cleanup?.()
        if (el instanceof HTMLElement) {
          delete el.dataset.contextMenuOpen
        }
      })
    
      await tipcClient?.showContextMenu({
        items: nextItems.map((item) => {
          if (item.type === 'text') {
            return {
              ...item,
              enabled: item.enabled ?? item.click !== undefined,
              click: undefined,
            }
          }
    
          return item
        }),
      })
    }
    
    export const CONTEXT_MENU_SHOW_EVENT_KEY = 'contextmenu-show'

    判断非 Electron 环境下,发送事件被 Provider 监听,而且显示上下文菜单。

    效果如下:

    参考

    https://github.com/RSSNext/follow/blob/2ff6fc008294a63c71b0ecc901edf1ea8948d37c/src/renderer/src/lib/native-menu.ts

    https://github.com/RSSNext/follow/blob/800706a400cefcf4f379a9bbc7e75f540083fe6b/src/renderer/src/providers/context-menu-provider.tsx

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接