Skip to content

Commit ab5c87f

Browse files
authored
Hide cursor while dragging number inputs in Safari to approximate PointerLock, and disable it on desktop (#3425)
* Make pointerlock conditional and opt out on Safari and desktop * Add Safari workaround
1 parent 8383a3a commit ab5c87f

File tree

4 files changed

+134
-82
lines changed

4 files changed

+134
-82
lines changed

frontend/src/components/Editor.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@
223223
user-select: none;
224224
}
225225
226+
body.cursor-hidden * {
227+
cursor: none !important;
228+
}
229+
226230
// Needed for the viewport hole punch on desktop
227231
html:has(body > .viewport-hole-punch),
228232
body:has(> .viewport-hole-punch) {

frontend/src/components/widgets/inputs/NumberInput.svelte

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { evaluateMathExpression } from "@graphite/../wasm/pkg/graphite_wasm.js";
55
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
66
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages";
7+
import { browserVersion, isDesktop } from "@graphite/utility-functions/platform";
78
89
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
910
import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
@@ -315,10 +316,10 @@
315316
// Remove the text entry cursor from any other selected text field
316317
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
317318
318-
// Don't drag the text value from is input element
319+
// Don't drag the text value from its input element
319320
e.preventDefault();
320321
321-
// Now we need to wait and see if the user follows this up with a mousemove or mouseup.
322+
// Now we need to wait and see if the user follows this up with a mousemove or mouseup...
322323
323324
// For some reason, both events can get fired before their event listeners are removed, so we need to guard against both running.
324325
let alreadyActedGuard = false;
@@ -327,16 +328,22 @@
327328
const onMove = () => {
328329
if (alreadyActedGuard) return;
329330
alreadyActedGuard = true;
331+
330332
isDragging = true;
331333
beginDrag(e);
334+
332335
removeEventListener("pointermove", onMove);
336+
removeEventListener("pointerup", onUp);
333337
};
334338
// If it's a mouseup, we'll begin editing the text field.
335339
const onUp = () => {
336340
if (alreadyActedGuard) return;
337341
alreadyActedGuard = true;
342+
338343
isDragging = false;
339344
self?.focus();
345+
346+
removeEventListener("pointermove", onMove);
340347
removeEventListener("pointerup", onUp);
341348
};
342349
addEventListener("pointermove", onMove);
@@ -348,8 +355,25 @@
348355
const target = e.target || undefined;
349356
if (!(target instanceof HTMLElement)) return;
350357
358+
// Default to using pointer lock except on unsupported platforms (Safari and the native desktop app).
359+
// Even though Safari supposedly supports the PointerLock API, it implements an old (2016) version of the spec that requires an "engagement
360+
// gesture" (<https://www.w3.org/TR/2016/REC-pointerlock-20161027/#glossary>) to initiate pointer lock, which the newer spec doesn't require.
361+
// Because "mousemove" (and similarly, the "pointermove" event we use) is defined as not being a user-initiated "engagement gesture" event,
362+
// Safari never lets us to enter pointer lock while the mouse button is held down and we are awaiting movement to begin dragging the slider.
363+
const isSafari = browserVersion().toLowerCase().includes("safari");
364+
const usePointerLock = !isSafari && !isDesktop();
365+
366+
// On Safari, we use a workaround involving an alternative strategy where we hide the cursor while it's within the web page
367+
// (but we can't hide it when it ventures outside the page), taking advantage of a separate (helpful) Safari bug where it
368+
// keeps reporting deltas to the pointer position even as it pushes up against edges of the screen. Like the PointerLock API,
369+
// this allows infinite movement in each direction, but the downside is that the cursor remains visible outside the page and
370+
// it ends up in that location when the drag ends. It isn't possible to take advantage of the PointerCapture API (yes, that's
371+
// a different API than PointerLock) which is supposed to allow the CSS cursor style (such as `cursor: none`) to persist even
372+
// while dragging it outside the browser window, because Safari has another bug where the cursor icon is unaffected by that API.
373+
if (isSafari) document.body.classList.add("cursor-hidden");
374+
351375
// Enter dragging state
352-
target.requestPointerLock();
376+
if (usePointerLock) target.requestPointerLock();
353377
initialValueBeforeDragging = value;
354378
cumulativeDragDelta = 0;
355379
@@ -359,6 +383,9 @@
359383
// We ignore the first event invocation's `e.movementX` value because it's unreliable.
360384
// In both Chrome and Firefox (tested on Windows 10), the first `e.movementX` value is occasionally a very large number
361385
// (around positive 1000, even if movement was in the negative direction). This seems to happen more often if the movement is rapid.
386+
// TODO: On rarer occasions, it isn't sufficient to ignore just the first event, so this solution is imperfect.
387+
// TODO: Using a counter to ignore more frames helps progressively decrease—but not eliminate—the issue, but it makes drag initiation feel delayed so we don't do that.
388+
// TODO: A better solution will need to discard outlier movement values across multiple frames by basically implementing a time-series data analysis filtering algorithm.
362389
let ignoredFirstMovement = false;
363390
364391
const pointerUp = () => {
@@ -367,24 +394,24 @@
367394
initialValueBeforeDragging = value;
368395
cumulativeDragDelta = 0;
369396
370-
document.exitPointerLock();
371-
372-
// Fallback for Safari in case pointerlockchange never fires
373-
setTimeout(() => {
374-
if (!document.pointerLockElement) pointerLockChange();
375-
}, 0);
397+
if (usePointerLock) document.exitPointerLock();
398+
else pointerLockChange();
376399
};
377400
const pointerMove = (e: PointerEvent) => {
401+
// TODO: Display a fake cursor over the top of the page which wraps around the edges of the editor.
402+
378403
// Abort the drag if right click is down. This works here because a "pointermove" event is fired when right clicking even if the cursor didn't move.
379404
if (e.buttons & BUTTONS_RIGHT) {
380-
document.exitPointerLock();
405+
if (usePointerLock) document.exitPointerLock();
406+
else pointerLockChange();
381407
return;
382408
}
383409
384-
// If no buttons are down, we are stuck in the drag state after having released the mouse, so we should exit.
385-
// For some reason on firefox in wayland the button is -1 and the buttons is 0.
410+
// If no buttons are down, that means we are stuck in the drag state after having released the mouse, so we should exit.
411+
// For some reason on Firefox in Wayland, `e.buttons` can be 0 while `e.button` is -1, but we don't want to exit in that state.
386412
if (e.buttons === 0 && e.button !== -1) {
387-
document.exitPointerLock();
413+
if (usePointerLock) document.exitPointerLock();
414+
else pointerLockChange();
388415
return;
389416
}
390417
@@ -408,7 +435,10 @@
408435
};
409436
const pointerLockChange = () => {
410437
// Do nothing if we just entered, rather than exited, pointer lock.
411-
if (document.pointerLockElement) return;
438+
if (usePointerLock && document.pointerLockElement) return;
439+
440+
// Un-hide the cursor if we're using the Safari workaround.
441+
if (isSafari) document.body.classList.remove("cursor-hidden");
412442
413443
// Reset the value to the initial value if the drag was aborted, or to the current value if it was just confirmed by changing the initial value to the current value.
414444
updateValue(initialValueBeforeDragging);
@@ -418,12 +448,12 @@
418448
// Clean up the event listeners.
419449
removeEventListener("pointerup", pointerUp);
420450
removeEventListener("pointermove", pointerMove);
421-
document.removeEventListener("pointerlockchange", pointerLockChange);
451+
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
422452
};
423453
424454
addEventListener("pointerup", pointerUp);
425455
addEventListener("pointermove", pointerMove);
426-
document.addEventListener("pointerlockchange", pointerLockChange);
456+
if (usePointerLock) document.addEventListener("pointerlockchange", pointerLockChange);
427457
}
428458
429459
// ===============================

