From 49cf622b7c0d9239fc60eeff74110540161247e3 Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Tue, 16 May 2023 04:27:36 -0500 Subject: [PATCH] web: Support pasting from clipboard --- .cargo/config.toml | 6 +++ core/src/backend/ui.rs | 2 +- desktop/src/ui.rs | 2 +- web/Cargo.toml | 8 ++-- web/packages/core/src/ruffle-player.ts | 3 ++ web/src/lib.rs | 57 ++++++++++++++++++++++++-- web/src/ui.rs | 13 +++++- 7 files changed, 79 insertions(+), 12 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 24a1916af449..4ef3189b2a02 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,10 +1,16 @@ [target.'cfg(all())'] # NOTE that the web build overrides this setting in package.json via the RUSTFLAGS environment variable rustflags = [ + # We need to specify this flag for all targets because Clippy checks all of our code against all targets + # and our web code does not compile without this flag + "--cfg=web_sys_unstable_apis", + # CLIPPY LINT SETTINGS # This is a workaround to configure lints for the entire workspace, pending the ability to configure this via TOML. # See: https://github.com/rust-lang/cargo/issues/5034 # https://github.com/EmbarkStudios/rust-ecosystem/issues/22#issuecomment-947011395 + # TODO: Move these to the root Cargo.toml once support is merged and stable + # See: https://github.com/rust-lang/cargo/pull/12148 # Clippy nightly often adds new/buggy lints that we want to ignore. # Don't warn about these new lints on stable. diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 7f80a49f442c..4401a050c61f 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -159,7 +159,7 @@ impl UiBackend for NullUiBackend { fn set_mouse_cursor(&mut self, _cursor: MouseCursor) {} fn clipboard_content(&mut self) -> String { - "".to_string() + "".into() } fn set_clipboard_content(&mut self, _content: String) {} diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index d278ccdf60ac..9ff9db392f52 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -63,7 +63,7 @@ impl UiBackend for DesktopUiBackend { } fn clipboard_content(&mut self) -> String { - self.clipboard.get_text().unwrap_or_else(|_| "".to_string()) + self.clipboard.get_text().unwrap_or_default() } fn set_clipboard_content(&mut self, content: String) { diff --git a/web/Cargo.toml b/web/Cargo.toml index 101a0e9482e9..577503b6fa03 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -60,8 +60,8 @@ version = "0.3.63" features = [ "AddEventListenerOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioContext", "AudioDestinationNode", "AudioNode", "AudioParam", "Blob", "BlobPropertyBag", - "ChannelMergerNode", "ChannelSplitterNode", "Element", "Event", "EventTarget", "GainNode", - "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "HtmlInputElement", - "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", "Request", "RequestInit", - "Response", "Storage", "WheelEvent", "Window", + "ChannelMergerNode", "ChannelSplitterNode", "ClipboardEvent", "DataTransfer", "Element", "Event", + "EventTarget", "GainNode", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", + "HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", + "Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", ] diff --git a/web/packages/core/src/ruffle-player.ts b/web/packages/core/src/ruffle-player.ts index d9eb9af604ab..9ad2800ae66e 100644 --- a/web/packages/core/src/ruffle-player.ts +++ b/web/packages/core/src/ruffle-player.ts @@ -1183,6 +1183,9 @@ export class RufflePlayer extends HTMLElement { this.virtualKeyboard.focus({ preventScroll: true }); } } + protected isVirtualKeyboardFocused(): boolean { + return this.shadow.activeElement === this.virtualKeyboard; + } private contextMenuItems(isTouch: boolean): Array { const CHECKMARK = String.fromCharCode(0x2713); diff --git a/web/src/lib.rs b/web/src/lib.rs index 9d735e8864e3..2ed9cd18a530 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -38,8 +38,8 @@ use tracing_wasm::{WASMLayer, WASMLayerConfigBuilder}; use url::Url; use wasm_bindgen::{prelude::*, JsCast, JsValue}; use web_sys::{ - AddEventListenerOptions, Element, Event, EventTarget, HtmlCanvasElement, HtmlElement, - KeyboardEvent, PointerEvent, WheelEvent, Window, + AddEventListenerOptions, ClipboardEvent, Element, Event, EventTarget, HtmlCanvasElement, + HtmlElement, KeyboardEvent, PointerEvent, WheelEvent, Window, }; static RUFFLE_GLOBAL_PANIC: Once = Once::new(); @@ -78,6 +78,7 @@ struct RuffleInstance { mouse_wheel_callback: Option>, key_down_callback: Option>, key_up_callback: Option>, + paste_callback: Option>, unload_callback: Option>, has_focus: bool, trace_observer: Arc>, @@ -119,6 +120,9 @@ extern "C" { #[wasm_bindgen(method, js_name = "openVirtualKeyboard")] fn open_virtual_keyboard(this: &JavascriptPlayer); + + #[wasm_bindgen(method, js_name = "isVirtualKeyboardFocused")] + fn is_virtual_keyboard_focused(this: &JavascriptPlayer) -> bool; } struct JavascriptInterface { @@ -619,6 +623,7 @@ impl Ruffle { mouse_wheel_callback: None, key_down_callback: None, key_up_callback: None, + paste_callback: None, unload_callback: None, timestamp: None, has_focus: false, @@ -837,6 +842,7 @@ impl Ruffle { let key_down_callback = Closure::new(move |js_event: KeyboardEvent| { let _ = ruffle.with_instance(|instance| { if instance.has_focus { + let mut paste_event = false; let _ = instance.with_core_mut(|core| { let key_code = web_to_ruffle_key_code(&js_event.code()); let key_char = web_key_to_codepoint(&js_event.key()); @@ -848,13 +854,23 @@ impl Ruffle { is_ctrl_cmd, js_event.shift_key(), ) { - core.handle_event(PlayerEvent::TextControl { code: control_code }); + paste_event = control_code == TextControlCode::Paste; + // The JS paste event fires separately and the clipboard text is not available until then, + // so we need to wait before handling it + if !paste_event { + core.handle_event(PlayerEvent::TextControl { + code: control_code, + }); + } } else if let Some(codepoint) = key_char { core.handle_event(PlayerEvent::TextInput { codepoint }); } }); - js_event.prevent_default(); + // Don't prevent the JS paste event from firing + if !paste_event { + js_event.prevent_default(); + } } }); }); @@ -867,6 +883,31 @@ impl Ruffle { .warn_on_error(); instance.key_down_callback = Some(key_down_callback); + let paste_callback = Closure::new(move |js_event: ClipboardEvent| { + let _ = ruffle.with_instance(|instance| { + if instance.has_focus { + let _ = instance.with_core_mut(|core| { + let clipboard_content = if let Some(content) = js_event.clipboard_data() + { + content.get_data("text/plain").unwrap_or_default() + } else { + "".into() + }; + core.ui_mut().set_clipboard_content(clipboard_content); + core.handle_event(PlayerEvent::TextControl { + code: TextControlCode::Paste, + }); + }); + js_event.prevent_default(); + } + }); + }); + + window + .add_event_listener_with_callback("paste", paste_callback.as_ref().unchecked_ref()) + .warn_on_error(); + instance.paste_callback = Some(paste_callback); + // Create keyup event handler. let key_up_callback = Closure::new(move |js_event: KeyboardEvent| { let _ = ruffle.with_instance(|instance| { @@ -1258,6 +1299,14 @@ impl Drop for RuffleInstance { ) .warn_on_error(); } + if let Some(paste_callback) = self.paste_callback.take() { + self.window + .remove_event_listener_with_callback( + "paste", + paste_callback.as_ref().unchecked_ref(), + ) + .warn_on_error(); + } if let Some(key_up_callback) = self.key_up_callback.take() { self.window .remove_event_listener_with_callback( diff --git a/web/src/ui.rs b/web/src/ui.rs index cefd420214f5..81b5d345c7e4 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -14,6 +14,7 @@ pub struct WebUiBackend { cursor_visible: bool, cursor: MouseCursor, language: LanguageIdentifier, + clipboard_content: String, } impl WebUiBackend { @@ -29,6 +30,7 @@ impl WebUiBackend { cursor_visible: true, cursor: MouseCursor::Arrow, language, + clipboard_content: "".into(), } } @@ -66,11 +68,13 @@ impl UiBackend for WebUiBackend { } fn clipboard_content(&mut self) -> String { - tracing::warn!("get clipboard not implemented"); - "".to_string() + // On web, clipboard content is not directly accessible due to security restrictions, + // but pasting from the clipboard is supported via the JS `paste` event + self.clipboard_content.to_owned() } fn set_clipboard_content(&mut self, content: String) { + self.clipboard_content = content.to_owned(); // We use `document.execCommand("copy")` as `navigator.clipboard.writeText("string")` // is available only in secure contexts (HTTPS). if let Some(element) = self.canvas.parent_element() { @@ -86,6 +90,7 @@ impl UiBackend for WebUiBackend { .dyn_into() .expect("create_element(\"textarea\") didn't give us a textarea"); + let editing_text = self.js_player.is_virtual_keyboard_focused(); textarea.set_value(&content); let _ = element.append_child(&textarea); textarea.select(); @@ -102,6 +107,10 @@ impl UiBackend for WebUiBackend { } let _ = element.remove_child(&textarea); + if editing_text { + // Return focus to the text area + self.js_player.open_virtual_keyboard(); + } } }