From 51673cd09818f9c70a7bfb26ab55ee0af8d3c9b8 Mon Sep 17 00:00:00 2001 From: gniax Date: Tue, 13 Oct 2026 14:07:02 +0200 Subject: [PATCH] feat(web): Apple Pencil input on iPad with palm rejection Read pen and finger Pointer Events in the web shell (capture phase) instead of eframe's touch handlers, which can't tell a pen from a finger and see every touch twice. The pen drives the pointer with its pressure and hovers; fingers are touch contacts for pinch, two-finger pan and undo/redo taps. Once a pen has been seen (remembered in localStorage), a finger or a resting palm never presses a tool on the canvas, and a pen landing cancels fingers already down. A long press drops the tool press and opens the context menu. The page no longer scrolls, zooms or selects under touch, and the canvas stays inside the safe areas of a full-screen app. --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/index.html | 19 ++- apps/vectorcraft-web/src/main.rs | 6 +- apps/vectorcraft-web/src/pencil.rs | 230 +++++++++++++++++++++++++++++ apps/vectorcraft-web/src/web.rs | 10 ++ crates/ui-egui/src/canvas.rs | 35 ++++- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/touch.rs | 61 ++++++++ 8 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 apps/vectorcraft-web/src/pencil.rs diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 811b937a2..2e9218dc3 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -28,4 +28,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } diff --git a/apps/vectorcraft-web/index.html b/apps/vectorcraft-web/index.html index 05490806f..42eaaf74e 100644 --- a/apps/vectorcraft-web/index.html +++ b/apps/vectorcraft-web/index.html @@ -2,12 +2,25 @@ - + + + + + + VectorCraft diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index fdf419915..4ad16335c 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -9,7 +9,9 @@ //! - File → Open uses the browser file picker; bytes arrive asynchronously through //! `Services::inbox`; //! - Save / Export trigger a browser download; -//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; +//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and +//! hover, palm rejection, fingers navigate once a pen has been seen). //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any //! language code) stands in for the browser's languages when the interface language is Automatic. @@ -19,6 +21,8 @@ mod fonts; #[cfg(target_arch = "wasm32")] mod locks; #[cfg(target_arch = "wasm32")] +mod pencil; +#[cfg(target_arch = "wasm32")] mod web; #[cfg(target_arch = "wasm32")] diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs new file mode 100644 index 000000000..4416196cd --- /dev/null +++ b/apps/vectorcraft-web/src/pencil.rs @@ -0,0 +1,230 @@ +//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in +//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from +//! a finger and see each touch twice (as a pointer and as a touch event). +//! +//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the +//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers +//! where the pen can (Apple Pencil hover). +//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first +//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates +//! only once a pen has been seen ([`touch::set_source`]), which the page remembers. +//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers +//! already down (a palm resting first). +//! +//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; +use vectorcraft_ui_egui::touch::{self, Source}; +use wasm_bindgen::JsCast as _; + +/// Where the page remembers that a pen has been seen (`localStorage`). +const PEN_SEEN_KEY: &str = "vectorcraft-pen-seen"; +/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger). +const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1); +const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2); + +/// The pen and finger contacts followed, and the egui events they made since the last frame. +#[derive(Default)] +pub struct Pointers { + events: Vec, + /// The pen pressing, by pointer id. + pen: Option, + /// The fingers down, by pointer id. + fingers: Vec, + /// The contact driving egui's pointer. + primary: Option, +} + +pub type SharedPointers = Rc>; + +impl Pointers { + /// The events since the last frame, for the frame's raw input. + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) { + self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force }); + } + + fn press(&mut self, id: i32, pos: Pos2) { + self.primary = Some(id); + self.events.push(Event::PointerMoved(pos)); + self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::default() }); + } + + fn release(&mut self, pos: Pos2) { + self.primary = None; + self.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::default() }); + } + + /// Cancel every finger down (a palm under a pen landing). + fn cancel_fingers(&mut self, pos: Pos2) { + for id in std::mem::take(&mut self.fingers) { + self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None); + if self.primary == Some(id) { + self.release(pos); + self.events.push(Event::PointerGone); + } + } + } + + /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). + fn feed(&mut self, kind: &str, e: &web_sys::PointerEvent, pos: Pos2, on_canvas: bool) -> bool { + let id = e.pointer_id(); + match e.pointer_type().as_str() { + "pen" => { + let force = Some(e.pressure().clamp(0.0, 1.0)); + match kind { + "pointerdown" => { + if !on_canvas && self.pen.is_none() { + return false; + } + set_pen_seen(); + touch::set_source(Source::Pen); + self.cancel_fingers(pos); + self.pen = Some(id); + self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force); + self.press(id, pos); + } + "pointermove" if self.pen == Some(id) => { + self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force); + self.events.push(Event::PointerMoved(pos)); + } + // Hovering (Apple Pencil hover): the pointer follows without pressing. + "pointermove" => { + if self.primary.is_some() { + return true; + } + if !on_canvas { + return false; + } + self.events.push(Event::PointerMoved(pos)); + } + "pointerup" | "pointercancel" if self.pen == Some(id) => { + let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; + self.pen = None; + self.touch(PEN_DEVICE, id, phase, pos, None); + self.release(pos); + } + // A pen leaving the hover range, or an event of a press we didn't take. + _ => { + if kind == "pointerleave" && self.primary.is_none() && on_canvas { + self.events.push(Event::PointerGone); + } + return self.pen.is_some(); + } + } + true + } + "touch" => { + match kind { + "pointerdown" => { + // Palm rejection: nothing presses while the pen does. + if self.pen.is_some() { + return true; + } + if !on_canvas && self.fingers.is_empty() { + return false; + } + self.fingers.push(id); + self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None); + if self.primary.is_none() && self.fingers.len() == 1 { + touch::set_source(Source::Finger); + self.press(id, pos); + } + } + "pointermove" if self.fingers.contains(&id) => { + self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None); + if self.primary == Some(id) { + self.events.push(Event::PointerMoved(pos)); + } + } + "pointerup" | "pointercancel" if self.fingers.contains(&id) => { + let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; + self.fingers.retain(|f| *f != id); + self.touch(FINGER_DEVICE, id, phase, pos, None); + if self.primary == Some(id) { + self.release(pos); + self.events.push(Event::PointerGone); + } + } + // A rejected palm's later events, or one we never took. + _ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas, + } + true + } + _ => false, + } + } +} + +/// Whether the page remembers a pen, for [`touch::set_pen_seen`] before the first frame. +pub fn restore_pen_seen() { + let seen = local_storage().and_then(|s| s.get_item(PEN_SEEN_KEY).ok().flatten()).is_some_and(|v| v == "1"); + touch::set_pen_seen(seen); +} + +fn set_pen_seen() { + if !touch::pen_seen() + && let Some(s) = local_storage() + { + // Private browsing can refuse it: fingers then draw again on the next visit, no more. + let _ = s.set_item(PEN_SEEN_KEY, "1"); + } +} + +fn local_storage() -> Option { + web_sys::window()?.local_storage().ok().flatten() +} + +/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch +/// events that follow them), for the runner's frames. Mouse events stay eframe's. +pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> { + let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into(); + let options = web_sys::AddEventListenerOptions::new(); + options.set_capture(true); + options.set_passive(false); + for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] { + let (pointers, canvas) = (pointers.clone(), canvas.clone()); + runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| { + let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); + let rect = canvas.get_bounding_client_rect(); + let z = runner.egui_ctx().zoom_factor(); + let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z); + if !pointers.borrow_mut().feed(kind, &e, pos, on_canvas) { + return; + } + e.stop_immediate_propagation(); + // No compatibility mouse events, no text selection or magnifier. + e.prevent_default(); + if kind == "pointerup" { + // Keys typed next (a Magic Keyboard) go to the app. + canvas.focus().ok(); + // Safari lets a page open the share sheet, a download or the on-screen keyboard + // only during the user's gesture: handle the release now. + runner.logic(); + } + runner.egui_ctx().request_repaint(); + })?; + } + // The touch events behind those pointer events: eframe would see each touch a second time, + // and Safari would scroll, zoom or select. + for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] { + let (pointers, canvas) = (pointers.clone(), canvas.clone()); + runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| { + let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); + let p = pointers.borrow(); + if on_canvas || p.pen.is_some() || !p.fingers.is_empty() { + e.stop_immediate_propagation(); + if e.cancelable() { + e.prevent_default(); + } + } + })?; + } + Ok(()) +} diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 3be50e5fb..e4753c28c 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; +use crate::pencil::SharedPointers; type Inbox = Arc)>>>; @@ -47,6 +48,8 @@ struct Host { place_inbox: PlaceInbox, drag: DragPos, pasted: Rc>, + /// Pen and finger input read from the page's Pointer Events (`crate::pencil`). + pointers: SharedPointers, /// The page's Web Locks for Data Recovery, started once before the first frame: a graphics /// restart keeps them, so the tab stays the owner of its recovery area. locks: Option, @@ -61,6 +64,7 @@ pub fn start() { return; }; detect_language(); + crate::pencil::restore_pen_seen(); wasm_bindgen_futures::spawn_local(async move { // Before the first frame, which looks for copies a crash left behind. let locks = WebLocks::start(RECOVERY_PREFIX).await; @@ -73,6 +77,7 @@ pub fn start() { place_inbox: Arc::default(), drag: DragPos::default(), pasted: Rc::default(), + pointers: SharedPointers::default(), locks, restarts: 0, }; @@ -96,6 +101,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { + if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) { + log::error!("vectorcraft-web: no pen input: {}", js_err(e)); + } if let Some(el) = element(LOADING_ID) { el.remove(); } @@ -381,6 +390,7 @@ impl eframe::App for WebShell { } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { app.raw_input_hook(raw); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7aa8ab790..fc1e11be2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -569,6 +569,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: app.hover_doc = hover.map(|p| xf.to_doc(p)); let view = app.view_info(); touch_gestures(app, ui, rect, view); + // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the + // tool's press is dropped, as a right-click presses nothing. + if resp.long_touched() { + drop_press(app, ui, view); + } let drag: Option = ui.data(|d| d.get_temp(drag_id())); // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows // without moving the mouse (Alt switches the Shape Builder to erase mode). @@ -610,7 +615,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: { ui.ctx().memory_mut(|mem| mem.stop_text_input()); app.ui.flyout = None; - let d = if zoom_mode { + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } } else if pan_mode { Drag::Pan { start: p, center: v.center, middle: false } @@ -890,13 +898,13 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) { continue; } + // A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began. + if phase == egui::TouchPhase::Cancel { + drop_press(app, ui, view); + } match taps.feed(id, phase, pos, now) { Some(crate::touch::Touch::Fingers) => { - if ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool) { - // Nothing it began is kept; the tool's mouse-up returns it to rest. - let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); - } + drop_press(app, ui, view); ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); } Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), @@ -909,6 +917,21 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec } } +/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press +/// does nothing ([`Drag::Gesture`]). +fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { + match ui.data(|d| d.get_temp::(drag_id())) { + Some(Drag::Tool) => { + // The tool's mouse-up returns it to rest. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + Some(_) => {} + None => return, + } + ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); +} + /// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of /// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it /// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55c158ca8..57fe1cba0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,7 +55,7 @@ pub mod theme; pub mod thumb_worker; pub mod titlebar; pub mod toolbar; -mod touch; +pub mod touch; mod ui_fonts; pub mod unsaved; pub mod widgets; diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs index 1f5e9c999..b6223907c 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -5,9 +5,56 @@ //! system: `WM_POINTER` on Windows), and makes the first finger the mouse pointer too. So the first //! finger has pressed on the canvas by the time a second lands: that press is dropped then //! ([`Taps::feed`] says [`Touch::Fingers`]), and the tap undoes what came before it. +//! +//! Where the platform tells a pen from a finger (the web shell reads Pointer Events: an iPad with +//! an Apple Pencil), it says which drives the pointer ([`set_source`]). Once a pen has been seen, +//! fingers only navigate: pinch, two-finger pan and the taps; a finger or a resting palm never +//! presses a tool on the canvas. + +use std::cell::Cell; use egui::{Pos2, TouchId, TouchPhase}; +/// What drives the pointer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Source { + /// A mouse or trackpad, or a platform that can't tell (the desktop apps). + #[default] + Mouse, + /// A pen (Apple Pencil, a stylus). + Pen, + /// A finger on a touch screen. + Finger, +} + +thread_local! { + static SOURCE: Cell = const { Cell::new(Source::Mouse) }; + static PEN_SEEN: Cell = const { Cell::new(false) }; +} + +/// The pointer's presses now come from `source`; a pen makes fingers navigate only from then on. +pub fn set_source(source: Source) { + SOURCE.with(|s| s.set(source)); + if source == Source::Pen { + PEN_SEEN.with(|p| p.set(true)); + } +} + +/// Whether a pen has been seen (the shell restores it across visits), so fingers navigate only. +pub fn set_pen_seen(seen: bool) { + PEN_SEEN.with(|p| p.set(seen)); +} + +/// A pen has been seen. +pub fn pen_seen() -> bool { + PEN_SEEN.with(Cell::get) +} + +/// The press in progress is a finger's while a pen has been seen: it navigates, it doesn't draw. +pub(crate) fn finger_navigates_only() -> bool { + SOURCE.with(Cell::get) == Source::Finger && pen_seen() +} + /// How long the fingers of a tap may stay down, in seconds. const TAP_TIME: f64 = 0.5; /// How far (points) a finger of a tap may move: further, it is a pinch or a pan. @@ -128,4 +175,18 @@ mod tests { assert_eq!(gesture(0.0, 0.2, TouchPhase::Cancel, 2), None, "cancelled"); assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 1), None, "a pen or one finger"); } + + #[test] + fn fingers_navigate_only_once_a_pen_was_seen() { + set_pen_seen(false); + set_source(Source::Finger); + assert!(!finger_navigates_only(), "no pen yet: a finger draws"); + set_source(Source::Pen); + assert!(pen_seen() && !finger_navigates_only()); + set_source(Source::Finger); + assert!(finger_navigates_only()); + set_source(Source::Mouse); + assert!(!finger_navigates_only(), "a mouse still draws"); + set_pen_seen(false); + } }