frontend/src/utility-functions/platform.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm.js";
2+
13
export function browserVersion(): string {
24
const agent = window.navigator.userAgent;
35
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
@@ -37,6 +39,10 @@ export function operatingSystem(): OperatingSystem {
3739
return osTable[userAgentOS || "Unknown"];
3840
}
3941

42+
export function isDesktop(): boolean {
43+
return isPlatformNative();
44+
}
45+
4046
export function isEventSupported(eventName: string) {
4147
const onEventName = `on${eventName}`;
4248

frontend/wasm/src/editor_api.rs

Lines changed: 78 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -55,72 +55,15 @@ pub fn wasm_memory() -> JsValue {
5555
wasm_bindgen::memory()
5656
}
5757

58-
fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
59-
let window = match window() {
60-
Some(window) => window,
61-
None => {
62-
error!("Cannot render canvas: window object not found");
63-
return;
64-
}
65-
};
66-
let document = window.document().expect("window should have a document");
67-
let window_obj = Object::from(window);
68-
let image_canvases_key = JsValue::from_str("imageCanvases");
69-
70-
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
71-
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
72-
_ => {
73-
let new_obj = Object::new();
74-
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
75-
error!("Failed to create and set imageCanvases object on window");
76-
return;
77-
}
78-
new_obj.into()
79-
}
80-
};
81-
let canvases_obj = Object::from(canvases_obj);
82-
83-
for (placeholder_id, image) in image_data.iter() {
84-
let canvas_name = placeholder_id.to_string();
85-
let js_key = JsValue::from_str(&canvas_name);
86-
87-
if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
88-
continue;
89-
}
90-
91-
let canvas: HtmlCanvasElement = document
92-
.create_element("canvas")
93-
.expect("Failed to create canvas element")
94-
.dyn_into::<HtmlCanvasElement>()
95-
.expect("Failed to cast element to HtmlCanvasElement");
96-
97-
canvas.set_width(image.width);
98-
canvas.set_height(image.height);
99-
100-
let context: CanvasRenderingContext2d = canvas
101-
.get_context("2d")
102-
.expect("Failed to get 2d context")
103-
.expect("2d context was not found")
104-
.dyn_into::<CanvasRenderingContext2d>()
105-
.expect("Failed to cast context to CanvasRenderingContext2d");
106-
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
107-
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
108-
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
109-
Ok(image_data_obj) => {
110-
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
111-
error!("Failed to put image data on canvas for id: {placeholder_id}");
112-
}
113-
}
114-
Err(e) => {
115-
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
116-
}
117-
}
118-
119-
let js_value = JsValue::from(canvas);
120-
121-
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
122-
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
123-
}
58+
#[wasm_bindgen(js_name = isPlatformNative)]
59+
pub fn is_platform_native() -> bool {
60+
#[cfg(feature = "native")]
61+
{
62+
true
63+
}
64+
#[cfg(not(feature = "native"))]
65+
{
66+
false
12467
}
12568
}
12669

