マウスクリックを透過させる(クリックスルー)

setIgnoreCursorEvents()(権限 core:window:allow-set-ignore-cursor-events)でクリックを背後のアプリへ通す。ボタンの上だけ押せる部分透過と、解除用のショートカットも示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約9分 win-027
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 切り替えの基本
  4. 一部の要素だけクリックできるようにする
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

配信画面に重ねる字幕、ゲーム中の HUD、デスクトップのマスコットのように、表示はしたままクリックは背後のアプリに届けたいウィンドウがあります。これを「クリックスルー」と呼び、JS の setIgnoreCursorEvents(true) か Rust の set_ignore_cursor_events(true) で有効にします(プラグイン不要)。有効にするとウィンドウはクリック・ホイール・マウスの移動を一切受け取らなくなるので、ウィンドウをクリックして元に戻すことはできません。戻し方を先に用意しておくのが一番のポイントです。背景を透かす方法は 透明なウィンドウ、手前に固定する方法は 常に最前面 を参照してください。

前提条件

setIgnoreCursorEvents() の権限 core:window:allow-set-ignore-cursor-events は core:default に含まれないので追加します。後述の「一部の要素だけクリックできるようにする」で使う cursorPosition()・innerPosition()・scaleFactor() は core:window:default に含まれます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the overlay window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-set-ignore-cursor-events"
  ]
}

クリックスルーは設定ファイルでは指定できず、WebviewWindowBuilder にも該当するオプションはありません。起動直後から有効にしたいなら、Rust の setup で呼びます。2 章では解除用のショートカットに Global Shortcut プラグインを使います。Rust から登録するだけなら、JS 用の権限は要りません。

npm run tauri add global-shortcut
やりたいこと方法
常にクリックスルーにするsetup で set_ignore_cursor_events(true)
一部の要素だけ押せるようにするカーソルの位置を定期的に調べて切り替える(1 章)
必要なときだけ操作できるようにするグローバルショートカット(2 章)や トレイのメニュー で切り替える

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

切り替えの基本

今の状態を読み取る API は無いので、状態はアプリ側で持ちます。開発中にページを再読み込みすると JS の変数は初期値に戻りますが、ウィンドウのクリックスルーはそのまま残ります。読み込み時に一度 false を明示して、両者をそろえておきます。

import { getCurrentWindow } from '@tauri-apps/api/window';

const win = getCurrentWindow();

export async function setClickThrough(on: boolean) {
  await win.setIgnoreCursorEvents(on); // core:window:allow-set-ignore-cursor-events が必要
  document.body.classList.toggle('click-through', on); // 半透明にするなど、状態を見た目で示す
  console.log('[click-through]', on);
}

// 戻し忘れても操作不能にならないよう、時間を区切って有効にする
export async function clickThroughFor(ms: number) {
  await setClickThrough(true);
  window.setTimeout(() => void setClickThrough(false), ms);
}

await setClickThrough(false); // 再読み込み後もウィンドウと変数をそろえる

キーボード入力は止まらないので、フォーカスがある間はページ内の keydown でも解除できます。ただし背後のアプリをクリックした時点でフォーカスはそちらへ移り、クリックでは取り戻せないので、これだけに頼るのは危険です。

一部の要素だけクリックできるようにする

クリックスルーはウィンドウ全体にしか効かず、「ボタンの上だけ受け付ける」といった指定はできません。そこで、カーソルの位置を一定間隔で調べ、操作させたい要素の上にあるときだけ解除します。クリックスルー中はページに mousemove が届かないので、DOM のイベントでは判定できません。画面全体での位置を返す cursorPosition() を使います(カーソル座標の取得)。

cursorPosition() と innerPosition()(ページが描かれる領域の左上)はどちらも物理ピクセルです。差を倍率で割るとページの CSS ピクセルになるので、document.elementFromPoint() でその位置の要素を調べます。

<div class="hud">
  <p>字幕やコメント(ここのクリックは背後へ届く)</p>
  <button type="button" data-hit>設定</button>
</div>
import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window';

const win = getCurrentWindow();
let ignoring: boolean | null = null;

async function updateHitTest() {
  const [cursor, origin, scale] = await Promise.all([
    cursorPosition(),    // 画面全体での位置(物理ピクセル)
    win.innerPosition(), // 描画領域の左上(物理ピクセル)
    win.scaleFactor(),
  ]);
  const x = (cursor.x - origin.x) / scale;
  const y = (cursor.y - origin.y) / scale;
  // data-hit を付けた要素の上だけ操作を受け付ける(ウィンドウの外なら null)
  const hit = document.elementFromPoint(x, y)?.closest('[data-hit]') != null;
  if (ignoring !== !hit) {
    ignoring = !hit;
    await win.setIgnoreCursorEvents(ignoring);
    console.log('[hit-test] ignore =', ignoring);
  }
}

export function startHitTest(intervalMs = 50) {
  let running = false;
  const timer = window.setInterval(() => {
    if (running) return; // 前回の問い合わせが終わるまで次を送らない
    running = true;
    updateHitTest()
      .catch(console.error)
      .finally(() => { running = false; });
  }, intervalMs);
  return () => window.clearInterval(timer);
}

