本文介紹一種可以在 Electron 應用中顯示原生菜單,並且在非 Electron 環境(Web)下也可以顯示自定義的上下文菜單的方法。通過封裝一個通用組件和調用方法,在兩套環境中交互統一。
調出原生菜單#
在 Electron 中,默認情況下右鍵並不會彈出類似 Chrome 中的上下文菜單。很多時候我們需要根據自己的業務場景編寫相應的上下文菜單。
我們可以使用 Menu
去構建一個原生的上下文菜單。在主進程中通過 ipcMain 監聽事件,通過 Menu.buildFromTemplate
然後 popup
方法顯示原生菜單。
ipcMain.on('show-context-menu', (event) => {
const template = [
{
label: '菜單項 1',
click: () => {
console.log('菜單項 1 被點擊')
},
},
{
label: '菜單項 2',
click: () => {
console.log('菜單項 2 被點擊')
},
},
]
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}>
右鍵點擊以打開菜單
</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')
},
})
}),
}
這裡我們定義了兩個事件,一個用來發送點擊菜單項時發送,另一個則是菜單被關閉。這個方法在 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: '重命名類別',
click: () => {
present({
title: '重命名類別',
content: ({ dismiss }) => (
<CategoryRenameContent
feedIdList={feedIdList}
category={data.name}
view={view}
onSuccess={dismiss}
/>
),
})
},
},
{
type: 'text',
label: '刪除類別',
click: async () => {
present({
title: `刪除類別 ${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 監聽,而且顯示上下文菜單。
效果如下:
參考#
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus