banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

A universal method for displaying native and custom menus in Electron and web environments

This article introduces a method to display native menus in Electron applications, while also allowing for custom context menus in non-Electron environments (Web). By encapsulating a generic component and calling methods, we achieve unified interaction in both environments.

Triggering the Native Menu#

In Electron, right-clicking does not, by default, bring up a context menu like in Chrome. Often, we need to write corresponding context menus based on our business scenarios.

We can use Menu to construct a native context menu. In the main process, we listen for events through ipcMain, then display the native menu using Menu.buildFromTemplate followed by the popup method.

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))
})

In the Render process, we can open the menu by sending a specified event using ipcRenderer.send().

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

The effect is shown in the image below.

image

Binding Click Events#

In the implementation above, our menu is hardcoded, and the click events are executed in the Main process. However, many times we need to execute the menu's click events in the Render process. Therefore, we need to implement a dynamic menu construction method.

Now let's implement this method using @egoist/tipc to define a type-safe bridge method.

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')
        },
      })
    }),
}

Here we define two events: one for sending when a menu item is clicked, and another for when the menu is closed. This method is executed in the Main process, so the event receivers are all in the Render process. When the menu-click event is sent to the Render process, the corresponding method is executed based on the index.

In the Render process, define a method to call the menu, communicating with the Main process through the bridge. After receiving the menu-click event from the Main process, execute the method in the Render process.

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
    }),
  })
}

A simple usage example is as follows:

<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>

image

Displaying Custom Context Menus in Web#

In the implementation above, the business-customized context menu is displayed in the Electron environment. However, in a Web app, it cannot be displayed, and instead, the menu provided by Chrome or other browsers appears. This leads to inconsistent interactions and many operations related to the right-click menu cannot be achieved.

In this section, we utilize radix/context-menu to implement a context menu UI and modify the above showNativeMenu method so that this method has the same interaction logic in both environments. This way, we do not need to modify business code but can smooth it out in showContextMenu.

First, install the Radix component:

ni @radix-ui/react-context-menu

Then you can copy the styles from shadcn/ui and tweak the UI.

Define a global context menu Provider at the top level of the App. The code is as follows:

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 defines a key for event subscription, which will be sent when showNativeMenu is called. This is then listened to by the top-level ContextMenuProvider, which simulates a right-click operation using new MouseEvent("contextmenu"), setting the current context menu item.

Mount it at the top level of the App:

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

Modify the showNativeMenu method:

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'

In non-Electron environments, the event is sent and listened to by the Provider, displaying the context menu.

The effect is shown below:

image

References#

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

This article is updated by Mix Space to xLog. The original link is https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.