script: Properly fire input events for clipboard use in input elements (#37100)

When using the clipboard to paste or modify the contents of an input
element the specification says says[^1] to

> Queue tasks to fire any events that should fire due to the
> modification, see § 5.3 Integration with other scripts and events for
> details.

This change does that, by turning `handle_text_clipboard_action` into
`TextInput::handle_clipboard_event` and having the caller responsible
for executing events. In addition, when content is changed, the node is
dirtied, forcing a relayout.

[^1]: https://www.w3.org/TR/clipboard-apis/#paste-action

Testing: This is difficult to test because we do not have test harness
support for input events currently. There is a manual test for this in
the linked bug which is now passing.
Fixes: #37074.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Martin Robinson 2025-08-11 11:23:58 +02:00 committed by GitHub
parent 6ddcce4889
commit 9d4004135b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 154 additions and 89 deletions

View file

@ -92,8 +92,7 @@ use crate::textinput::KeyReaction::{
};
use crate::textinput::Lines::Single;
use crate::textinput::{
Direction, SelectionDirection, TextInput, UTF8Bytes, UTF16CodeUnits,
handle_text_clipboard_action,
ClipboardEventReaction, Direction, SelectionDirection, TextInput, UTF8Bytes, UTF16CodeUnits,
};
const DEFAULT_SUBMIT_VALUE: &str = "Submit";
@ -3160,8 +3159,26 @@ impl VirtualMethods for HTMLInputElement {
event.mark_as_handled();
}
} else if let Some(clipboard_event) = event.downcast::<ClipboardEvent>() {
if !event.DefaultPrevented() {
handle_text_clipboard_action(self, &self.textinput, clipboard_event, can_gc);
let reaction = self
.textinput
.borrow_mut()
.handle_clipboard_event(clipboard_event);
if reaction.contains(ClipboardEventReaction::FireClipboardChangedEvent) {
self.owner_document().fire_clipboardchange_event(can_gc);
}
if reaction.contains(ClipboardEventReaction::QueueInputEvent) {
self.owner_global()
.task_manager()
.user_interaction_task_source()
.queue_event(
self.upcast(),
atom!("input"),
EventBubbles::Bubbles,
EventCancelable::NotCancelable,
);
}
if !reaction.is_empty() {
self.upcast::<Node>().dirty(NodeDamage::ContentOrHeritage);
}
}

View file

@ -43,8 +43,8 @@ use crate::dom::validitystate::{ValidationFlags, ValidityState};
use crate::dom::virtualmethods::VirtualMethods;
use crate::script_runtime::CanGc;
use crate::textinput::{
Direction, KeyReaction, Lines, SelectionDirection, TextInput, UTF8Bytes, UTF16CodeUnits,
handle_text_clipboard_action,
ClipboardEventReaction, Direction, KeyReaction, Lines, SelectionDirection, TextInput,
UTF8Bytes, UTF16CodeUnits,
};
#[dom_struct]
@ -698,8 +698,26 @@ impl VirtualMethods for HTMLTextAreaElement {
event.mark_as_handled();
}
} else if let Some(clipboard_event) = event.downcast::<ClipboardEvent>() {
if !event.DefaultPrevented() {
handle_text_clipboard_action(self, &self.textinput, clipboard_event, CanGc::note());
let reaction = self
.textinput
.borrow_mut()
.handle_clipboard_event(clipboard_event);
if reaction.contains(ClipboardEventReaction::FireClipboardChangedEvent) {
self.owner_document().fire_clipboardchange_event(can_gc);
}
if reaction.contains(ClipboardEventReaction::QueueInputEvent) {
self.owner_global()
.task_manager()
.user_interaction_task_source()
.queue_event(
self.upcast(),
atom!("input"),
EventBubbles::Bubbles,
EventCancelable::NotCancelable,
);
}
if !reaction.is_empty() {
self.upcast::<Node>().dirty(NodeDamage::ContentOrHeritage);
}
}

View file

@ -9,21 +9,19 @@ use std::cmp::min;
use std::default::Default;
use std::ops::{Add, AddAssign, Range};
use bitflags::bitflags;
use keyboard_types::{Key, KeyState, Modifiers, NamedKey, ShortcutMatcher};
use unicode_segmentation::UnicodeSegmentation;
use crate::clipboard_provider::{ClipboardProvider, EmbedderClipboardProvider};
use crate::dom::bindings::cell::DomRefCell;
use crate::clipboard_provider::ClipboardProvider;
use crate::dom::bindings::codegen::Bindings::EventBinding::Event_Binding::EventMethods;
use crate::dom::bindings::inheritance::Castable;
use crate::dom::bindings::str::DOMString;
use crate::dom::compositionevent::CompositionEvent;
use crate::dom::event::Event;
use crate::dom::keyboardevent::KeyboardEvent;
use crate::dom::node::NodeTraits;
use crate::dom::types::ClipboardEvent;
use crate::drag_data_store::{DragDataStore, Kind};
use crate::script_runtime::CanGc;
use crate::drag_data_store::Kind;
#[derive(Clone, Copy, PartialEq)]
pub enum Selection {
@ -206,6 +204,15 @@ pub enum KeyReaction {
Nothing,
}
bitflags! {
/// Resulting action to be taken by the owner of a text input that is handling a clipboard
/// event.
pub struct ClipboardEventReaction: u8 {
const QueueInputEvent = 1 << 0;
const FireClipboardChangedEvent = 1 << 1;
}
}
impl Default for TextPoint {
fn default() -> TextPoint {
TextPoint {
@ -338,7 +345,8 @@ impl<T: ClipboardProvider> TextInput<T> {
self.insert_string(ch.to_string());
}
/// Insert a string at the current editing point
/// Insert a string at the current editing point or replace the selection if
/// one exists.
pub fn insert_string<S: Into<String>>(&mut self, s: S) {
if self.selection_origin.is_none() {
self.selection_origin = Some(self.edit_point);
@ -1199,81 +1207,103 @@ impl<T: ClipboardProvider> TextInput<T> {
self.edit_point.index = byte_offset;
}
fn paste_contents(&mut self, drag_data_store: &DragDataStore) {
for item in drag_data_store.iter_item_list() {
if let Kind::Text { data, .. } = item {
self.insert_string(data.to_string());
}
/// This implements step 3 onward from:
///
/// - <https://www.w3.org/TR/clipboard-apis/#copy-action>
/// - <https://www.w3.org/TR/clipboard-apis/#cut-action>
/// - <https://www.w3.org/TR/clipboard-apis/#paste-action>
///
/// Earlier steps should have already been run by the callers.
pub(crate) fn handle_clipboard_event(
&mut self,
clipboard_event: &ClipboardEvent,
) -> ClipboardEventReaction {
let event = clipboard_event.upcast::<Event>();
if !event.IsTrusted() {
return ClipboardEventReaction::empty();
}
// This step is common to all event types in the specification.
// Step 3: If the event was not canceled, then
if event.DefaultPrevented() {
// Step 4: Else, if the event was canceled
// Step 4.1: Return false.
return ClipboardEventReaction::empty();
}
match event.Type().str() {
"copy" => {
// These steps are from <https://www.w3.org/TR/clipboard-apis/#copy-action>:
let selection = self.get_selection_text();
// Step 3.1 Copy the selected contents, if any, to the clipboard
if let Some(text) = selection {
self.clipboard_provider.set_text(text);
}
// Step 3.2 Fire a clipboard event named clipboardchange
ClipboardEventReaction::FireClipboardChangedEvent
},
"cut" => {
// These steps are from <https://www.w3.org/TR/clipboard-apis/#cut-action>:
let selection = self.get_selection_text();
// Step 3.1 If there is a selection in an editable context where cutting is enabled, then
let Some(text) = selection else {
// Step 3.2 Else, if there is no selection or the context is not editable, then
return ClipboardEventReaction::empty();
};
// Step 3.1.1 Copy the selected contents, if any, to the clipboard
self.clipboard_provider.set_text(text);
// Step 3.1.2 Remove the contents of the selection from the document and collapse the selection.
self.delete_char(Direction::Backward);
// Step 3.1.3 Fire a clipboard event named clipboardchange
// Step 3.1.4 Queue tasks to fire any events that should fire due to the modification.
ClipboardEventReaction::FireClipboardChangedEvent |
ClipboardEventReaction::QueueInputEvent
},
"paste" => {
// These steps are from <https://www.w3.org/TR/clipboard-apis/#paste-action>:
let Some(data_transfer) = clipboard_event.get_clipboard_data() else {
return ClipboardEventReaction::empty();
};
let Some(drag_data_store) = data_transfer.data_store() else {
return ClipboardEventReaction::empty();
};
// Step 3.1: If there is a selection or cursor in an editable context where pasting is
// enabled, then:
// TODO: Our TextInput always has a selection or an input point. It's likely that this
// shouldn't be the case when the entry loses the cursor.
// Step 3.1.1: Insert the most suitable content found on the clipboard, if any, into the
// context.
// TODO: Only text content is currently supported, but other data types should be supported
// in the future.
let Some(text_content) =
drag_data_store
.iter_item_list()
.find_map(|item| match item {
Kind::Text { data, .. } => Some(data.to_string()),
_ => None,
})
else {
return ClipboardEventReaction::empty();
};
if text_content.is_empty() {
return ClipboardEventReaction::empty();
}
self.insert_string(text_content);
// Step 3.1.2: Queue tasks to fire any events that should fire due to the
// modification, see § 5.3 Integration with other scripts and events for details.
ClipboardEventReaction::QueueInputEvent
},
_ => ClipboardEventReaction::empty(),
}
}
}
/// <https://www.w3.org/TR/clipboard-apis/#clipboard-actions> step 3
pub(crate) fn handle_text_clipboard_action(
owning_node: &impl NodeTraits,
textinput: &DomRefCell<TextInput<EmbedderClipboardProvider>>,
event: &ClipboardEvent,
can_gc: CanGc,
) -> bool {
let e = event.upcast::<Event>();
if !e.IsTrusted() {
return false;
}
// Step 3
match e.Type().str() {
"copy" => {
let selection = textinput.borrow().get_selection_text();
// Step 3.1 Copy the selected contents, if any, to the clipboard
if let Some(text) = selection {
textinput.borrow_mut().clipboard_provider.set_text(text);
}
// Step 3.2 Fire a clipboard event named clipboardchange
owning_node
.owner_document()
.fire_clipboardchange_event(can_gc);
},
"cut" => {
let selection = textinput.borrow().get_selection_text();
// Step 3.1 If there is a selection in an editable context where cutting is enabled, then
if let Some(text) = selection {
// Step 3.1.1 Copy the selected contents, if any, to the clipboard
textinput.borrow_mut().clipboard_provider.set_text(text);
// Step 3.1.2 Remove the contents of the selection from the document and collapse the selection.
textinput.borrow_mut().delete_char(Direction::Backward);
// Step 3.1.3 Fire a clipboard event named clipboardchange
owning_node
.owner_document()
.fire_clipboardchange_event(can_gc);
// Step 3.1.4 Queue tasks to fire any events that should fire due to the modification.
} else {
// Step 3.2 Else, if there is no selection or the context is not editable, then
return false;
}
},
"paste" => {
// Step 3.1 If there is a selection or cursor in an editable context where pasting is enabled, then
if let Some(data) = event.get_clipboard_data() {
// Step 3.1.1 Insert the most suitable content found on the clipboard, if any, into the context.
let drag_data_store = data.data_store().expect("This shouldn't fail");
textinput.borrow_mut().paste_contents(&drag_data_store);
// Step 3.1.2 Queue tasks to fire any events that should fire due to the modification.
} else {
// Step 3.2 Else return false.
return false;
}
},
_ => (),
}
//Step 5
true
}