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 7f0d55182291..4401a050c61f 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -1,4 +1,4 @@ -use crate::events::{KeyCode, PlayerEvent}; +use crate::events::{KeyCode, PlayerEvent, TextControlCode}; use fluent_templates::loader::langid; pub use fluent_templates::LanguageIdentifier; use std::borrow::Cow; @@ -15,6 +15,9 @@ pub trait UiBackend { /// Changes the mouse cursor image. fn set_mouse_cursor(&mut self, cursor: MouseCursor); + /// Get the clipboard content + fn clipboard_content(&mut self) -> String; + /// Sets the clipboard to the given content. fn set_clipboard_content(&mut self, content: String); @@ -63,6 +66,7 @@ pub struct InputManager { keys_down: HashSet, last_key: KeyCode, last_char: Option, + last_text_control: Option, } impl InputManager { @@ -71,6 +75,7 @@ impl InputManager { keys_down: HashSet::new(), last_key: KeyCode::Unknown, last_char: None, + last_text_control: None, } } @@ -97,6 +102,10 @@ impl InputManager { PlayerEvent::KeyUp { key_code, key_char } => { self.last_char = key_char; self.remove_key(key_code); + self.last_text_control = None; + } + PlayerEvent::TextControl { code } => { + self.last_text_control = Some(code); } PlayerEvent::MouseDown { button, .. } => self.add_key(button.into()), PlayerEvent::MouseUp { button, .. } => self.remove_key(button.into()), @@ -116,6 +125,10 @@ impl InputManager { self.last_char } + pub fn last_text_control(&self) -> Option { + self.last_text_control + } + pub fn is_mouse_down(&self) -> bool { self.is_key_down(KeyCode::MouseLeft) } @@ -145,6 +158,10 @@ impl UiBackend for NullUiBackend { fn set_mouse_cursor(&mut self, _cursor: MouseCursor) {} + fn clipboard_content(&mut self) -> String { + "".into() + } + fn set_clipboard_content(&mut self, _content: String) {} fn set_fullscreen(&mut self, _is_full: bool) -> Result<(), FullscreenError> { diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index a5c0fc70a07d..380db56b8be9 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -17,7 +17,7 @@ use crate::display_object::interactive::{ }; use crate::display_object::{DisplayObjectBase, DisplayObjectPtr, TDisplayObject}; use crate::drawing::Drawing; -use crate::events::{ButtonKeyCode, ClipEvent, ClipEventResult, KeyCode}; +use crate::events::{ClipEvent, ClipEventResult, TextControlCode}; use crate::font::{round_down_to_pixel, Glyph, TextRenderSettings}; use crate::html::{BoxBounds, FormatSpans, LayoutBox, LayoutContent, LayoutMetrics, TextFormat}; use crate::prelude::*; @@ -1174,15 +1174,143 @@ impl<'gc> EditText<'gc> { None } - pub fn text_input(self, character: char, context: &mut UpdateContext<'_, 'gc>) { - if self.0.read().flags.contains(EditTextFlag::READ_ONLY) { + /// The number of characters that currently can be inserted, considering `TextField.maxChars` + /// constraint, current text length, and current text selection length. + fn available_chars(self) -> usize { + let read = self.0.read(); + let max_chars = read.max_chars; + if max_chars == 0 { + usize::MAX + } else { + let text_len = read.text_spans.text().len() as i32; + let selection_len = if let Some(selection) = self.selection() { + (selection.end() - selection.start()) as i32 + } else { + 0 + }; + 0.max(max_chars.max(0) - (text_len - selection_len)) as usize + } + } + + pub fn text_control_input( + self, + control_code: TextControlCode, + context: &mut UpdateContext<'_, 'gc>, + ) { + if !self.is_editable() && control_code.is_edit_input() { return; } if let Some(selection) = self.selection() { let mut changed = false; - match character as u8 { - 8 | 127 if !selection.is_caret() => { + let is_selectable = self.is_selectable(); + match control_code { + TextControlCode::MoveLeft => { + let new_pos = if selection.is_caret() && selection.to > 0 { + string_utils::prev_char_boundary(&self.text(), selection.to) + } else { + selection.start() + }; + self.set_selection( + Some(TextSelection::for_position(new_pos)), + context.gc_context, + ); + } + TextControlCode::MoveRight => { + let new_pos = if selection.is_caret() && selection.to < self.text().len() { + string_utils::next_char_boundary(&self.text(), selection.to) + } else { + selection.end() + }; + self.set_selection( + Some(TextSelection::for_position(new_pos)), + context.gc_context, + ); + } + TextControlCode::SelectLeft => { + if is_selectable && selection.to > 0 { + let new_pos = string_utils::prev_char_boundary(&self.text(), selection.to); + self.set_selection( + Some(TextSelection::for_range(selection.from, new_pos)), + context.gc_context, + ); + } + } + TextControlCode::SelectRight => { + if is_selectable && selection.to < self.text().len() { + let new_pos = string_utils::next_char_boundary(&self.text(), selection.to); + self.set_selection( + Some(TextSelection::for_range(selection.from, new_pos)), + context.gc_context, + ) + } + } + TextControlCode::SelectAll => { + if is_selectable { + self.set_selection( + Some(TextSelection::for_range(0, self.text().len())), + context.gc_context, + ); + } + } + TextControlCode::Copy => { + if !selection.is_caret() { + let text = &self.text()[selection.start()..selection.end()]; + context.ui.set_clipboard_content(text.to_string()); + } + } + TextControlCode::Paste => { + let text = &context.ui.clipboard_content(); + // TODO: To match Flash Player, we should truncate pasted text that is longer than max_chars + // instead of canceling the paste action entirely + if text.len() <= self.available_chars() { + self.replace_text( + selection.start(), + selection.end(), + &WString::from_utf8(text), + context, + ); + let new_pos = selection.start() + text.len(); + if is_selectable { + self.set_selection( + Some(TextSelection::for_position(new_pos)), + context.gc_context, + ); + } else { + self.set_selection( + Some(TextSelection::for_position(self.text().len())), + context.gc_context, + ); + } + changed = true; + } + } + TextControlCode::Cut => { + if !selection.is_caret() { + let text = &self.text()[selection.start()..selection.end()]; + context.ui.set_clipboard_content(text.to_string()); + + self.replace_text( + selection.start(), + selection.end(), + WStr::empty(), + context, + ); + if is_selectable { + self.set_selection( + Some(TextSelection::for_position(selection.start())), + context.gc_context, + ); + } else { + self.set_selection( + Some(TextSelection::for_position(self.text().len())), + context.gc_context, + ); + } + changed = true; + } + } + TextControlCode::Backspace | TextControlCode::Delete if !selection.is_caret() => { // Backspace or delete with multiple characters selected self.replace_text(selection.start(), selection.end(), WStr::empty(), context); self.set_selection( @@ -1191,7 +1319,7 @@ impl<'gc> EditText<'gc> { ); changed = true; } - 8 => { + TextControlCode::Backspace => { // Backspace with caret if selection.start() > 0 { // Delete previous character @@ -1205,7 +1333,7 @@ impl<'gc> EditText<'gc> { changed = true; } } - 127 => { + TextControlCode::Delete => { // Delete with caret if selection.end() < self.text_length() { // Delete next character @@ -1216,27 +1344,39 @@ impl<'gc> EditText<'gc> { changed = true; } } + _ => {} + } + if changed { + let mut activation = Avm1Activation::from_nothing( + context.reborrow(), + ActivationIdentifier::root("[Propagate Text Binding]"), + self.into(), + ); + self.propagate_text_binding(&mut activation); + self.on_changed(&mut activation); + } + } + } + + pub fn text_input(self, character: char, context: &mut UpdateContext<'_, 'gc>) { + if self.0.read().flags.contains(EditTextFlag::READ_ONLY) { + return; + } + + if let Some(selection) = self.selection() { + let mut changed = false; + match character as u8 { code if !(code as char).is_control() => { - let can_insert = { - let read = self.0.read(); - let max_chars = read.max_chars; - if max_chars == 0 { - true - } else { - let text_len = read.text_spans.text().len(); - text_len < max_chars.max(0) as usize - } - }; - if can_insert { + if self.available_chars() > 0 { self.replace_text( selection.start(), selection.end(), &WString::from_char(character), context, ); - let new_start = selection.start() + character.len_utf8(); + let new_pos = selection.start() + character.len_utf8(); self.set_selection( - Some(TextSelection::for_position(new_start)), + Some(TextSelection::for_position(new_pos)), context.gc_context, ); changed = true; @@ -1257,61 +1397,6 @@ impl<'gc> EditText<'gc> { } } - /// Listens for keyboard text control commands. - /// - /// TODO: Add explicit text control events (#4452). - pub fn handle_text_control_event( - self, - context: &mut UpdateContext<'_, 'gc>, - event: ClipEvent, - ) -> ClipEventResult { - if let ClipEvent::KeyPress { key_code } = event { - let mut edit_text = self.0.write(context.gc_context); - let selection = edit_text.selection; - if let Some(mut selection) = selection { - let text = edit_text.text_spans.text(); - let length = text.len(); - match key_code { - ButtonKeyCode::Left => { - if (context.input.is_key_down(KeyCode::Shift) || selection.is_caret()) - && selection.to > 0 - { - selection.to = string_utils::prev_char_boundary(text, selection.to); - if !context.input.is_key_down(KeyCode::Shift) { - selection.from = selection.to; - } - } else if !context.input.is_key_down(KeyCode::Shift) { - selection.to = selection.start(); - selection.from = selection.to; - } - selection.clamp(length); - edit_text.selection = Some(selection); - return ClipEventResult::Handled; - } - ButtonKeyCode::Right => { - if (context.input.is_key_down(KeyCode::Shift) || selection.is_caret()) - && selection.to < length - { - selection.to = string_utils::next_char_boundary(text, selection.to); - if !context.input.is_key_down(KeyCode::Shift) { - selection.from = selection.to; - } - } else if !context.input.is_key_down(KeyCode::Shift) { - selection.to = selection.end(); - selection.from = selection.to; - } - selection.clamp(length); - edit_text.selection = Some(selection); - return ClipEventResult::Handled; - } - _ => (), - } - } - } - - ClipEventResult::NotHandled - } - fn initialize_as_broadcaster(&self, activation: &mut Avm1Activation<'_, 'gc>) { if let Avm1Value::Object(object) = self.object() { activation.context.avm1.broadcaster_functions().initialize( @@ -1955,7 +2040,7 @@ impl TextSelection { self.from.min(self.to) } - /// The "end" part of the range is the smallest (closest to 0) part of this selection range. + /// The "end" part of the range is the largest (farthest from 0) part of this selection range. pub fn end(&self) -> usize { self.from.max(self.to) } diff --git a/core/src/events.rs b/core/src/events.rs index e49c71e53e06..ac0afae4ff13 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -32,6 +32,9 @@ pub enum PlayerEvent { TextInput { codepoint: char, }, + TextControl { + code: TextControlCode, + }, } /// The distance scrolled by the mouse wheel. @@ -330,6 +333,33 @@ impl<'gc> ClipEvent<'gc> { } } +/// Control inputs to a text field +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TextControlCode { + // TODO: Add control codes for Ctrl+Arrows and Home/End keys + MoveLeft, + MoveRight, + SelectLeft, + SelectRight, + SelectAll, + Copy, + Paste, + Cut, + Backspace, + Enter, + Delete, +} + +impl TextControlCode { + /// Indicates whether this is an event that edits the text content + pub fn is_edit_input(self) -> bool { + matches!( + self, + Self::Paste | Self::Cut | Self::Backspace | Self::Enter | Self::Delete + ) + } +} + /// Flash virtual keycode. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, FromPrimitive)] pub enum KeyCode { diff --git a/core/src/player.rs b/core/src/player.rs index 8af3e939c0e7..e35549037952 100644 --- a/core/src/player.rs +++ b/core/src/player.rs @@ -991,16 +991,6 @@ impl Player { if state == ClipEventResult::Handled { key_press_handled = true; break; - } else if let Some(text) = - context.focus_tracker.get().and_then(|o| o.as_edit_text()) - { - // Text fields listen for arrow key presses, etc. - if text.handle_text_control_event(context, button_event) - == ClipEventResult::Handled - { - key_press_handled = true; - break; - } } } } @@ -1066,6 +1056,11 @@ impl Player { text.text_input(codepoint, context); } } + if let PlayerEvent::TextControl { code } = event { + if let Some(text) = context.focus_tracker.get().and_then(|o| o.as_edit_text()) { + text.text_control_input(code, context); + } + } } // Propagate clip events. diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 8adf99ba48d8..01ceb7b45968 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -22,9 +22,10 @@ use isahc::{config::RedirectPolicy, prelude::*, HttpClient}; use rfd::FileDialog; use ruffle_core::backend::audio::AudioBackend; use ruffle_core::backend::navigator::OpenURLMode; +use ruffle_core::events::{KeyCode, TextControlCode}; use ruffle_core::{ - config::Letterbox, events::KeyCode, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, - PlayerEvent, StageDisplayState, StageScaleMode, StaticCallstack, ViewportDimensions, + config::Letterbox, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, PlayerEvent, + StageDisplayState, StageScaleMode, StaticCallstack, ViewportDimensions, }; use ruffle_render::backend::RenderBackend; use ruffle_render::quality::StageQuality; @@ -588,7 +589,13 @@ impl App { let key_char = winit_key_to_char(key, modifiers.shift()); let event = match input.state { ElementState::Pressed => { - PlayerEvent::KeyDown { key_code, key_char } + if let Some(control_code) = + winit_to_ruffle_text_control(key, modifiers) + { + PlayerEvent::TextControl { code: control_code } + } else { + PlayerEvent::KeyDown { key_code, key_char } + } } ElementState::Released => { PlayerEvent::KeyUp { key_code, key_char } @@ -906,6 +913,47 @@ fn winit_key_to_char(key_code: VirtualKeyCode, is_shift_down: bool) -> Option Option { + let shift = modifiers.contains(ModifiersState::SHIFT); + let ctrl_cmd = modifiers.contains(ModifiersState::CTRL) + || (modifiers.contains(ModifiersState::LOGO) && cfg!(target_os = "macos")); + if ctrl_cmd { + match key { + VirtualKeyCode::A => Some(TextControlCode::SelectAll), + VirtualKeyCode::C => Some(TextControlCode::Copy), + VirtualKeyCode::V => Some(TextControlCode::Paste), + VirtualKeyCode::X => Some(TextControlCode::Cut), + _ => None, + } + } else { + match key { + VirtualKeyCode::Back => Some(TextControlCode::Backspace), + VirtualKeyCode::Delete => Some(TextControlCode::Delete), + VirtualKeyCode::Left => { + if shift { + Some(TextControlCode::SelectLeft) + } else { + Some(TextControlCode::MoveLeft) + } + } + VirtualKeyCode::Right => { + if shift { + Some(TextControlCode::SelectRight) + } else { + Some(TextControlCode::MoveRight) + } + } + _ => None, + } + } +} + fn run_timedemo(opt: Opt) -> Result<(), Error> { let path = opt .input_path diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index e653207858ee..9ff9db392f52 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -62,6 +62,10 @@ impl UiBackend for DesktopUiBackend { self.window.set_cursor_icon(icon); } + fn clipboard_content(&mut self) -> String { + self.clipboard.get_text().unwrap_or_default() + } + fn set_clipboard_content(&mut self, content: String) { if let Err(e) = self.clipboard.set_text(content) { error!("Couldn't set clipboard contents: {:?}", e); 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 5b13cf1a8fa1..2ed9cd18a530 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -13,7 +13,7 @@ use ruffle_core::backend::navigator::OpenURLMode; use ruffle_core::compatibility_rules::CompatibilityRules; use ruffle_core::config::{Letterbox, NetworkingAccessMode}; use ruffle_core::context::UpdateContext; -use ruffle_core::events::{KeyCode, MouseButton, MouseWheelDelta}; +use ruffle_core::events::{KeyCode, MouseButton, MouseWheelDelta, TextControlCode}; use ruffle_core::external::{ ExternalInterfaceMethod, ExternalInterfaceProvider, Value as ExternalValue, Value, }; @@ -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,17 +842,35 @@ 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()); + let is_ctrl_cmd = js_event.ctrl_key() || js_event.meta_key(); core.handle_event(PlayerEvent::KeyDown { key_code, key_char }); - if let Some(codepoint) = key_char { + if let Some(control_code) = web_to_ruffle_text_control( + &js_event.key(), + is_ctrl_cmd, + js_event.shift_key(), + ) { + 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(); + } } }); }); @@ -860,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| { @@ -1251,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( @@ -1740,3 +1796,49 @@ fn web_key_to_codepoint(key: &str) -> Option { } } } + +/// Convert a web `KeyboardEvent.key` value to a Ruffle `TextControlCode`, +/// given the states of the modifier keys. Return `None` if there is no match. +/// TODO: Handle Ctrl+Arrows and Home/End keys +pub fn web_to_ruffle_text_control( + key: &str, + ctrl_key: bool, + shift_key: bool, +) -> Option { + let mut chars = key.chars(); + let (c1, c2) = (chars.next(), chars.next()); + if c2.is_none() { + // Single character. + if ctrl_key { + match c1 { + Some('a') => Some(TextControlCode::SelectAll), + Some('c') => Some(TextControlCode::Copy), + Some('v') => Some(TextControlCode::Paste), + Some('x') => Some(TextControlCode::Cut), + _ => None, + } + } else { + None + } + } else { + match key { + "Delete" => Some(TextControlCode::Delete), + "Backspace" => Some(TextControlCode::Backspace), + "ArrowLeft" => { + if shift_key { + Some(TextControlCode::SelectLeft) + } else { + Some(TextControlCode::MoveLeft) + } + } + "ArrowRight" => { + if shift_key { + Some(TextControlCode::SelectRight) + } else { + Some(TextControlCode::MoveRight) + } + } + _ => None, + } + } +} diff --git a/web/src/ui.rs b/web/src/ui.rs index 9d18fcb68e4e..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(), } } @@ -65,7 +67,14 @@ impl UiBackend for WebUiBackend { self.update_mouse_cursor(); } + fn clipboard_content(&mut self) -> 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() { @@ -81,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(); @@ -97,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(); + } } }