本文では、Electron アプリケーションでネイティブメニューを表示し、非 Electron 環境(Web)でもカスタムコンテキストメニューを表示する方法を紹介します。汎用コンポーネントをラップし、メソッドを呼び出すことで、2 つの環境間でのインタラクションを統一します。
ネイティブメニューを表示する#
Electron では、デフォルトでは右クリックしても Chrome のようなコンテキストメニューは表示されません。多くの場合、ビジネスシーンに応じて適切なコンテキストメニューを作成する必要があります。
Menu
を使用してネイティブのコンテキストメニューを構築できます。メインプロセスで ipcMain を使用してイベントをリッスンし、Menu.buildFromTemplate
を使用してネイティブメニューを表示します。
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))
})
レンダープロセスでは、ipcRenderer.send()
を使用して指定されたイベントを送信し、メニューを開きます。
const ipcHandle = (): void => window.electron.ipcRenderer.send('show-context-menu')
<button onContextMenu={ipcHandle}>
右クリックしてメニューを開く
</button>
効果は以下の図のようになります。
クリックイベントのバインディング#
上記の実装では、メニューは固定されており、クリックイベントはメインプロセスで実行されますが、多くの場合、レンダープロセスでメニューのクリックイベントを実行する必要があります。したがって、動的なメニュー構築メソッドを実装する必要があります。
このメソッドを実装するために、@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')
},
})
}),
}
ここでは、メニュー項目がクリックされたときに送信されるイベントと、メニューが閉じられたときのイベントの 2 つを定義しています。このメソッドはメインプロセスで実行されるため、ここでのイベント受信者はすべてレンダープロセスです。したがって、menu-click
イベントがレンダープロセスに送信されると、index
に基づいて適切なメソッドが実行されます。
レンダープロセスでは、メニューを呼び出すメソッドを定義します。メインプロセスとブリッジ通信を行います。メインプロセスのmenu-click
イベントを受信した後、レンダープロセスでメソッドを実行します。
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 アプリでは表示できず、代わりに Chrome や他のブラウザが提供するメニューが表示されます。これにより、インタラクションが統一されず、右クリックメニューに関する多くの操作が実行できなくなります。
このセクションでは、radix/context-menu
を利用してコンテキストメニューの UI を実装し、上記のshowNativeMenu
を改造して、このメソッドが 2 つの環境で同じインタラクションロジックを持つようにします。これにより、ビジネスコードを変更する必要がなく、showContextMenu
で調整を行うことができます。
まず、Radix コンポーネントをインストールします:
ni @radix-ui/react-context-menu
次に、shadcn/ui のスタイルをコピーし、UI を微調整します。
アプリのトップレベルでグローバルなコンテキストメニュープロバイダーを定義します。コードは以下の通りです:
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={() => {
// ここでは1フレーム遅延させる必要があります。
// これは2つのrafで、特定のシナリオでモーダルが呼び出された後にRadixOverlayによって`point-event: none`が記録されるようにするためです。
// モーダルがオフになるとページがフリーズします。
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
は、showNativeMenu
の際に送信されるイベントの購読キーを定義します。これにより、トップレベルのContextMenuProvider
がリッスンし、new MouseEvent("contextmenu")
を使用して右クリック操作をシミュレートし、現在のコンテキストメニュー項目を設定します。
アプリのトップレベルにマウントします:
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 環境での判断により、イベントがプロバイダーによってリッスンされ、コンテキストメニューが表示されます。
効果は以下の通りです:
参考#
この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus