diff --git a/rust/tauri-pinch-zoom.md b/rust/tauri-pinch-zoom.md new file mode 100644 index 0000000..385c5c7 --- /dev/null +++ b/rust/tauri-pinch-zoom.md @@ -0,0 +1,157 @@ +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. + +```toml +# src-tauri/Cargo.toml + +[target."cfg(target_os = \"linux\")".dependencies] +gtk = "0.18" +``` + +```rust +``#[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(>k_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 `WheelEvent`s. 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. + +```ts +// 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('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); + }); +} +``` + +```tsx +// 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( + + + , +); +``` + +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.