From b36a6b792b860f6778155d9c51a301ec5efe0033 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Wed, 12 Jun 2024 11:28:01 +0200 Subject: [PATCH] web: Implement pasting text using Clipboard API Ruffle does not have direct clipboard access on web, so the current paste implementation from the context menu does not work. This patch intercepts the paste context menu callback, and uses the Clipboard API to ask the browser for the clipboard and update it before calling the callback. When the Clipboard API is not available or the user denies clipboard permission, a modal informing the user about cut, copy, paste shortcuts is displayed. --- web/Cargo.toml | 2 +- web/src/lib.rs | 82 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index 4b8291b10..0f060f3a9 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -74,7 +74,7 @@ features = [ "EventTarget", "GainNode", "Headers", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", "Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", "ReadableStream", "RequestCredentials", - "Url", + "Url", "Clipboard", ] [package.metadata.cargo-machete] diff --git a/web/src/lib.rs b/web/src/lib.rs index d1fdd7b0c..a56e35449 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -17,6 +17,7 @@ use external_interface::{external_to_js_value, js_to_external_value}; use input::{web_key_to_codepoint, web_to_ruffle_key_code, web_to_ruffle_text_control}; use js_sys::{Error as JsError, Uint8Array}; use ruffle_core::context::UpdateContext; +use ruffle_core::context_menu::ContextMenuCallback; use ruffle_core::events::{MouseButton, MouseWheelDelta, TextControlCode}; use ruffle_core::tag_utils::SwfMovie; use ruffle_core::{Player, PlayerEvent, StaticCallstack, ViewportDimensions}; @@ -214,7 +215,7 @@ impl RuffleHandle { /// /// `parameters` are *extra* parameters to set on the LoaderInfo - /// parameters from `movie_url` query parameters will be automatically added. - pub fn stream_from(&mut self, movie_url: String, parameters: JsValue) -> Result<(), JsValue> { + pub fn stream_from(&self, movie_url: String, parameters: JsValue) -> Result<(), JsValue> { let _ = self.with_core_mut(|core| { let parameters_to_load = parse_movie_parameters(¶meters); @@ -232,7 +233,7 @@ impl RuffleHandle { /// /// This method should only be called once per player. pub fn load_data( - &mut self, + &self, swf_data: Uint8Array, parameters: JsValue, swf_name: String, @@ -268,19 +269,19 @@ impl RuffleHandle { Ok(()) } - pub fn play(&mut self) { + pub fn play(&self) { let _ = self.with_core_mut(|core| { core.set_is_playing(true); }); } - pub fn pause(&mut self) { + pub fn pause(&self) { let _ = self.with_core_mut(|core| { core.set_is_playing(false); }); } - pub fn is_playing(&mut self) -> bool { + pub fn is_playing(&self) -> bool { self.with_core(|core| core.is_playing()).unwrap_or_default() } @@ -288,7 +289,7 @@ impl RuffleHandle { self.with_core(|core| core.volume()).unwrap_or_default() } - pub fn set_volume(&mut self, value: f32) { + pub fn set_volume(&self, value: f32) { let _ = self.with_core_mut(|core| core.set_volume(value)); } @@ -303,7 +304,7 @@ impl RuffleHandle { } // after the context menu is closed, remember to call `clear_custom_menu_items`! - pub fn prepare_context_menu(&mut self) -> JsValue { + pub fn prepare_context_menu(&self) -> JsValue { self.with_core_mut(|core| { let info = core.prepare_context_menu(); serde_wasm_bindgen::to_value(&info).unwrap_or(JsValue::UNDEFINED) @@ -311,19 +312,76 @@ impl RuffleHandle { .unwrap_or(JsValue::UNDEFINED) } - pub fn run_context_menu_callback(&mut self, index: usize) { - let _ = self.with_core_mut(|core| core.run_context_menu_callback(index)); + pub async fn run_context_menu_callback(&self, index: usize) { + let is_paste = self + .with_core_mut(|core| { + let is_paste = core.mutate_with_update_context(|context| { + matches!( + context + .current_context_menu + .as_ref() + .map(|menu| menu.callback(index)), + Some(ContextMenuCallback::TextControl { + code: TextControlCode::Paste, + .. + }) + ) + }); + if !is_paste { + core.run_context_menu_callback(index) + } + is_paste + }) + .unwrap_or_default(); + + // When the user selects paste, we need to use the Clipboard API which + // requests the clipboard asynchronously, so that the browser can ask for permission. + if is_paste { + self.run_context_menu_callback_paste(index).await; + } } - pub fn set_fullscreen(&mut self, is_fullscreen: bool) { + async fn run_context_menu_callback_paste(&self, index: usize) { + let window = web_sys::window().expect("Missing window"); + let Some(clipboard) = window.navigator().clipboard() else { + tracing::warn!("Clipboard unsupported"); + let _ = self.with_instance(|inst| inst.js_player.display_clipboard_modal(false)); + return; + }; + + let promise = clipboard.read_text(); + tracing::debug!("Requested text from clipboard"); + let clipboard = wasm_bindgen_futures::JsFuture::from(promise) + .await + .ok() + .and_then(|value| value.as_string()); + let Some(clipboard) = clipboard else { + tracing::warn!("Clipboard permission denied"); + let _ = self.with_instance(|inst| inst.js_player.display_clipboard_modal(true)); + return; + }; + + if !clipboard.is_empty() { + let _ = self.with_core_mut(|core| { + core.mutate_with_update_context(|context| { + context.ui.set_clipboard_content(clipboard); + }); + core.run_context_menu_callback(index); + }); + } else { + tracing::info!("Clipboard was empty"); + } + } + + pub fn set_fullscreen(&self, is_fullscreen: bool) { let _ = self.with_core_mut(|core| core.set_fullscreen(is_fullscreen)); } - pub fn clear_custom_menu_items(&mut self) { + pub fn clear_custom_menu_items(&self) { let _ = self.with_core_mut(Player::clear_custom_menu_items); } - pub fn destroy(&mut self) { + pub fn destroy(&self) { // Remove instance from the active list. let _ = self.remove_instance(); // Instance is dropped at this point.