banner
innei

innei

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

Electron と Web 環境でネイティブおよびカスタムメニューを表示するための一般的な方法

本文では、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>

効果は以下の図のようになります。

image

クリックイベントのバインディング#

上記の実装では、メニューは固定されており、クリックイベントはメインプロセスで実行されますが、多くの場合、レンダープロセスでメニューのクリックイベントを実行する必要があります。したがって、動的なメニュー構築メソッドを実装する必要があります。

このメソッドを実装するために、@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>

image

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 環境での判断により、イベントがプロバイダーによってリッスンされ、コンテキストメニューが表示されます。

効果は以下の通りです:

image

参考#

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

この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。