Files
gists/rust/tauri-pinch-zoom.md

5.2 KiB

On Linux, Tauri uses WebKitGTK as its webview, which intercepts trackpad pinch gestures at the native level and applies them as page zoom. Calling preventDefault() on wheel events from JavaScript does not stop this, because WebKit handles the magnification through a separate gesture pipeline that bypasses the DOM event system.

The approach below intercepts the pinch gesture at the GTK layer before WebKit sees it, then re-dispatches it as a synthetic WheelEvent with ctrlKey: true. This is the same shape of event that browsers fire natively for trackpad pinch on Chrome/Firefox, so the React frontend code can stay platform-agnostic and use a single wheel event handler everywhere.

The Rust side attaches a gtk::GestureZoom controller to the GTK window in the Capture propagation phase and claims the gesture, which prevents WebKit from also processing it. It then emits Tauri events containing the scale factor and the cursor position.

# src-tauri/Cargo.toml

[target."cfg(target_os = \"linux\")".dependencies]
gtk = "0.18"
``#[cfg(target_os = "linux")]
fn setup_linux_pinch(window: &tauri::WebviewWindow) {
    use gtk::prelude::*;
    use gtk::{EventSequenceState, PropagationPhase};
    use tauri::{Emitter, Manager};

    let gtk_window = window.gtk_window().expect("gtk window");
    let app_handle = window.app_handle().clone();

    let gesture = gtk::GestureZoom::new(&gtk_window);
    gesture.set_propagation_phase(PropagationPhase::Capture);

    let ah = app_handle.clone();
    gesture.connect_begin(move |gesture, _seq| {
        gesture.set_state(EventSequenceState::Claimed);
        let (x, y) = gesture.bounding_box_center().unwrap_or((0.0, 0.0));
        let _ = ah.emit("pinch-begin", (x, y));
    });

    let ah = app_handle.clone();
    gesture.connect_scale_changed(move |_, scale| {
        let _ = ah.emit("pinch-scale", scale);
    });

    unsafe {
        gtk_window.set_data("zoom-gesture", gesture);
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            #[cfg(target_os = "linux")]
            {
                use tauri::Manager;
                let window = app.get_webview_window("main").unwrap();
                setup_linux_pinch(&window);
            }
            Ok(())
        })
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

On the JavaScript side, a small adapter listens for the Tauri events and re-dispatches them as synthetic WheelEvents. The adapter checks for window.__TAURI_INTERNALS__ and bails out silently when running in a plain browser, where the events would not fire and @tauri-apps/api/event would fail to initialize.

// src/tauriPinchAdapter.ts

export async function setupTauriPinchAdapter() {
    // Bail out if not running inside Tauri
    if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) {
        return;
    }

    // Dynamic import so the @tauri-apps/api code isn't even loaded in the browser
    const { listen } = await import('@tauri-apps/api/event');

    let pinchOriginX = 0;
    let pinchOriginY = 0;
    let lastScale = 1;

    await listen<[number, number]>('pinch-begin', (event) => {
        const [x, y] = event.payload;
        pinchOriginX = x;
        pinchOriginY = y;
        lastScale = 1;
    });

    await listen<number>('pinch-scale', (event) => {
        const currentScale = event.payload;
        const scaleRatio = currentScale / lastScale;
        lastScale = currentScale;

        const deltaY = -Math.log(scaleRatio) * 100;
        const target = document.elementFromPoint(pinchOriginX, pinchOriginY) ?? document.body;

        const wheelEvent = new WheelEvent('wheel', {
            deltaY,
            ctrlKey: true,
            clientX: pinchOriginX,
            clientY: pinchOriginY,
            bubbles: true,
            cancelable: true,
        });

        target.dispatchEvent(wheelEvent);
    });
}
// src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { setupTauriPinchAdapter } from "./tauriPinchAdapter";

setupTauriPinchAdapter();

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>,
);

With the adapter in place, any component that wants to handle zoom can use a plain wheel event listener with ctrlKey detection. The same code works in the browser, on Linux Tauri (via the adapter), and on other platforms where the browser-style events fire natively.

Notes on the other platforms:

  • Windows (WebView2): set the env var WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--disable-pinch before launching the app. Pinch then fires as a regular wheel event with ctrlKey: true and the adapter is not needed.
  • macOS (WKWebView): access the webview from Rust with with_webview and set allowsMagnification = false. The Linux adapter is not needed.
  • The sensitivity constants (100 in the adapter, 0.01 in the wheel handler) are tuned to roughly match browser pinch feel. Adjust if the zoom speed feels off.