From e494754df762b9264bd1156593c237dc869c5741 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 15 Jul 2026 02:40:58 +0800 Subject: [PATCH] fix(editing): enforce maxlength for inserted text --- .../wpt-cross-current/passed-cases.txt | 1 + moli-dom/src/forms/tests.rs | 6 + moli-dom/src/forms/text.rs | 24 ++- moli-protocol/src/domains/input/tests.rs | 8 +- .../src/native_bridge/document/lifecycle.rs | 169 ++++++++++++------ moli-renderer-v8/src/native_bridge/element.rs | 2 +- .../src/native_bridge/element/forms.rs | 2 +- .../element/forms/input/numeric.rs | 24 +-- .../element/forms/input/value.rs | 3 +- .../element/forms/text_control.rs | 2 +- .../element/forms/text_control/selection.rs | 121 ++++++++++--- .../element/forms/text_control/value.rs | 15 +- .../src/script_vm/input_dispatch.rs | 24 +-- .../tests/dom_elements/text_controls.rs | 71 ++++++++ moli-wpt-compat/fixtures/wpt/manifest.toml | 20 +-- 15 files changed, 358 insertions(+), 134 deletions(-) diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 228f0a5044..211b77104b 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5763,6 +5763,7 @@ html/semantics/forms/constraints/form-validation-validity-valueMissing-weekmonth html/semantics/forms/constraints/form-validation-validity-valueMissing.html html/semantics/forms/constraints/form-validation-willValidate-datalist.html html/semantics/forms/constraints/form-validation-willValidate.html +html/semantics/forms/constraints/input-maxlength-emoji.html html/semantics/forms/constraints/input-number-validity-dynamic-value-no-change.html html/semantics/forms/constraints/input-pattern-dynamic-value.html html/semantics/forms/constraints/inputwillvalidate.html diff --git a/moli-dom/src/forms/tests.rs b/moli-dom/src/forms/tests.rs index 17e848db64..cad13c02a0 100644 --- a/moli-dom/src/forms/tests.rs +++ b/moli-dom/src/forms/tests.rs @@ -63,6 +63,12 @@ fn integer_prefix_parsers_follow_html_attribute_rules() { assert_eq!(parse_positive_integer_prefix("-1"), None); assert_eq!(parse_positive_integer_prefix("abc"), None); assert_eq!(parse_positive_integer_prefix("999999999999"), None); + + assert_eq!(parse_non_negative_length_attribute(" +12px"), Some(12)); + assert_eq!(parse_non_negative_length_attribute("-0tail"), Some(0)); + assert_eq!(parse_non_negative_length_attribute("-1"), None); + assert_eq!(parse_non_negative_length_attribute("abc"), None); + assert_eq!(parse_non_negative_length_attribute("2147483648"), None); } #[test] diff --git a/moli-dom/src/forms/text.rs b/moli-dom/src/forms/text.rs index 797330677f..6df6272f66 100644 --- a/moli-dom/src/forms/text.rs +++ b/moli-dom/src/forms/text.rs @@ -43,7 +43,9 @@ pub fn parse_positive_integer_prefix(value: &str) -> Option { } pub fn parse_non_negative_length_attribute(value: &str) -> Option { - value.parse::().ok() + parse_html_integer_prefix(value) + .filter(|value| *value >= 0) + .map(|value| value as usize) } pub fn text_control_value_length(value: &str) -> usize { @@ -72,3 +74,23 @@ fn integer_prefix_digits(value: &str) -> &str { .unwrap_or(value.len()); &value[..end] } + +fn parse_html_integer_prefix(value: &str) -> Option { + let value = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()); + let mut chars = value.chars(); + let (sign, rest) = match chars.next() { + Some('+') => (1_i64, chars.as_str()), + Some('-') => (-1_i64, chars.as_str()), + Some(_) => (1_i64, value), + None => return None, + }; + let digits = rest + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if digits.is_empty() { + return None; + } + let value = sign * digits.parse::().ok()?; + i32::try_from(value).ok() +} diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index 3de7b7aecc..d9d8c20a24 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -1777,7 +1777,7 @@ async fn coordinate_drag_event_completes_through_pending_layout_dispatch() { } #[tokio::test(flavor = "multi_thread")] -async fn insert_text_marks_text_controls_user_edited_for_length_validity() { +async fn insert_text_enforces_maxlength_and_marks_short_values_user_edited() { let mut ctx = TestContext::new(); with_loaded_document( &mut ctx, @@ -1847,7 +1847,7 @@ async fn insert_text_marks_text_controls_user_edited_for_length_validity() { "JSON.stringify({value: field.value, tooLong: field.validity.tooLong, valid: field.validity.valid})" ) .await, - r#"{"value":"abcde","tooLong":true,"valid":false}"# + r#"{"value":"abcd","tooLong":false,"valid":true}"# ); assert_eq!( @@ -1877,7 +1877,7 @@ async fn insert_text_marks_text_controls_user_edited_for_length_validity() { "JSON.stringify({value: bio.value, tooLong: bio.validity.tooLong, valid: bio.validity.valid})" ) .await, - r#"{"value":"abcd","tooLong":true,"valid":false}"# + r#"{"value":"abc","tooLong":false,"valid":true}"# ); evaluate_string( @@ -1898,7 +1898,7 @@ async fn insert_text_marks_text_controls_user_edited_for_length_validity() { "JSON.stringify({value: emoji.value, tooLong: emoji.validity.tooLong, valid: emoji.validity.valid})" ) .await, - r#"{"value":"๐Ÿ˜€","tooLong":true,"valid":false}"# + r#"{"value":"","tooLong":false,"valid":true}"# ); } diff --git a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs index 61828c22a5..371eb0a71f 100644 --- a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs @@ -8,15 +8,21 @@ use super::{ is_html_document, throw_dom_exception, }; use crate::native_bridge::element::{ - char_offset_to_byte_index, contenteditable_editing_host, dispatch_text_control_event, - is_text_control, queue_text_control_document_selection_change_event, - replace_contenteditable_selection, replace_text_control_selection, text_control_value, + contenteditable_editing_host, dispatch_text_control_event, + form_control_is_effectively_disabled, is_text_control, + queue_text_control_document_selection_change_event, replace_contenteditable_selection, + replace_text_control_selection, text_control_value, }; use crate::{ context_bootstrap::WINDOW_EVENT_HANDLER_PROPERTIES, custom_elements, document_runtime::DomHandle, - util::{call_object_method, node_wrapper_from_handle, v8str}, + dom::native::{NativeDom, NodeData}, + parser::HtmlParser, + util::{ + call_object_method, node_wrapper_from_handle, utf16_replace_units_range_lossy, utf16_units, + v8str, + }, webidl, }; @@ -358,38 +364,17 @@ fn normalized_editing_command<'s>( .unwrap_or_default() } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, strum::EnumString, strum::IntoStaticStr)] +#[strum(serialize_all = "lowercase")] enum EditingCommand { Copy, Delete, ForwardDelete, + InsertHtml, InsertText, SelectAll, } -impl EditingCommand { - fn parse(command: &str) -> Option { - match command { - "copy" => Some(Self::Copy), - "delete" => Some(Self::Delete), - "forwarddelete" => Some(Self::ForwardDelete), - "inserttext" => Some(Self::InsertText), - "selectall" => Some(Self::SelectAll), - _ => None, - } - } - - fn name(self) -> &'static str { - match self { - Self::Copy => "copy", - Self::Delete => "delete", - Self::ForwardDelete => "forwarddelete", - Self::InsertText => "inserttext", - Self::SelectAll => "selectall", - } - } -} - fn editing_command_document<'s>( scope: &mut v8::PinScope<'s, '_>, args: &v8::FunctionCallbackArguments<'s>, @@ -435,7 +420,7 @@ pub(in crate::native_bridge) fn node_document_exec_command_callback<'s>( ) else { return; }; - let Some(command) = EditingCommand::parse(&command) else { + let Ok(command) = command.parse::() else { rv.set(v8::Boolean::new(scope, false).into()); return; }; @@ -452,21 +437,25 @@ pub(in crate::native_bridge) fn node_document_exec_command_callback<'s>( return; } EditingCommand::InsertText => { - let replacement = if args.length() > 2 { - let Some(value) = args.get(2).to_string(scope) else { - return; - }; - value.to_rust_string_lossy(scope) - } else { - String::new() + let Some(value) = editing_command_value(scope, &args) else { + return; }; - let inserted = exec_command_insert_text(scope, runtime_ptr, &replacement); + let inserted = exec_command_insert_text(scope, runtime_ptr, &value); + rv.set(v8::Boolean::new(scope, inserted).into()); + return; + } + EditingCommand::InsertHtml => { + let Some(value) = editing_command_value(scope, &args) else { + return; + }; + let inserted = exec_command_insert_html(scope, runtime_ptr, &value); rv.set(v8::Boolean::new(scope, inserted).into()); return; } EditingCommand::Delete | EditingCommand::ForwardDelete => {} } - let removed = exec_command_delete_selection(scope, runtime_ptr, args.this(), command.name()); + let command_name: &'static str = command.into(); + let removed = exec_command_delete_selection(scope, runtime_ptr, args.this(), command_name); rv.set(v8::Boolean::new(scope, removed).into()); } @@ -501,7 +490,7 @@ pub(in crate::native_bridge) fn node_document_query_command_supported_callback<' ) else { return; }; - rv.set(v8::Boolean::new(scope, EditingCommand::parse(&command).is_some()).into()); + rv.set(v8::Boolean::new(scope, command.parse::().is_ok()).into()); } pub(in crate::native_bridge) fn node_document_query_command_enabled_callback<'s>( @@ -519,21 +508,20 @@ pub(in crate::native_bridge) fn node_document_query_command_enabled_callback<'s> return; }; let runtime = unsafe { &*runtime_ptr }; - let enabled = match EditingCommand::parse(&command) { - Some( - EditingCommand::Delete | EditingCommand::ForwardDelete | EditingCommand::InsertText, - ) => { + let enabled = match command.parse::() { + Ok(EditingCommand::Delete | EditingCommand::ForwardDelete | EditingCommand::InsertText) => { runtime.document_design_mode_enabled(document_handle) || runtime.active_element_handle().is_some_and(|active| { is_text_control(runtime, active) || contenteditable_editing_host(runtime, active).is_some() }) } - Some(EditingCommand::SelectAll) => { + Ok(EditingCommand::InsertHtml) => exec_command_insert_html_target(runtime).is_some(), + Ok(EditingCommand::SelectAll) => { exec_command_select_all_target(runtime, document_handle).is_some() } - Some(EditingCommand::Copy) => current_protocol_user_gesture_activation(scope), - None => false, + Ok(EditingCommand::Copy) => current_protocol_user_gesture_activation(scope), + Err(_) => false, }; rv.set(v8::Boolean::new(scope, enabled).into()); } @@ -665,6 +653,83 @@ fn current_protocol_user_gesture_activation(scope: &mut v8::PinScope<'_, '_>) -> .is_some_and(|host_ptr| unsafe { (&*host_ptr).protocol_user_gesture_activation() }) } +fn editing_command_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, +) -> Option { + if args.length() < 3 { + return Some(String::new()); + } + args.get(2) + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) +} + +fn exec_command_insert_html( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + value: &str, +) -> bool { + let runtime = unsafe { &*runtime_ptr }; + let Some(target) = exec_command_insert_html_target(runtime) else { + return false; + }; + let insertion_text = input_text_from_html_fragment(runtime, value); + replace_text_control_selection(scope, runtime_ptr, target, &insertion_text) +} + +fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option { + let handle = runtime.active_element_handle()?; + let element = runtime.dom_host().node(handle)?.as_element()?; + let accepts_plain_text = element.is_html_textarea() + || (element.is_html_input() && element.input_type().supports_text_length_validation()); + if !accepts_plain_text + || element.has_attribute("readonly") + || form_control_is_effectively_disabled(runtime, handle) + { + return None; + } + Some(handle) +} + +fn input_text_from_html_fragment(runtime: &JsContextHost, value: &str) -> String { + let parsed = HtmlParser.parse_fragment_without_declarative_shadow_roots_with_scripting( + runtime.host_document().url().clone(), + "http://www.w3.org/1999/xhtml", + "body", + value.to_owned(), + true, + ); + let root = parsed + .body_node_id() + .unwrap_or_else(|| parsed.document_node_id()); + let mut text = String::new(); + for child in parsed.child_ids(root) { + append_input_fragment_text(&parsed, child, &mut text); + } + text +} + +fn append_input_fragment_text(dom: &NativeDom, handle: DomHandle, text: &mut String) { + let Some(node) = dom.node(handle) else { + return; + }; + if node.is_html_element_named("br") { + text.push('\n'); + return; + } + match node.data() { + NodeData::Text(value) => text.push_str(value.data()), + NodeData::CDataSection(value) => text.push_str(value.data()), + NodeData::Document(_) | NodeData::Element(_) | NodeData::DocumentFragment(_) => { + for child in dom.child_ids(handle) { + append_input_fragment_text(dom, child, text); + } + } + NodeData::DocumentType(_) | NodeData::Comment(_) | NodeData::ProcessingInstruction(_) => {} + } +} + fn exec_command_delete_selection<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, @@ -730,7 +795,8 @@ fn exec_command_delete_text_control( return None; } let value = text_control_value(runtime, handle); - let value_len = value.chars().count() as u32; + let value_units = utf16_units(&value); + let value_len = value_units.len() as u32; let (start, end) = runtime .dom_host() .node(handle) @@ -759,10 +825,11 @@ fn exec_command_delete_text_control( (start - 1, start, start - 1) }; - let next_value = format!( - "{}{}", - &value[..char_offset_to_byte_index(&value, from)], - &value[char_offset_to_byte_index(&value, to)..] + let next_value = utf16_replace_units_range_lossy( + &value_units, + from as usize, + to.saturating_sub(from) as usize, + &[], ); let runtime = unsafe { &mut *runtime_ptr }; let changed = runtime.set_input_value_from_user_edit(handle, &next_value); diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 8de005f8ce..c5766dd634 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -331,7 +331,7 @@ pub(crate) use forms::{ cache_input_files_from_selected_files, form_control_is_effectively_disabled, }; pub(crate) use forms::{ - char_offset_to_byte_index, dispatch_text_control_event, is_text_control, + dispatch_text_control_event, is_text_control, queue_text_control_document_selection_change_event, replace_text_control_selection, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 7e68d29ec7..28a20b093c 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -233,7 +233,7 @@ pub(crate) use self::submission::{ align_event_constructor_function_realm_with_target, }; pub(crate) use self::text_control::{ - char_offset_to_byte_index, dispatch_text_control_event, is_text_control, + dispatch_text_control_event, is_text_control, queue_text_control_document_selection_change_event, replace_text_control_selection, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/input/numeric.rs b/moli-renderer-v8/src/native_bridge/element/forms/input/numeric.rs index aaa24ffc29..8b2447c4ef 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/input/numeric.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/input/numeric.rs @@ -1,6 +1,7 @@ use super::super::*; use crate::native_bridge::element::{html_element_getter_receiver, html_element_setter_receiver}; use crate::webidl; +use moli_dom::forms::parse_non_negative_length_attribute; pub(in crate::native_bridge) fn input_max_length_getter_function<'s>( scope: &mut v8::PinScope<'s, '_>, @@ -150,31 +151,12 @@ fn text_control_length_getter_from_object<'s>( return; }; let value = element_attribute(unsafe { &*runtime_ptr }, handle, attribute) - .and_then(|value| parse_non_negative_long_prefix(&value)) + .and_then(|value| parse_non_negative_length_attribute(&value)) + .and_then(|value| i32::try_from(value).ok()) .unwrap_or(-1); rv.set_int32(value); } -fn parse_non_negative_long_prefix(value: &str) -> Option { - let value = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()); - let mut chars = value.chars(); - let (sign, rest) = match chars.next() { - Some('+') => (1_i64, chars.as_str()), - Some('-') => (-1_i64, chars.as_str()), - Some(_) => (1_i64, value), - None => return None, - }; - let digits = rest - .chars() - .take_while(|ch| ch.is_ascii_digit()) - .collect::(); - if digits.is_empty() { - return None; - } - let value = sign * digits.parse::().ok()?; - i32::try_from(value).ok().filter(|value| *value >= 0) -} - fn text_control_length_setter_on_object<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/input/value.rs b/moli-renderer-v8/src/native_bridge/element/forms/input/value.rs index c11c0177ae..614fe2c40f 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/input/value.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/input/value.rs @@ -1,4 +1,5 @@ use super::super::*; +use crate::util::utf16_len; use crate::webidl; use moli_dom::forms::{ InputStepDirection, InputStepError, InputStepOutcome, InputStepState, date_input_milliseconds, @@ -218,7 +219,7 @@ fn reset_input_selection_to_end(runtime: &mut JsContextHost, handle: DomHandle) .node(handle) .and_then(Node::as_element) .filter(|element| element.input_type().supports_variable_length_selection()) - .map(|element| element.input_value().chars().count() as u32) + .map(|element| utf16_len(&element.input_value()) as u32) else { return; }; diff --git a/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs b/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs index da25b5b607..b16945bb0f 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs @@ -19,7 +19,7 @@ pub(in crate::native_bridge) use selection::{ text_control_set_selection_range_callback, }; pub(in crate::native_bridge) use value::normalize_textarea_api_value; -pub(crate) use value::{char_offset_to_byte_index, is_text_control, text_control_value}; +pub(crate) use value::{is_text_control, text_control_value}; pub(in crate::native_bridge) use value::{ textarea_value_getter_function, textarea_value_setter_function, }; diff --git a/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs b/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs index bdc3a0f76a..968abfbafb 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs @@ -3,11 +3,11 @@ use super::events::{ queue_text_control_selection_change_event, }; use super::value::{ - char_offset_to_byte_index, clamp_text_control_offset, is_text_control, - supports_variable_length_selection, + clamp_text_control_offset, is_text_control, supports_variable_length_selection, }; use super::*; -use crate::util::v8str; +use crate::dom::forms::parse_non_negative_length_attribute; +use crate::util::{utf16_replace_units_range_lossy, utf16_units, v8str}; use crate::webidl; #[derive(webidl::WebIdlArgs)] @@ -126,8 +126,7 @@ pub(crate) fn replace_text_control_selection( handle: DomHandle, replacement_text: &str, ) -> bool { - let runtime = unsafe { &*runtime_ptr }; - if !is_text_control(runtime, handle) { + if !is_text_control(unsafe { &*runtime_ptr }, handle) { return false; } @@ -139,7 +138,12 @@ pub(crate) fn replace_text_control_selection( return false; } + let runtime = unsafe { &*runtime_ptr }; + if !is_text_control(runtime, handle) { + return false; + } let value = text_control_value(runtime, handle); + let value_units = utf16_units(&value); let (start, end) = runtime .dom_host() .node(handle) @@ -154,15 +158,24 @@ pub(crate) fn replace_text_control_selection( } }) .unwrap_or_else(|| { - let value_len = value.chars().count() as u32; + let value_len = value_units.len() as u32; (value_len, value_len) }); - - let next_value = format!( - "{}{}{}", - &value[..char_offset_to_byte_index(&value, start)], + let start = (start as usize).min(value_units.len()); + let end = (end as usize).min(value_units.len()).max(start); + let replacement_units = text_control_user_edit_replacement_units( + runtime, + handle, + value_units.len(), + start, + end, replacement_text, - &value[char_offset_to_byte_index(&value, end)..] + ); + let next_value = utf16_replace_units_range_lossy( + &value_units, + start, + end.saturating_sub(start), + &replacement_units, ); let runtime = unsafe { &mut *runtime_ptr }; @@ -170,7 +183,7 @@ pub(crate) fn replace_text_control_selection( if changed { runtime.mark_text_control_change_pending(handle, &value); } - let caret = start + replacement_text.chars().count() as u32; + let caret = u32::try_from(start.saturating_add(replacement_units.len())).unwrap_or(u32::MAX); let selection_changed = text_control_set_selection_range_internal(scope, runtime_ptr, handle, caret, caret); if changed || selection_changed { @@ -179,6 +192,72 @@ pub(crate) fn replace_text_control_selection( changed || selection_changed } +fn text_control_user_edit_replacement_units( + runtime: &JsContextHost, + handle: DomHandle, + current_value_len: usize, + selection_start: usize, + selection_end: usize, + replacement_text: &str, +) -> Vec { + let Some(element) = runtime.dom_host().node(handle).and_then(Node::as_element) else { + return Vec::new(); + }; + let replacement_text = if element.is_html_input() { + normalize_single_line_text_insertion(replacement_text) + } else { + replacement_text.to_owned() + }; + let mut replacement_units = utf16_units(&replacement_text); + + let max_length = (element.is_html_textarea() + || (element.is_html_input() && element.input_type().supports_text_length_validation())) + .then(|| element.attribute("maxlength")) + .flatten() + .and_then(parse_non_negative_length_attribute); + let Some(max_length) = max_length else { + return replacement_units; + }; + + let selection_len = if runtime.active_element_handle() == Some(handle) { + selection_end.saturating_sub(selection_start) + } else { + 0 + }; + let base_len = current_value_len.saturating_sub(selection_len); + let appendable_len = max_length.saturating_sub(base_len); + if replacement_units.len() <= appendable_len { + return replacement_units; + } + replacement_units.truncate(appendable_len); + if replacement_units + .last() + .is_some_and(|unit| (0xD800..=0xDBFF).contains(unit)) + { + let _ = replacement_units.pop(); + } + replacement_units +} + +fn normalize_single_line_text_insertion(value: &str) -> String { + let value = value.trim_end_matches(['\r', '\n']); + let mut normalized = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\r' => { + if chars.peek() == Some(&'\n') { + let _ = chars.next(); + } + normalized.push(' '); + } + '\n' => normalized.push(' '), + _ => normalized.push(ch), + } + } + normalized +} + fn current_selection_or_end( runtime: &JsContextHost, handle: DomHandle, @@ -411,7 +490,8 @@ pub(in crate::native_bridge) fn text_control_set_range_text_callback<'s>( } let value = text_control_value(runtime, handle); - let value_len = value.chars().count() as u32; + let value_units = utf16_units(&value); + let value_len = value_units.len() as u32; let (current_start, current_end) = current_selection_or_end(runtime, handle, value_len); let start = parsed.start.unwrap_or(current_start).min(value_len); let end = parsed.end.unwrap_or(current_end).min(value_len); @@ -425,13 +505,14 @@ pub(in crate::native_bridge) fn text_control_set_range_text_callback<'s>( return; } - let replacement_len = parsed.replacement.chars().count() as u32; - let start_byte = char_offset_to_byte_index(&value, start); - let end_byte = char_offset_to_byte_index(&value, end); - let mut next_value = String::with_capacity(value.len() + parsed.replacement.len()); - next_value.push_str(&value[..start_byte]); - next_value.push_str(&parsed.replacement); - next_value.push_str(&value[end_byte..]); + let replacement_units = utf16_units(&parsed.replacement); + let replacement_len = replacement_units.len() as u32; + let next_value = utf16_replace_units_range_lossy( + &value_units, + start as usize, + end.saturating_sub(start) as usize, + &replacement_units, + ); let mode = parsed.selection_mode.as_deref().unwrap_or("preserve"); if !matches!(mode, "select" | "start" | "end" | "preserve") { diff --git a/moli-renderer-v8/src/native_bridge/element/forms/text_control/value.rs b/moli-renderer-v8/src/native_bridge/element/forms/text_control/value.rs index 3cbe1b5cd8..94fe9df4ab 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/text_control/value.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/text_control/value.rs @@ -1,5 +1,6 @@ use super::*; use crate::native_bridge::document::detached_native_handle_for_runtime; +use crate::util::utf16_len; pub(crate) fn text_control_value(runtime: &JsContextHost, handle: DomHandle) -> String { let Some(element) = runtime.dom_host().node(handle).and_then(Node::as_element) else { @@ -39,7 +40,7 @@ pub(super) fn clamp_text_control_offset( handle: DomHandle, offset: u32, ) -> u32 { - let len = text_control_value(runtime, handle).chars().count() as u32; + let len = utf16_len(&text_control_value(runtime, handle)) as u32; offset.min(len) } @@ -79,16 +80,6 @@ pub(super) fn supports_variable_length_selection( }) } -pub(crate) fn char_offset_to_byte_index(value: &str, offset: u32) -> usize { - if offset == 0 { - return 0; - } - value - .char_indices() - .nth(offset as usize) - .map_or(value.len(), |(index, _)| index) -} - pub(in crate::native_bridge) fn textarea_value_getter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -149,7 +140,7 @@ pub(in crate::native_bridge) fn textarea_value_setter_function<'s>( let _ = runtime.set_input_value(handle, &next_value); let current_value = text_control_value(runtime, handle); if current_value != previous_value { - let end = current_value.chars().count() as u32; + let end = utf16_len(¤t_value) as u32; let _ = runtime.set_selection_range(handle, end, end); } rv.set_undefined(); diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 8ffb21b5b6..cc205e31c7 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -24,12 +24,13 @@ use crate::native_bridge::element::{ construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, - contenteditable_editing_host, dispatch_public_event, observable_input_hit_test, - observable_input_surface_hit_test, perform_auxiliary_link_default_action, - perform_drop_default_action, perform_mouse_focus_default_action, - perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, - replace_contenteditable_selection, replace_text_control_selection, - select_contenteditable_contents, text_control_set_selection_range_internal, + contenteditable_editing_host, dispatch_public_event, is_text_control, + observable_input_hit_test, observable_input_surface_hit_test, + perform_auxiliary_link_default_action, perform_drop_default_action, + perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action, + perform_wheel_scroll_default_action, replace_contenteditable_selection, + replace_text_control_selection, select_contenteditable_contents, + text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; use crate::native_bridge::{ @@ -39,7 +40,7 @@ use crate::runtime::{ RendererDragData, RendererInputDispatchOutcome, RendererPointerEventProperties, RendererTouchPoint, }; -use crate::util::node_wrapper_from_handle; +use crate::util::{node_wrapper_from_handle, utf16_len}; fn related_target_value<'s>( scope: &mut v8::PinScope<'s, '_>, @@ -1770,7 +1771,8 @@ impl ScriptVm { }; let result = self.with_default_context_scope(|scope, runtime_ptr| { - if replace_text_control_selection(scope, runtime_ptr, handle, text) { + if is_text_control(unsafe { &*runtime_ptr }, handle) { + let _ = replace_text_control_selection(scope, runtime_ptr, handle, text); return Ok(true); } let runtime = unsafe { &*runtime_ptr }; @@ -2073,7 +2075,7 @@ impl ScriptVm { } if target.is_text_control && key_lower == "delete" { - let value_len = text_control_value(runtime, handle).chars().count() as u32; + let value_len = utf16_len(&text_control_value(runtime, handle)) as u32; let (start, end) = current_selection_range(runtime, handle); let (from, to) = if start != end { (start, end) @@ -2093,7 +2095,7 @@ impl ScriptVm { } if target.is_text_control && (ctrl || meta) && key_lower == "a" { - let value_len = text_control_value(runtime, handle).chars().count() as u32; + let value_len = utf16_len(&text_control_value(runtime, handle)) as u32; let _ = text_control_set_selection_range_internal( scope, runtime_ptr, @@ -2131,7 +2133,7 @@ impl ScriptVm { "arrowleft" | "left" | "arrowright" | "right" | "home" | "end" ) { - let value_len = text_control_value(runtime, handle).chars().count() as u32; + let value_len = utf16_len(&text_control_value(runtime, handle)) as u32; let (start, end, direction) = current_selection_state(runtime, handle); if shift { let (anchor, focus) = match direction.as_str() { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs index 008d86156f..b1711758f9 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs @@ -1144,6 +1144,77 @@ async fn text_control_selectionchange_bubbles_across_shadow_and_exec_delete_targ "0|document" ); } + +#[test] +fn exec_command_insert_html_enforces_maxlength_in_utf16_units() { + let mut vm = new_storage_test_vm("https://exec-command-insert-html-maxlength.test/"); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const body = document.body || root.appendChild(document.createElement('body')); + const input = document.createElement('input'); + input.setAttribute('maxlength', ' +10tail'); + body.append(input); + const events = []; + input.addEventListener('beforeinput', () => events.push('beforeinput')); + input.addEventListener('input', () => events.push('input')); + + const supported = document.queryCommandSupported('InsertHTML'); + const enabledBeforeFocus = document.queryCommandEnabled('InsertHTML'); + input.focus(); + const enabled = document.queryCommandEnabled('InsertHTML'); + const returned = document.execCommand('InsertHTML', false, '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ'); + + const markupInput = document.createElement('input'); + body.append(markupInput); + markupInput.focus(); + const markupReturned = document.execCommand( + 'InsertHTML', + false, + 'A
B&' + ); + + const rangeInput = document.createElement('input'); + rangeInput.value = 'A๐Ÿ˜€B'; + body.append(rangeInput); + rangeInput.setRangeText('x', 1, 3, 'end'); + + input.readOnly = true; + input.focus(); + const enabledReadonly = document.queryCommandEnabled('InsertHTML'); + const readonlyReturned = document.execCommand('InsertHTML', false, 'x'); + + return JSON.stringify({ + supported, + enabledBeforeFocus, + enabled, + returned, + value: input.value, + valueLength: input.value.length, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + events, + markupReturned, + markupValue: markupInput.value, + rangeValue: rangeInput.value, + rangeSelection: [rangeInput.selectionStart, rangeInput.selectionEnd], + enabledReadonly, + readonlyReturned + }); +})() +"#, + ) + .expect("execCommand InsertHTML maxlength probe should evaluate"); + + assert_eq!( + result, + r#"{"supported":true,"enabledBeforeFocus":false,"enabled":true,"returned":true,"value":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€","valueLength":9,"selectionStart":9,"selectionEnd":9,"events":["beforeinput","input"],"markupReturned":true,"markupValue":"A BC&","rangeValue":"AxB","rangeSelection":[2,2],"enabledReadonly":false,"readonlyReturned":false}"# + ); +} + #[test] fn input_file_value_setter_rejects_non_empty_values() { let mut vm = new_storage_test_vm("https://forms-input-file-value.test/"); diff --git a/moli-wpt-compat/fixtures/wpt/manifest.toml b/moli-wpt-compat/fixtures/wpt/manifest.toml index c255f3f5a4..bddfd76297 100644 --- a/moli-wpt-compat/fixtures/wpt/manifest.toml +++ b/moli-wpt-compat/fixtures/wpt/manifest.toml @@ -2007,7 +2007,7 @@ wait_until = "load" timeout_ms = 5000 suite = "smoke" tags = ["forms", "html", "validation", "input", "textarea", "length", "actions"] -notes = "Manual compat port for user-edit provenance in input/textarea minLength/maxLength validity: script-set values remain exempt, manifest-driven insert-text actions make tooShort/tooLong participate in ValidityState.valid, and UTF-16 code unit length is checked for non-BMP input." +notes = "Manual compat port for user-edit provenance and editing limits in input/textarea minLength/maxLength validity: script-set values remain exempt, short manifest-driven insert-text actions make tooShort participate in ValidityState.valid, and maxlength truncation counts UTF-16 code units without leaving a split surrogate." [[test.actions]] type = "evaluate" @@ -2039,9 +2039,9 @@ text = "abcde" [[test.actions]] type = "evaluate" expression = ''' -__lmAssertLengthState(field.value === "abcde", "second input action should replace selected value"); -__lmAssertLengthState(field.validity.tooLong === true, "user-edited long input should trip tooLong"); -__lmAssertLengthState(field.validity.valid === false, "tooLong input should be invalid"); +__lmAssertLengthState(field.value === "abcd", "input action should truncate to maxlength"); +__lmAssertLengthState(field.validity.tooLong === false, "maxlength should prevent a tooLong input value"); +__lmAssertLengthState(field.validity.valid === true, "truncated input should remain valid"); field.value = "abcdef"; __lmAssertLengthState(field.validity.tooLong === false, "script-set value after user edit should clear tooLong provenance"); __lmAssertLengthState(field.validity.valid === true, "script-set long input should be valid without user-edit provenance"); @@ -2064,9 +2064,9 @@ text = "abcd" [[test.actions]] type = "evaluate" expression = ''' -__lmAssertLengthState(bio.value === "abcd", "textarea action should replace selected value"); -__lmAssertLengthState(bio.validity.tooLong === true, "user-edited long textarea should trip tooLong"); -__lmAssertLengthState(bio.validity.valid === false, "tooLong textarea should be invalid"); +__lmAssertLengthState(bio.value === "abc", "textarea action should truncate to maxlength"); +__lmAssertLengthState(bio.validity.tooLong === false, "maxlength should prevent a tooLong textarea value"); +__lmAssertLengthState(bio.validity.valid === true, "truncated textarea should remain valid"); const emoji = document.getElementById("emoji"); emoji.focus(); "ready"; @@ -2079,9 +2079,9 @@ text = "๐Ÿ˜€" [[test.actions]] type = "evaluate" expression = ''' -__lmAssertLengthState(emoji.value === "๐Ÿ˜€", "emoji input should receive the inserted scalar"); -__lmAssertLengthState(emoji.validity.tooLong === true, "maxlength should count UTF-16 code units for user-edited non-BMP input"); -__lmAssertLengthState(emoji.validity.valid === false, "non-BMP value over maxLength should be invalid"); +__lmAssertLengthState(emoji.value === "", "maxlength should not leave half of a surrogate pair"); +__lmAssertLengthState(emoji.validity.tooLong === false, "rejected non-BMP input should not be tooLong"); +__lmAssertLengthState(emoji.validity.valid === true, "rejected non-BMP input should remain valid"); "done"; '''