一覧の行を右クリックしたら「開く」「名前を変更」「削除」が並ぶ、といったネイティブのメニューは、メニューを作って 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 で扱います。