間隔を短くするほど追従は良くなりますが、そのぶん Rust 側への問い合わせが増えます(50ms なら 1 秒に約 60 回)。

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

次の例は、起動直後からクリックスルーにし、Ctrl+Shift+X(macOS は Cmd+Shift+X)で切り替えます。状態は State の AtomicBool に持ち、切り替えたらイベントでページに知らせます。tauri add が lib.rs にプラグインの登録を書き足していたら、下の例と二重にならないよう消します。

use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{Emitter, Manager};

struct ClickThrough(AtomicBool);

fn apply_click_through(window: &tauri::WebviewWindow, on: bool) -> tauri::Result<()> {
    window.set_ignore_cursor_events(on)?;
    window.state::<ClickThrough>().0.store(on, Ordering::Relaxed);
    window.emit("click-through-changed", on) // ページの表示を合わせる
}

#[tauri::command]
fn set_click_through(window: tauri::WebviewWindow, on: bool) -> Result<(), String> {
    apply_click_through(&window, on).map_err(|e| e.to_string())
}

#[tauri::command]
fn click_through_state(state: tauri::State<'_, ClickThrough>) -> bool {
    state.0.load(Ordering::Relaxed)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(ClickThrough(AtomicBool::new(false)))
        .setup(|app| {
            if let Some(main) = app.get_webview_window("main") {
                apply_click_through(&main, true)?; // 起動直後からクリックスルー
            }
            #[cfg(desktop)]
            {
                use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};

                app.handle().plugin(tauri_plugin_global_shortcut::Builder::new().build())?;
                app.global_shortcut().on_shortcut("CommandOrControl+Shift+X", |app, _shortcut, event| {
                    if event.state() != ShortcutState::Pressed {
                        return; // キーを離したときにも呼ばれるので無視する
                    }
                    if let Some(main) = app.get_webview_window("main") {
                        let next = !app.state::<ClickThrough>().0.load(Ordering::Relaxed);
                        let _ = apply_click_through(&main, next);
                    }
                })?;
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![set_click_through, click_through_state])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

setup の時点ではページがまだ読み込まれていないので、ページは最初の状態をコマンドで問い合わせ、以降の変化はイベントで受け取ります。Rust のコマンド経由なら、JS 側に core:window:allow-set-ignore-cursor-events は要りません。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

const render = (on: boolean) => document.body.classList.toggle('click-through', on);

render(await invoke<boolean>('click_through_state')); // setup で有効にした状態を反映
await listen<boolean>('click-through-changed', (e) => render(e.payload));

1 章の startHitTest() と 2 章のショートカットを同時に使うと、定期的な判定がショートカットでの切り替えを上書きしてしまいます。どちらか一方にします。

動作確認

npm run tauri dev で 2 章の例を起動すると、ウィンドウは表示されたまま、その上をクリックすると背後のアプリやデスクトップが反応します。ショートカットを押すたびにページの click-through クラスが切り替わり、解除中はウィンドウのボタンを押せます。1 章の startHitTest() では、カーソルを「設定」ボタンに乗せたときだけ解除され、コンソールに次のように出ます。

[hit-test] ignore = true
[hit-test] ignore = false
[hit-test] ignore = true

よくあるエラーと対処法

  • 「window.set_ignore_cursor_events not allowed. Permissions associated with this command: core:window:allow-set-ignore-cursor-events」: 権限の追加漏れです。この文面はデバッグビルドのもので、リリースビルドでは「Command plugin:window|set_ignore_cursor_events not allowed by ACL」と出ます。追加して tauri dev を再起動します。
  • 操作できなくなって戻せない: 開発中なら、tauri dev を動かしているターミナルで Ctrl+C を押して終了します。配布するアプリでは、ショートカット・トレイ・時間切れのどれかで必ず戻せるようにします。
  • 部分クリックの判定がずれる: 物理ピクセルと CSS ピクセルが混ざっていないか確認します。outerPosition() を使うとタイトルバーと枠の分だけずれるので、描画領域の左上である innerPosition() を使います。
  • 起動しなくなった: 他のアプリが同じキーの組み合わせを使っていると、ショートカットの登録に失敗することがあります。例のように ? で返しているとアプリが起動しないので、別のキーにするか、エラーをログに出して続行します。

OS ごとの違いと注意点

  • iOS / Android: デスクトップ専用の機能で、使えません。
  • macOS: CommandOrControl は Cmd キーになります。透けて見えるオーバーレイにするには macOSPrivateApi が必要です(透明なウィンドウ)。
  • Linux: 部分クリックの方法は、ウィンドウの外でもカーソル位置が取れることが前提です。Wayland では取れない場合があるので(カーソル座標の取得)、常時クリックスルーとショートカットの組み合わせにします。
  • 共通: クリックスルー中もウィンドウの表示や最前面の設定はそのままです。表示したときにフォーカスを奪われたくないオーバーレイは、ウィンドウの設定で "focus": false にしておきます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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