右クリックメニュー(コンテキストメニュー)を出す

Menu.new() で作ったメニューを contextmenu イベントで popup() し、右クリックした場所にネイティブのメニューを出す。要素ごとの出し分け、対象の渡し方、Rust の popup_menu() も示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約10分 menu-008
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 右クリックした場所で出し分ける
  4. ボタンの下に出す
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

一覧の行を右クリックしたら「開く」「名前を変更」「削除」が並ぶ、といったネイティブのメニューは、メニューを作って popup()(Rust は popup_menu())を呼べば、右クリックした場所に出せます。項目・区切り線・標準項目・選ばれたときの処理は、メニューバーと同じ部品で書けます(作り方は menu-001、処理の振り分けは menu-005)。ブラウザ標準の右クリックメニューを出さないだけでよいなら、右クリック・テキスト選択・ドラッグを無効化して Web っぽさを消す を参照してください。ここでは、右クリックした場所でメニューを出し分ける方法と、選ばれた項目に「どの要素に対する操作か」を伝える方法を中心に説明します。

前提条件

プラグインは不要です。JS の popup() の権限 core:menu:allow-popup は core:menu:default に含まれ、それが core:default に含まれるので、新規プロジェクトの capability のままで使えます。権限は呼び出したページのウィンドウで判定されるので、サブウィンドウで出すならそのラベルも windows に入れます。Rust から出すだけなら権限は関係ありません。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default"
  ]
}
やりたいこと使うもの
ページの状態に合わせて出し、処理もページで行うJS の Menu.new() と popup()
Rust で作ったメニューを出し、処理を Rust で行うRust の popup_menu() をコマンドから呼ぶ
ボタンの下など決まった位置に出すpopup() / popup_menu_at() に位置を渡す
ブラウザ標準のメニューを出さないだけfront-011

1. フロントエンドから実装する (TypeScript)

右クリックした場所で出し分ける

ページの contextmenu イベントで preventDefault() を呼んで標準のメニューを止め、代わりに popup() を呼びます。位置を省略すると、マウスカーソルの位置に出ます。

import { Menu, MenuItem } from '@tauri-apps/api/menu';

// action に渡るのは id だけなので、右クリックされた行はここに覚えておく
let targetRow: HTMLElement | null = null;

function runRowCommand(id: string) {
  const row = targetRow;
  if (!row) return;
  const name = row.dataset.name ?? '';
  if (id === 'row:open') console.log('開く:', name);
  if (id === 'row:rename') console.log('名前を変更:', name);
  if (id === 'row:delete') row.remove();
}

// メニューは起動時に 1 回だけ作り、右クリックのたびに使い回す
const deleteItem = await MenuItem.new({ id: 'row:delete', text: '削除', action: runRowCommand });
const rowMenu = await Menu.new({
  items: [
    { id: 'row:open', text: '開く', action: runRowCommand },
    { id: 'row:rename', text: '名前を変更...', action: runRowCommand },
    { item: 'Separator' },
    deleteItem,
  ],
});
const listMenu = await Menu.new({
  items: [
    { id: 'list:new', text: '新規作成', action: (id) => console.log(id) },
    { id: 'list:refresh', text: '最新の状態に更新', action: (id) => console.log(id) },
  ],
});

document.addEventListener('contextmenu', async (e) => {
  const el = e.target instanceof Element ? e.target : null;
  // 入力欄はブラウザ標準のメニュー(貼り付けなど)をそのまま使う
  if (el?.closest('input, textarea, [contenteditable]')) return;
  e.preventDefault(); // 最初の await より前に呼ぶ

  const row = el?.closest<HTMLElement>('[data-name]') ?? null;
  if (row) {
    targetRow = row;
    await deleteItem.setEnabled(row.dataset.locked !== 'true'); // 行ごとに有効・無効を変える
    await rowMenu.popup();
  } else {
    await listMenu.popup();
  }
});

書くときのポイントは 4 つです。

  • preventDefault() は最初の await より前に呼ぶ: await の後では、ブラウザ側の処理がもう終わっていて標準のメニューを止められません。
  • メニューは 1 回だけ作って使い回す: Menu.new() で作ったメニューは、close() を呼ぶまで解放されません。右クリックのたびに作ると溜まっていくので起動時に作り、行ごとに変わる部分は setEnabled() などで切り替えます(menu-006)。
  • 対象の要素は自分で覚えておく: action に渡るのは項目の id だけです。右クリックの時点で要素を変数に入れ、処理の中で読みます。
  • id に接頭辞を付ける: 右クリックメニューの項目も、Rust の on_menu_event にはメニューバーの項目と同じように届きます。row: のように分けておくと、同じ id の処理が二重に動く事故を防げます。

ボタンの下に出す

「⋯」ボタンを押したら真下にメニューを出す、のように位置を決めたいときは、popup() の第 1 引数に位置を渡します。基準はウィンドウの左上で、ページの CSS ピクセルは論理ピクセルなので LogicalPosition にします。第 2 引数にウィンドウを渡すと、別のウィンドウの上にも出せます。

import { Menu } from '@tauri-apps/api/menu';
import { LogicalPosition } from '@tauri-apps/api/dpi';

const moreMenu = await Menu.new({
  items: [
    { id: 'more:export', text: 'エクスポート...', action: (id) => console.log(id) },
    { id: 'more:settings', text: '設定...', action: (id) => console.log(id) },
  ],
});

document.querySelector<HTMLButtonElement>('#more')?.addEventListener('click', async (e) => {
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
  await moreMenu.popup(new LogicalPosition(rect.left, rect.bottom)); // ボタンの左下に出す
});