@@ -1061,3 +1004,72 @@ fn auto_save_all_documents() {
10611004
handle.dispatch(PortfolioMessage::AutoSaveAllDocuments);
10621005
});
10631006
}
1007+
1008+
fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
1009+
let window = match window() {
1010+
Some(window) => window,
1011+
None => {
1012+
error!("Cannot render canvas: window object not found");
1013+
return;
1014+
}
1015+
};
1016+
let document = window.document().expect("window should have a document");
1017+
let window_obj = Object::from(window);
1018+
let image_canvases_key = JsValue::from_str("imageCanvases");
1019+
1020+
let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
1021+
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
1022+
_ => {
1023+
let new_obj = Object::new();
1024+
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
1025+
error!("Failed to create and set imageCanvases object on window");
1026+
return;
1027+
}
1028+
new_obj.into()
1029+
}
1030+
};
1031+
let canvases_obj = Object::from(canvases_obj);
1032+
1033+
for (placeholder_id, image) in image_data.iter() {
1034+
let canvas_name = placeholder_id.to_string();
1035+
let js_key = JsValue::from_str(&canvas_name);
1036+
1037+
if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
1038+
continue;
1039+
}
1040+
1041+
let canvas: HtmlCanvasElement = document
1042+
.create_element("canvas")
1043+
.expect("Failed to create canvas element")
1044+
.dyn_into::<HtmlCanvasElement>()
1045+
.expect("Failed to cast element to HtmlCanvasElement");
1046+
1047+
canvas.set_width(image.width);
1048+
canvas.set_height(image.height);
1049+
1050+
let context: CanvasRenderingContext2d = canvas
1051+
.get_context("2d")
1052+
.expect("Failed to get 2d context")
1053+
.expect("2d context was not found")
1054+
.dyn_into::<CanvasRenderingContext2d>()
1055+
.expect("Failed to cast context to CanvasRenderingContext2d");
1056+
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
1057+
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
1058+
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
1059+
Ok(image_data_obj) => {
1060+
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
1061+
error!("Failed to put image data on canvas for id: {placeholder_id}");
1062+
}
1063+
}
1064+
Err(e) => {
1065+
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
1066+
}
1067+
}
1068+
1069+
let js_value = JsValue::from(canvas);
1070+
1071+
if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
1072+
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
1073+
}
1074+
}
1075+
}

0 commit comments

Comments
 (0)