mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #4106 from addibble/web-zoom-keys
Web: shortcuts the browser also has run only in the app
This commit is contained in:
@@ -29,4 +29,4 @@ wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
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", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "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"] }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Shortcuts the browser would also act on. Many of the app's Cmd and Ctrl chords are the
|
||||
//! browser's too: the View menu's `Cmd+=`, `Cmd+-` and `Cmd+0` zoom the page, Edit's `Cmd+Z` and
|
||||
//! `Cmd+Shift+Z` undo and redo in the browser (in Safari, Undo Close Tab when the page has nothing
|
||||
//! to undo), `Cmd+[` and `Cmd+]` go back and forward, `Cmd+F` finds. eframe lets them through, so
|
||||
//! the browser acted on them as well. For a press the keymap in effect binds (the user's shortcuts
|
||||
//! included), the page's default action is cancelled; the key still reaches egui, which runs the
|
||||
//! command (or a text field its own undo). A few chords never reach a page at all (Safari's
|
||||
//! `Cmd+L`, Chrome's `Cmd+T`, `Cmd+W` and `Cmd+N`): the browser keeps those.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
/// Whether the browser's own action for a key press is cancelled. `key` is the event's `key` (the
|
||||
/// character typed), `code` its `code` (the key's position), `modifiers` as eframe reports them,
|
||||
/// and `alt_graph` whether AltGr is held.
|
||||
///
|
||||
/// Only Cmd and Ctrl presses: a plain or Shift press types (cancelling it would drop the
|
||||
/// character it types), and so does AltGr, which Windows reports as Ctrl and Alt held. Cut, Copy
|
||||
/// and Paste (with any other modifiers) are left to the browser, whose clipboard events carry
|
||||
/// their text, and Reload too, as eframe leaves them.
|
||||
pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt_graph: bool) -> bool {
|
||||
if !(modifiers.ctrl || modifiers.mac_cmd) || alt_graph {
|
||||
return false;
|
||||
}
|
||||
if matches!(Key::from_name(key).or(Key::from_name(code)), Some(Key::C | Key::V | Key::X | Key::R)) {
|
||||
return false;
|
||||
}
|
||||
vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers)
|
||||
}
|
||||
|
||||
/// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers {
|
||||
Modifiers { alt: e.alt_key(), ctrl: e.ctrl_key(), shift: e.shift_key(), mac_cmd: e.meta_key(), command: e.ctrl_key() || e.meta_key() }
|
||||
}
|
||||
|
||||
/// Whether the app has the keyboard for this event: it went to the canvas, or to the text input
|
||||
/// eframe places right after it while a text field has the focus. Elsewhere (the page's body, a
|
||||
/// file input) eframe ignores the key, so the browser keeps it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn to_the_app(e: &web_sys::KeyboardEvent) -> bool {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
let Some(target) = e.target() else { return false };
|
||||
if target.has_type::<web_sys::HtmlCanvasElement>() {
|
||||
return true;
|
||||
}
|
||||
target
|
||||
.dyn_ref::<web_sys::HtmlInputElement>()
|
||||
.and_then(|input| input.previous_element_sibling())
|
||||
.is_some_and(|before| before.has_type::<web_sys::HtmlCanvasElement>())
|
||||
}
|
||||
|
||||
/// Cancel the page's default action for [`kept_from_browser`] presses, before eframe's listener
|
||||
/// sees the event (it doesn't stop it).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn keep_app_keys() {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
|
||||
// An IME's keys are its own until it commits.
|
||||
if e.is_composing() || !to_the_app(&e) {
|
||||
return;
|
||||
}
|
||||
if kept_from_browser(&e.key(), &e.code(), modifiers(&e), e.get_modifier_state("AltGraph")) {
|
||||
e.prevent_default();
|
||||
}
|
||||
});
|
||||
if let Err(e) = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true) {
|
||||
log::error!("couldn't follow keydown events: {e:?}");
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
on_key.forget();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere.
|
||||
fn cmd() -> Modifiers {
|
||||
if cfg!(target_os = "macos") { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keymaps_chords_are_kept_from_the_browser() {
|
||||
for (key, code, mods) in [
|
||||
("=", "Equal", cmd()),
|
||||
("-", "Minus", cmd()),
|
||||
("0", "Digit0", cmd()),
|
||||
("z", "KeyZ", cmd()),
|
||||
("Z", "KeyZ", cmd() | Modifiers::SHIFT),
|
||||
("[", "BracketLeft", cmd()),
|
||||
] {
|
||||
assert!(kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_the_clipboard_and_reload_stay_with_the_browser() {
|
||||
for (key, code, mods) in [
|
||||
// Typing: plain and Shift presses.
|
||||
("z", "KeyZ", Modifiers::NONE),
|
||||
("Z", "KeyZ", Modifiers::SHIFT),
|
||||
("=", "Equal", Modifiers::NONE),
|
||||
// The clipboard's events carry the text; Paste and Match Style is Shift+Cmd+V.
|
||||
("c", "KeyC", cmd()),
|
||||
("x", "KeyX", cmd()),
|
||||
("v", "KeyV", cmd()),
|
||||
("V", "KeyV", cmd() | Modifiers::SHIFT),
|
||||
("r", "KeyR", cmd()),
|
||||
] {
|
||||
assert!(!kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}");
|
||||
}
|
||||
// AltGr+Z types ż on a Polish layout, reported as Ctrl and Alt held on Windows.
|
||||
let ctrl_alt = Modifiers::CTRL | Modifiers::COMMAND | Modifiers::ALT;
|
||||
assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true));
|
||||
assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
//! - 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, the pen's eraser end
|
||||
//! selects the Eraser tool).
|
||||
//! selects the Eraser tool);
|
||||
//! - a Cmd or Ctrl chord the keymap binds runs its command, not the browser's own action as well
|
||||
//! (`Cmd+=` zooms the canvas, not the page; `Cmd+Z` undoes in the document) (`keys`).
|
||||
//!
|
||||
//! 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.
|
||||
@@ -21,6 +23,8 @@
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod keys;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn start() {
|
||||
restarts: 0,
|
||||
};
|
||||
track_paste(&host.pasted);
|
||||
crate::keys::keep_app_keys();
|
||||
run(host, canvas, None).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,6 +124,22 @@ fn runs_a_command(i: &egui::InputState, key: Key, cmd_only: bool) -> bool {
|
||||
!presses.is_empty() && presses.iter().all(bound)
|
||||
}
|
||||
|
||||
/// Whether a key press runs a shortcut of the keymap in effect, the user's own included. The web
|
||||
/// build asks this of every Cmd and Ctrl press, before egui sees it, to cancel what the browser
|
||||
/// would also do with it (zoom the page on `Cmd+=`, undo on `Cmd+Z`, go back on `Cmd+[`).
|
||||
/// `key` and `code` are the browser's names for the character typed and the key's position, which
|
||||
/// egui reads with [`Key::from_name`]; `modifiers` as egui reports them. The press is matched as
|
||||
/// the dispatcher matches it ([`dispatch`]) on a layout about which nothing is known: what typing
|
||||
/// has shown of the layout is left out, and so are double presses.
|
||||
pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool {
|
||||
let physical_key = Key::from_name(code);
|
||||
let Some(key) = Key::from_name(key).or(physical_key) else { return false };
|
||||
let press = egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers };
|
||||
let Some(input) = craft_keymap::KeyInput::from_egui(&press, crate::keymap::platform()) else { return false };
|
||||
let mirror = crate::shortcut_editor::current();
|
||||
craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None)
|
||||
}
|
||||
|
||||
/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own:
|
||||
/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C
|
||||
/// (Align center) copied instead (#1940).
|
||||
@@ -1264,6 +1280,55 @@ mod tests {
|
||||
assert_eq!(text_and_layers(&mut app, id), ("Hi".into(), layers + 1), "no layer is added until OK");
|
||||
}
|
||||
|
||||
/// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere.
|
||||
fn cmd() -> Modifiers {
|
||||
if crate::keymap::platform().is_mac() { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
|
||||
}
|
||||
|
||||
/// The web build keeps from the browser the presses the keymap binds: the View menu's zoom
|
||||
/// chords, Undo and Redo, whatever the browser reports as the key typed and its position.
|
||||
#[test]
|
||||
fn presses_the_keymap_binds_run_a_shortcut() {
|
||||
crate::shortcut_editor::sync(&crate::state::UiState::default());
|
||||
let (cmd, shift, alt) = (cmd(), Modifiers::SHIFT, Modifiers::ALT);
|
||||
for (key, code, mods) in [
|
||||
("=", "Equal", cmd),
|
||||
("+", "Equal", cmd | shift),
|
||||
("+", "NumpadAdd", cmd),
|
||||
("-", "Minus", cmd),
|
||||
("0", "Digit0", cmd),
|
||||
("º", "Digit0", cmd | alt),
|
||||
("z", "KeyZ", cmd),
|
||||
("Z", "KeyZ", cmd | shift),
|
||||
("[", "BracketLeft", cmd),
|
||||
// German's `+` key, where US has `]`: the character decides.
|
||||
("+", "BracketRight", cmd),
|
||||
] {
|
||||
assert!(runs_a_shortcut(key, code, mods), "{key} ({code}) {mods:?}");
|
||||
}
|
||||
// Keys the browser has no name egui knows for are matched by their position.
|
||||
assert!(runs_a_shortcut("Dead", "KeyZ", cmd), "a dead key at Z");
|
||||
for (key, code, mods) in [("Insert", "Numpad0", cmd), ("F13", "F13", cmd), ("Unidentified", "", cmd)] {
|
||||
assert!(!runs_a_shortcut(key, code, mods), "{key} ({code}) runs nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's shortcuts decide: a chord given to another command runs that one, and a chord
|
||||
/// taken from its command runs nothing.
|
||||
#[test]
|
||||
fn presses_follow_the_users_shortcuts() {
|
||||
let mut ui = crate::state::UiState::default();
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "view.zoomIn", "");
|
||||
crate::keymap::set_override(&mut ui.shortcut_overrides, "view.grid", "Cmd+Alt+Shift+F9");
|
||||
crate::shortcut_editor::sync(&ui);
|
||||
let zoom_in = runs_a_shortcut("=", "Equal", cmd());
|
||||
let grid = runs_a_shortcut("F9", "F9", cmd() | Modifiers::ALT | Modifiers::SHIFT);
|
||||
crate::shortcut_editor::sync(&crate::state::UiState::default());
|
||||
assert!(!zoom_in, "Zoom In has no shortcut");
|
||||
assert!(grid, "Show Grid's own chord");
|
||||
assert!(runs_a_shortcut("=", "Equal", cmd()), "the defaults again");
|
||||
}
|
||||
|
||||
/// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard
|
||||
/// tool's Shift+O), with the Selection tool or while the Type tool edits the text.
|
||||
#[test]
|
||||
|
||||
@@ -35,6 +35,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
|
||||
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
|
||||
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
|
||||
- **Pen and touch input** (`apps/vectorcraft-web/src/pencil.rs`): the shell reads pen and finger Pointer Events itself, in the capture phase on the window, before eframe's 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). Mouse and trackpad events stay eframe's, as do finger events until the first pen contact, preserving eframe's touch-only keyboard path. Pen buttons and event modifiers follow eframe's mappings. A pen drives egui's pointer and is a touch contact whose force is the pen's pressure; its coalesced events (an Apple Pencil reports up to 240 a second) reach the tool one by one with their pressure (`canvas::frame_moves`); holding it still opens the context menu (egui's long touch) and drops the tool's press. Fingers are touch contacts (pinch, two-finger pan, the undo and redo taps); the first one also drives the pointer, so it taps panels and menus. Once a pen has been seen in this page session (reset by reloading; no persistent storage), a finger never presses a tool on the canvas (`touch::finger_navigates_only`), and a pen landing cancels the fingers already down (palm rejection). The page itself doesn't scroll, zoom or select under touch (`touch-action: none`, `overscroll-behavior`), and the canvas stays inside the safe areas.
|
||||
- **Shortcuts the browser also has** (`apps/vectorcraft-web/src/keys.rs`): many of the app's Cmd and Ctrl chords are the browser's too (`Cmd+=`, `Cmd+-` and `Cmd+0` zoom the page, `Cmd+Z` undoes in the browser, in Safari Undo Close Tab, `Cmd+[` goes back, `Cmd+F` finds), and eframe lets most of them through. A capture listener on the window cancels the browser's action for a press the keymap in effect binds, the user's own shortcuts included (`shortcuts::runs_a_shortcut`, matched as the dispatcher matches it); egui still gets the key and runs the command. It acts only while the canvas or eframe's text input has the keyboard, and never on a plain or Shift press, an AltGr press (Windows reports it as Ctrl and Alt) or an IME's keys, which type; Cut, Copy and Paste stay the browser's, whose clipboard events carry the text, and so does Reload. Browsers keep a few chords from every page, which never reach it (Safari's `Cmd+L`, so New Layer's shortcut does nothing there; Chrome's `Cmd+T`, `Cmd+W`, `Cmd+N`): those still go to the browser. While the app has the keyboard, the page-zoom keys zoom the canvas instead; Preferences › User Interface › UI Scaling and the browser's View menu still scale the interface.
|
||||
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
|
||||
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
|
||||
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
|
||||
|
||||
Reference in New Issue
Block a user