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

158 lines
5.2 KiB
Markdown

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(&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 `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<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);
});
}
```
```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(
<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.