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.
This commit is contained in:
gniax
2026-10-16 01:36:23 +07:00
committed by bflatastic
parent b5b9264d71
commit 51673cd098
8 changed files with 353 additions and 12 deletions
+1 -1
View File
@@ -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"] }
+16 -3
View File
@@ -2,12 +2,25 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="VectorCraft">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
display: block; touch-action: none; }
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+5 -1
View File
@@ -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")]
+230
View File
@@ -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<Event>,
/// The pen pressing, by pointer id.
pen: Option<i32>,
/// The fingers down, by pointer id.
fingers: Vec<i32>,
/// The contact driving egui's pointer.
primary: Option<i32>,
}
pub type SharedPointers = Rc<RefCell<Pointers>>;
impl Pointers {
/// The events since the last frame, for the frame's raw input.
pub fn take(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
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::Storage> {
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::<web_sys::EventTarget>());
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::<web_sys::EventTarget>());
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(())
}
+10
View File
@@ -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<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -47,6 +48,8 @@ struct Host {
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// 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<WebLocks>,
@@ -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<Movi
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
let result = runner
.start(
canvas,
@@ -127,6 +133,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
.await;
match (result, kept) {
(Ok(()), _) => {
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);
}
+29 -6
View File
@@ -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<Drag> = 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>(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>(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).
+1 -1
View File
@@ -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;
+61
View File
@@ -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<Source> = const { Cell::new(Source::Mouse) };
static PEN_SEEN: Cell<bool> = 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);
}
}