web: Support pasting from clipboard

This commit is contained in:
nosamu 2023-05-16 04:27:36 -05:00 committed by Nathan Adams
parent ee956927b7
commit 7dfc36c6fc
7 changed files with 79 additions and 12 deletions

View File

@ -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.

View File

@ -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) {}

View File

@ -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) {

View File

@ -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",
]

View File

@ -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<ContextMenuItem | null> {
const CHECKMARK = String.fromCharCode(0x2713);

View File

@ -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<Closure<dyn FnMut(WheelEvent)>>,
key_down_callback: Option<Closure<dyn FnMut(KeyboardEvent)>>,
key_up_callback: Option<Closure<dyn FnMut(KeyboardEvent)>>,
paste_callback: Option<Closure<dyn FnMut(ClipboardEvent)>>,
unload_callback: Option<Closure<dyn FnMut(Event)>>,
has_focus: bool,
trace_observer: Arc<RefCell<JsValue>>,
@ -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(

View File

@ -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();
}
}
}