-
+
+
+
+
+
+
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);
+ }
}