2. バックエンドから実装する (Rust)

Rust では、ウィンドウの popup_menu()(位置を指定するなら popup_menu_at())にメニューを渡します。Rust で作ったメニューには JS の action を付けられないので、選ばれた項目は Builder::on_menu_event に届きます。ここでも「どの行か」はイベントに含まれないので、メニューを出す直前に State へ記録し、on_menu_event で取り出します。メニューは setup で 1 回だけ作って State に置きます。

use std::sync::Mutex;
use tauri::menu::{Menu, MenuBuilder, MenuEvent, MenuItem, MenuItemBuilder};
use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow, Wry};

/// 行の右クリックメニューと、どのウィンドウのどの行で開いたか
struct RowMenu {
    menu: Menu<Wry>,
    delete: MenuItem<Wry>,
    target: Mutex<Option<(String, String)>>, // (ウィンドウのラベル, 行の名前)
}

/// ページの contextmenu から呼ぶ。メニューはカーソル位置に出る
#[tauri::command]
fn show_row_menu(window: WebviewWindow, rows: State<'_, RowMenu>, name: String, locked: bool) -> Result<(), String> {
    rows.delete.set_enabled(!locked).map_err(|e| e.to_string())?;
    *rows.target.lock().unwrap() = Some((window.label().to_string(), name));
    window.popup_menu(&rows.menu).map_err(|e| e.to_string())
}

fn handle_row_menu(app: &AppHandle, event: MenuEvent) {
    let id = event.id().as_ref();
    if !id.starts_with("row:") {
        return; // メニューバーやトレイの項目は別の分岐で扱う
    }
    let target = app.state::<RowMenu>().target.lock().unwrap().take();
    let Some((label, name)) = target else { return };
    if id == "row:delete" {
        println!("delete {name}"); // ファイルの削除など、Rust で完結する処理はここで行う
    }
    // 結果は右クリックしたウィンドウにだけ知らせる
    let _ = app.emit_to(label.as_str(), "row-command", (id.to_string(), name));
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let delete = MenuItemBuilder::with_id("row:delete", "削除").build(app)?;
            let menu = MenuBuilder::new(app)
                .text("row:open", "開く")
                .text("row:rename", "名前を変更...")
                .separator()
                .item(&delete)
                .build()?;
            app.manage(RowMenu { menu, delete, target: Mutex::new(None) });
            Ok(())
        })
        .on_menu_event(handle_row_menu)
        .invoke_handler(tauri::generate_handler![show_row_menu])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ページからは右クリックされた行の情報を付けてコマンドを呼び、結果は自分のウィンドウ宛てのイベントで受け取ります。

import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';

document.addEventListener('contextmenu', (e) => {
  const row = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-name]') : null;
  if (!row) return;
  e.preventDefault();
  void invoke('show_row_menu', { name: row.dataset.name ?? '', locked: row.dataset.locked === 'true' });
});

await getCurrentWebviewWindow().listen<[string, string]>('row-command', ({ payload: [id, name] }) => {
  console.log(id, name);
});

動作確認

ページに次の HTML を置き、JS 版のコードを読み込んで npm run tauri dev で起動します。

<ul>
  <li data-name="report.txt">report.txt</li>
  <li data-name="system.log" data-locked="true">system.log</li>
</ul>
<input placeholder="ここは標準のメニュー">
<button id="more">⋯</button>

report.txt を右クリックして「開く」を選ぶと、コンソールに次のように出ます。system.log では「削除」がグレーになり選べません。行の無い場所では「新規作成」などのメニューが、入力欄では「貼り付け」を含む標準のメニューが出ます。「⋯」ボタンを押すとボタンの左下にメニューが出ます。位置の基準はウィンドウなので、対象の OS それぞれで位置を確かめます。

開く: report.txt

よくあるエラーと対処法

  • ネイティブのメニューとブラウザのメニューが両方出る: preventDefault() を await の後で呼んでいます。ハンドラーの先頭で呼びます。
  • 「menu.popup not allowed. Permissions associated with this command: core:menu:allow-popup, core:menu:default」: capability に core:default も core:menu:default もないか、右クリックしたウィンドウのラベルが windows に入っていません。リリースビルドでは「Command plugin:menu|popup not allowed by ACL」になります。
  • 選んでも何も起きない: JS で作った項目なら action の付け忘れ、Rust で作ったメニューなら on_menu_event の登録漏れです。
  • メニューバーの同じ id の処理まで動く: どのハンドラーにも全メニューのイベントが届きます。id を接頭辞で分けます。
  • 右クリックを繰り返すうちに重くなる: ハンドラーの中で毎回 Menu.new() しています。作るのは 1 回にします。
  • 入力欄で貼り付けができなくなった: 入力欄でも preventDefault() しています。上の例のように素通しにするか、標準項目の「切り取り」「コピー」「貼り付け」を入れたメニューを出します(menu-007)。

OS ごとの違いと注意点

  • macOS: メニューバーでは最上位にサブメニューしか置けませんが、これはメニューバーとして使う場合の制約です。右クリックメニューでは項目をそのまま並べられます。
  • 標準項目: 「元に戻す」は Windows と Linux で、「最小化」などは Linux で使えないなど、OS ごとに使えない項目があります。入れる前に menu-007 の表で確かめます。
  • iOS / Android: メニューの API はありません。
  • トレイアイコンのメニューは popup() ではなく、トレイアイコンに設定して出します(トレイアイコンのメニューを作る)。自作のタイトルバーのボタンからメニューを出す方法は menu-017 で扱います。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する