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.
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>
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:
References#
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