Desktop: Make input routing aware of floating menus (#4498)

* Desktop: Make input routing aware of floating menus

* Desktop: Route mouse input like pen input

* Desktop: Model pointer input routing as a state machine
This commit is contained in:
Timon
2026-09-06 01:29:18 +00:00
committed by GitHub
parent 80746a7f55
commit 63725ea623
12 changed files with 188 additions and 59 deletions

View File

@@ -280,6 +280,9 @@ impl App {
render_state.set_viewport_scale([viewport_scale_x as f32, viewport_scale_y as f32]); render_state.set_viewport_scale([viewport_scale_x as f32, viewport_scale_y as f32]);
} }
} }
DesktopFrontendMessage::WindowUpdateDirectInput { enabled } => {
self.input_state.set_direct_input(enabled);
}
DesktopFrontendMessage::UpdateUIScale { scale } => { DesktopFrontendMessage::UpdateUIScale { scale } => {
self.ui_scale = scale; self.ui_scale = scale;
self.resize(); self.resize();

View File

@@ -4,17 +4,16 @@ use winit::event::{ButtonSource, ElementState, MouseButton, MouseScrollDelta, Po
use winit::keyboard::ModifiersState; use winit::keyboard::ModifiersState;
use crate::ui::{InputEvent, MULTICLICK_ALLOWED_TRAVEL, MULTICLICK_TIMEOUT, PINCH_ZOOM_SPEED, SCROLL_LINE_HEIGHT, SCROLL_LINE_WIDTH, SCROLL_SPEED_X, SCROLL_SPEED_Y}; use crate::ui::{InputEvent, MULTICLICK_ALLOWED_TRAVEL, MULTICLICK_TIMEOUT, PINCH_ZOOM_SPEED, SCROLL_LINE_HEIGHT, SCROLL_LINE_WIDTH, SCROLL_SPEED_X, SCROLL_SPEED_Y};
use crate::wrapper::messages::{InputMessage, ModifierKeys, MouseKeys, PointerState, ScrollDelta}; use crate::wrapper::messages::{EditorPointerState, InputMessage, ModifierKeys, MouseKeys, ScrollDelta};
pub(crate) struct InputState { pub(crate) struct InputState {
start: Instant, start: Instant,
viewport_info: Option<ViewportInfo>, viewport_info: Option<ViewportInfo>,
pointer_lock_position: Option<PhysicalPosition<f64>>,
modifiers: ModifiersState, modifiers: ModifiersState,
pointer_position: PhysicalPosition<f64>, pointer_position: PhysicalPosition<f64>,
pointer_keys: MouseKeys, pointer_state: PointerState,
ui_capture: bool,
click_tracker: ClickTracker, click_tracker: ClickTracker,
direct_input: bool,
} }
impl InputState { impl InputState {
@@ -22,12 +21,11 @@ impl InputState {
Self { Self {
start: Instant::now(), start: Instant::now(),
viewport_info: None, viewport_info: None,
pointer_lock_position: None,
modifiers: ModifiersState::default(), modifiers: ModifiersState::default(),
pointer_position: PhysicalPosition::default(), pointer_position: PhysicalPosition::default(),
pointer_keys: MouseKeys::empty(), pointer_state: PointerState::Hover { route: Route::Ui },
ui_capture: true,
click_tracker: ClickTracker::default(), click_tracker: ClickTracker::default(),
direct_input: false,
} }
} }
@@ -35,20 +33,44 @@ impl InputState {
self.viewport_info = Some(ViewportInfo { x, y, width, height, scale }); self.viewport_info = Some(ViewportInfo { x, y, width, height, scale });
} }
pub(crate) fn set_direct_input(&mut self, enabled: bool) {
self.direct_input = enabled;
}
pub(crate) fn lock_pointer(&mut self) { pub(crate) fn lock_pointer(&mut self) {
self.pointer_lock_position = Some(self.pointer_position); self.pointer_state = match self.pointer_state {
PointerState::Hover { route } => PointerState::Locked {
route,
keys: MouseKeys::empty(),
position: self.pointer_position,
},
PointerState::Stroke { route, keys } | PointerState::Locked { route, keys, .. } => PointerState::Locked {
route,
keys,
position: self.pointer_position,
},
};
} }
pub(crate) fn unlock_pointer(&mut self) -> Option<PhysicalPosition<f64>> { pub(crate) fn unlock_pointer(&mut self) -> Option<PhysicalPosition<f64>> {
let position = self.pointer_lock_position.take(); let PointerState::Locked {
if let Some(position) = position { route: resume,
self.pointer_position = position; keys,
} position: restore,
position } = self.pointer_state
else {
return None;
};
self.pointer_position = restore;
self.pointer_state = match keys.is_empty() {
true => PointerState::Hover { route: Route::Ui },
false => PointerState::Stroke { route: resume, keys },
};
Some(restore)
} }
pub(crate) fn pointer_locked(&self) -> bool { pub(crate) fn pointer_locked(&self) -> bool {
self.pointer_lock_position.is_some() matches!(self.pointer_state, PointerState::Locked { .. })
} }
pub(crate) fn modifiers(&self) -> ModifiersState { pub(crate) fn modifiers(&self) -> ModifiersState {
@@ -60,24 +82,28 @@ impl InputState {
WindowEvent::PointerMoved { position, source, .. } => { WindowEvent::PointerMoved { position, source, .. } => {
self.pointer_position = *position; self.pointer_position = *position;
let PointerSource::TabletTool { kind, data } = source else { let route = match self.pointer_state {
ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build()); PointerState::Hover { .. } => {
return; let next = self.route(*position);
self.pointer_state = PointerState::Hover { route: next };
next
}
PointerState::Stroke { route, .. } => route,
PointerState::Locked { keys, route: resume, .. } => match keys.is_empty() {
true => Route::Ui,
false => resume,
},
}; };
let ui_capture = if self.pointer_keys.is_empty() { match route {
self.pointer_locked() || !self.in_viewport(*position) Route::Ui => ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build()),
} else { Route::Editor => editor_callback(InputMessage::PointerMove {
self.ui_capture editor_mouse_state: match source {
}; PointerSource::TabletTool { kind, data } => self.tablet_pointer_state(kind, data),
if ui_capture { _ => self.pointer_state(),
ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build()); },
return; modifier_keys: self.modifier_keys(),
}),
} }
editor_callback(InputMessage::PointerMove {
editor_mouse_state: self.tablet_pointer_state(kind, data),
modifier_keys: self.modifier_keys(),
});
} }
WindowEvent::PointerEntered { position, .. } => { WindowEvent::PointerEntered { position, .. } => {
self.pointer_position = *position; self.pointer_position = *position;
@@ -91,13 +117,6 @@ impl InputState {
WindowEvent::PointerButton { state, button, position, .. } => { WindowEvent::PointerButton { state, button, position, .. } => {
self.pointer_position = *position; self.pointer_position = *position;
let tablet = matches!(button, ButtonSource::TabletTool { .. });
// Stroke keeps capture decided from first button press until all buttons are released.
if state.is_pressed() && self.pointer_keys.is_empty() {
self.ui_capture = self.pointer_locked() || !tablet || !self.in_viewport(*position);
}
let mouse_button = button.clone().mouse_button(); let mouse_button = button.clone().mouse_button();
let keys = match mouse_button { let keys = match mouse_button {
Some(MouseButton::Left) => MouseKeys::LEFT, Some(MouseButton::Left) => MouseKeys::LEFT,
@@ -107,15 +126,41 @@ impl InputState {
Some(MouseButton::Forward) => MouseKeys::FORWARD, Some(MouseButton::Forward) => MouseKeys::FORWARD,
_ => MouseKeys::NONE, _ => MouseKeys::NONE,
}; };
match state {
ElementState::Pressed => self.pointer_keys.insert(keys), let (pointer, route) = match self.pointer_state {
ElementState::Released => self.pointer_keys.remove(keys), PointerState::Hover { route } => match (state.is_pressed(), keys.is_empty()) {
} (true, false) => {
let route = self.route(*position);
(PointerState::Stroke { route, keys }, route)
}
(true, true) => (PointerState::Hover { route }, self.route(*position)),
(false, _) => (PointerState::Hover { route }, route),
},
PointerState::Stroke { route, keys: mut held } => {
match state.is_pressed() {
true => held.insert(keys),
false => held.remove(keys),
}
match held.is_empty() {
true => (PointerState::Hover { route }, route),
false => (PointerState::Stroke { route, keys: held }, route),
}
}
PointerState::Locked { route, keys: mut held, position } => {
let resume = if state.is_pressed() && held.is_empty() { Route::Ui } else { route };
match state.is_pressed() {
true => held.insert(keys),
false => held.remove(keys),
}
(PointerState::Locked { route: resume, keys: held, position }, Route::Ui)
}
};
self.pointer_state = pointer;
let count = mouse_button.map_or(1, |button| self.click_tracker.input(*position, button, *state)); let count = mouse_button.map_or(1, |button| self.click_tracker.input(*position, button, *state));
let back_or_forward = matches!(mouse_button, Some(MouseButton::Back | MouseButton::Forward)); let back_or_forward = matches!(mouse_button, Some(MouseButton::Back | MouseButton::Forward));
if self.pointer_locked() || !(back_or_forward || (tablet && !self.ui_capture)) { if self.pointer_locked() || keys.is_empty() || !(back_or_forward || route == Route::Editor) {
let pointer = InputEvent::pointer().position(*position); let pointer = InputEvent::pointer().position(*position);
let input = match state { let input = match state {
ElementState::Pressed => pointer.pressed(button.clone(), count), ElementState::Pressed => pointer.pressed(button.clone(), count),
@@ -135,7 +180,7 @@ impl InputState {
ElementState::Released if count % 2 == 0 => { ElementState::Released if count % 2 == 0 => {
editor_callback(InputMessage::PointerUp { editor_mouse_state, modifier_keys }); editor_callback(InputMessage::PointerUp { editor_mouse_state, modifier_keys });
editor_callback(InputMessage::DoubleClick { editor_callback(InputMessage::DoubleClick {
editor_mouse_state: PointerState { editor_mouse_state: EditorPointerState {
mouse_keys: keys, mouse_keys: keys,
..editor_mouse_state ..editor_mouse_state
}, },
@@ -146,7 +191,7 @@ impl InputState {
} }
} }
WindowEvent::MouseWheel { delta, .. } => { WindowEvent::MouseWheel { delta, .. } => {
if self.pointer_locked() || !self.in_viewport(self.pointer_position) { if self.pointer_locked() || self.ui_captures(self.pointer_position) {
let input = match delta { let input = match delta {
MouseScrollDelta::LineDelta(x, y) => InputEvent::pointer().scrolled_lines(f64::from(*x), f64::from(*y)), MouseScrollDelta::LineDelta(x, y) => InputEvent::pointer().scrolled_lines(f64::from(*x), f64::from(*y)),
MouseScrollDelta::PixelDelta(position) => InputEvent::pointer().scrolled_pixels(position.x, position.y), MouseScrollDelta::PixelDelta(position) => InputEvent::pointer().scrolled_pixels(position.x, position.y),
@@ -163,12 +208,12 @@ impl InputState {
let scroll_delta = ScrollDelta::new(-x * SCROLL_SPEED_X, -y * SCROLL_SPEED_Y, 0.); let scroll_delta = ScrollDelta::new(-x * SCROLL_SPEED_X, -y * SCROLL_SPEED_Y, 0.);
editor_callback(InputMessage::WheelScroll { editor_callback(InputMessage::WheelScroll {
editor_mouse_state: PointerState { scroll_delta, ..self.pointer_state() }, editor_mouse_state: EditorPointerState { scroll_delta, ..self.pointer_state() },
modifier_keys: self.modifier_keys(), modifier_keys: self.modifier_keys(),
}); });
} }
WindowEvent::PinchGesture { delta, .. } => { WindowEvent::PinchGesture { delta, .. } => {
if self.pointer_locked() || !self.in_viewport(self.pointer_position) || !delta.is_normal() { if self.pointer_locked() || self.ui_captures(self.pointer_position) || !delta.is_normal() {
ui_callback(InputEvent::pointer().zoomed(*delta).modifiers(self.modifiers).build()); ui_callback(InputEvent::pointer().zoomed(*delta).modifiers(self.modifiers).build());
return; return;
} }
@@ -176,7 +221,7 @@ impl InputState {
// TODO: This is a temporary solution to handle pinch gestures, we should handle pinch gestures editor-side instead. // TODO: This is a temporary solution to handle pinch gestures, we should handle pinch gestures editor-side instead.
let scroll_delta = ScrollDelta::new(0., -delta * PINCH_ZOOM_SPEED, 0.); let scroll_delta = ScrollDelta::new(0., -delta * PINCH_ZOOM_SPEED, 0.);
editor_callback(InputMessage::WheelScroll { editor_callback(InputMessage::WheelScroll {
editor_mouse_state: PointerState { scroll_delta, ..self.pointer_state() }, editor_mouse_state: EditorPointerState { scroll_delta, ..self.pointer_state() },
modifier_keys: self.modifier_keys() | ModifierKeys::CONTROL, modifier_keys: self.modifier_keys() | ModifierKeys::CONTROL,
}); });
} }
@@ -192,21 +237,32 @@ impl InputState {
self.viewport_info.as_ref().map_or(1., |info| info.scale) self.viewport_info.as_ref().map_or(1., |info| info.scale)
} }
fn in_viewport(&self, position: PhysicalPosition<f64>) -> bool { fn ui_captures(&self, position: PhysicalPosition<f64>) -> bool {
self.viewport_info.as_ref().is_some_and(|info| info.contains(position)) !self.direct_input || !self.viewport_info.as_ref().is_some_and(|info| info.contains(position))
} }
fn pointer_state(&self) -> PointerState { fn route(&self, position: PhysicalPosition<f64>) -> Route {
PointerState { if self.ui_captures(position) { Route::Ui } else { Route::Editor }
}
fn pointer_keys(&self) -> MouseKeys {
match self.pointer_state {
PointerState::Hover { .. } => MouseKeys::empty(),
PointerState::Stroke { keys, .. } | PointerState::Locked { keys, .. } => keys,
}
}
fn pointer_state(&self) -> EditorPointerState {
EditorPointerState {
editor_position: (self.pointer_position.x / self.scale(), self.pointer_position.y / self.scale()).into(), editor_position: (self.pointer_position.x / self.scale(), self.pointer_position.y / self.scale()).into(),
mouse_keys: self.pointer_keys, mouse_keys: self.pointer_keys(),
time: Some(self.start.elapsed().as_secs_f64() * 1000.), time: Some(self.start.elapsed().as_secs_f64() * 1000.),
..Default::default() ..Default::default()
} }
} }
fn tablet_pointer_state(&self, kind: &TabletToolKind, data: &TabletToolData) -> PointerState { fn tablet_pointer_state(&self, kind: &TabletToolKind, data: &TabletToolData) -> EditorPointerState {
PointerState { EditorPointerState {
pressure: data.force.map(|force| force.normalized(None)), pressure: data.force.map(|force| force.normalized(None)),
tilt: data.clone().tilt().map(|tilt| (f64::from(tilt.x), f64::from(tilt.y)).into()), tilt: data.clone().tilt().map(|tilt| (f64::from(tilt.x), f64::from(tilt.y)).into()),
twist: data.twist.map(f64::from), twist: data.twist.map(f64::from),
@@ -226,6 +282,19 @@ impl InputState {
} }
} }
#[derive(Clone, Copy)]
enum PointerState {
Hover { route: Route },
Stroke { route: Route, keys: MouseKeys },
Locked { route: Route, keys: MouseKeys, position: PhysicalPosition<f64> },
}
#[derive(Clone, Copy, PartialEq)]
enum Route {
Ui,
Editor,
}
struct ViewportInfo { struct ViewportInfo {
x: f64, x: f64,
y: f64, y: f64,

View File

@@ -122,6 +122,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
FrontendMessage::WindowPointerLock => { FrontendMessage::WindowPointerLock => {
dispatcher.respond(DesktopFrontendMessage::PointerLock); dispatcher.respond(DesktopFrontendMessage::PointerLock);
} }
FrontendMessage::WindowUpdateDirectInput { enabled } => {
dispatcher.respond(DesktopFrontendMessage::WindowUpdateDirectInput { enabled });
}
FrontendMessage::WindowClose => { FrontendMessage::WindowClose => {
dispatcher.respond(DesktopFrontendMessage::WindowClose); dispatcher.respond(DesktopFrontendMessage::WindowClose);
} }

View File

@@ -5,7 +5,7 @@ pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage;
pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState}; pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState};
pub use graphite_editor::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; pub use graphite_editor::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys};
pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState as PointerState, EditorPosition as Position, MouseKeys, ScrollDelta}; pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ScrollDelta};
pub use graphite_editor::messages::prelude::DocumentId; pub use graphite_editor::messages::prelude::DocumentId;
pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage; pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage;
pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences; pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences;
@@ -40,6 +40,9 @@ pub enum DesktopFrontendMessage {
UpdateUIScale { UpdateUIScale {
scale: f64, scale: f64,
}, },
WindowUpdateDirectInput {
enabled: bool,
},
UpdateOverlays(vello::Scene), UpdateOverlays(vello::Scene),
PersistenceWriteDocument { PersistenceWriteDocument {
id: DocumentId, id: DocumentId,

View File

@@ -5,6 +5,7 @@ use crate::messages::prelude::*;
pub enum AppWindowMessage { pub enum AppWindowMessage {
PointerLock, PointerLock,
PointerLockMove { x: f64, y: f64 }, PointerLockMove { x: f64, y: f64 },
DirectInput { enabled: bool },
Restart, Restart,
Close, Close,
Minimize, Minimize,

View File

@@ -17,6 +17,12 @@ impl MessageHandler<AppWindowMessage, ()> for AppWindowMessageHandler {
AppWindowMessage::PointerLockMove { x, y } => { AppWindowMessage::PointerLockMove { x, y } => {
responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) }); responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) });
} }
AppWindowMessage::DirectInput { enabled } => {
#[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowUpdateDirectInput { enabled });
#[cfg(target_family = "wasm")]
let _ = enabled;
}
AppWindowMessage::Close => { AppWindowMessage::Close => {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
responses.add(FrontendMessage::WindowClose); responses.add(FrontendMessage::WindowClose);

View File

@@ -350,6 +350,10 @@ pub enum FrontendMessage {
position: (f64, f64), position: (f64, f64),
}, },
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
WindowUpdateDirectInput {
enabled: bool,
},
#[cfg(not(target_family = "wasm"))]
WindowClose, WindowClose,
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
WindowMinimize, WindowMinimize,

View File

@@ -17,10 +17,11 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, afterUpdate, createEventDispatcher, tick } from "svelte"; import { getContext, onMount, onDestroy, afterUpdate, createEventDispatcher, tick } from "svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import { onFloatingMenuOpenChange } from "/src/utility-functions/input";
import { browserVersion } from "/src/utility-functions/platform"; import { browserVersion } from "/src/utility-functions/platform";
import type { MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
const BUTTON_LEFT = 0; const BUTTON_LEFT = 0;
const POINTER_STRAY_DISTANCE = 100; const POINTER_STRAY_DISTANCE = 100;
@@ -42,6 +43,9 @@
export let escapeCloses = true; export let escapeCloses = true;
export let strayCloses = true; export let strayCloses = true;
const editor = getContext<EditorWrapper>("editor");
const menuId = String(Math.random()).substring(2);
let tail: HTMLDivElement | undefined; let tail: HTMLDivElement | undefined;
let self: HTMLDivElement | undefined; let self: HTMLDivElement | undefined;
let floatingMenuContainer: HTMLDivElement | undefined; let floatingMenuContainer: HTMLDivElement | undefined;
@@ -65,6 +69,8 @@
$: watchOpenChange(open); $: watchOpenChange(open);
$: onFloatingMenuOpenChange(menuId, open && type !== "Tooltip" && type !== "Cursor", editor);
$: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`; $: minWidthStyleValue = measuringOngoing ? "0" : `${Math.max(minWidth, minWidthParentWidth)}px`;
$: displayTail = open && type === "Popover"; $: displayTail = open && type === "Popover";
$: displayContainer = open || measuringOngoing; $: displayContainer = open || measuringOngoing;
@@ -157,6 +163,7 @@
}); });
onDestroy(() => { onDestroy(() => {
onFloatingMenuOpenChange(menuId, false, editor);
containerResizeObserver.disconnect(); containerResizeObserver.disconnect();
dialogResizeObserver?.disconnect(); dialogResizeObserver?.disconnect();
window.removeEventListener("pointermove", pointerMoveHandler); window.removeEventListener("pointermove", pointerMoveHandler);

View File

@@ -15,6 +15,7 @@
import type { MessageBody } from "/src/subscriptions-router"; import type { MessageBody } from "/src/subscriptions-router";
import { fillChoiceColor, createSRgba8 } from "/src/utility-functions/colors"; import { fillChoiceColor, createSRgba8 } from "/src/utility-functions/colors";
import { pasteFile } from "/src/utility-functions/files"; import { pasteFile } from "/src/utility-functions/files";
import { cleanupInputField } from "/src/utility-functions/input";
import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived } from "/src/utility-functions/viewports"; import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived } from "/src/utility-functions/viewports";
@@ -566,6 +567,7 @@
viewportResizeObserver?.disconnect(); viewportResizeObserver?.disconnect();
removeUpdatePixelRatio?.(); removeUpdatePixelRatio?.();
addedFontFaces.forEach((face) => window.document.fonts.delete(face)); addedFontFaces.forEach((face) => window.document.fonts.delete(face));
cleanupInputField(editor);
subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork"); subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork");
subscriptions.unsubscribeFrontendMessage("UpdateEyedropperSamplingState"); subscriptions.unsubscribeFrontendMessage("UpdateEyedropperSamplingState");

View File

@@ -19,6 +19,7 @@ import {
onContextMenu, onContextMenu,
onPaste, onPaste,
onPointerLockChange, onPointerLockChange,
updateDirectInput,
} from "/src/utility-functions/input"; } from "/src/utility-functions/input";
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -42,7 +43,7 @@ const listeners: Listener[] = [
{ target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) }, { target: window, eventName: "mousedown", action: (e: MouseEvent) => onMouseDown(e) },
{ target: window, eventName: "mouseup", action: (e: MouseEvent) => editorWrapper && onPotentialDoubleClick(e, editorWrapper) }, { target: window, eventName: "mouseup", action: (e: MouseEvent) => editorWrapper && onPotentialDoubleClick(e, editorWrapper) },
{ target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } }, { target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } },
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) }, { target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => editorWrapper && onModifyInputField(e, editorWrapper) },
{ target: window, eventName: "focusout", action: () => onFocusOut() }, { target: window, eventName: "focusout", action: () => onFocusOut() },
{ target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) },
{ target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() },
@@ -79,6 +80,8 @@ export function createInputManager(subscriptions: SubscriptionsRouter, editor: E
// Add event bindings for the lifetime of the application // Add event bindings for the lifetime of the application
listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options)); listeners.forEach(({ target, eventName, action, options }) => target.addEventListener(eventName, action, options));
updateDirectInput(editor);
// Focus the app container // Focus the app container
const app = window.document.querySelector("[data-app-container]"); const app = window.document.querySelector("[data-app-container]");
if (app instanceof HTMLElement) app.focus(); if (app instanceof HTMLElement) app.focus();

View File

@@ -20,6 +20,7 @@ let canvasFocused = true;
let inPointerLock = false; let inPointerLock = false;
let lastShakeTime = 0; let lastShakeTime = 0;
const shakeSamples: { x: number; y: number; time: number }[] = []; const shakeSamples: { x: number; y: number; time: number }[] = [];
const openFloatingMenus = new Set<string>();
// Keyboard events // Keyboard events
@@ -243,8 +244,14 @@ export function onWheelScroll(e: WheelEvent, editor: EditorWrapper) {
// Receives a custom event dispatched when the user begins interactively editing with the text tool. // Receives a custom event dispatched when the user begins interactively editing with the text tool.
// We keep a copy of the text input element to check against when it's active for text entry. // We keep a copy of the text input element to check against when it's active for text entry.
export function onModifyInputField(e: CustomEvent) { export function onModifyInputField(e: CustomEvent, editor: EditorWrapper) {
textToolInteractiveInputElement = e.detail; textToolInteractiveInputElement = e.detail;
updateDirectInput(editor);
}
export function cleanupInputField(editor: EditorWrapper) {
textToolInteractiveInputElement = undefined;
updateDirectInput(editor);
} }
// Window events // Window events
@@ -387,3 +394,20 @@ function potentiallyRestoreCanvasFocus(e: Event) {
app?.focus(); app?.focus();
} }
} }
// Direct input (desktop only)
// Desktop may route viewport pointer input directly to the editor if no floating menus are open
export function updateDirectInput(editor: EditorWrapper) {
if (import.meta.env.MODE !== "native") return;
const uiCapturesViewport = openFloatingMenus.size > 0 || textToolInteractiveInputElement !== undefined;
editor.appWindowDirectInput(!uiCapturesViewport);
}
export function onFloatingMenuOpenChange(menuId: string, open: boolean, editor: EditorWrapper) {
if (open) openFloatingMenus.add(menuId);
else openFloatingMenus.delete(menuId);
updateDirectInput(editor);
}

View File

@@ -79,6 +79,10 @@ mod editor_commands {
} }
} }
fn app_window_direct_input(enabled: bool) -> Message {
AppWindowMessage::DirectInput { enabled }.into()
}
/// Start Pointer Lock /// Start Pointer Lock
fn app_window_pointer_lock() -> Message { fn app_window_pointer_lock() -> Message {
AppWindowMessage::PointerLock.into() AppWindowMessage::PointerLock.into()