fix(editing): enforce maxlength for inserted text

This commit is contained in:
ldm0
2026-09-14 13:33:58 +08:00
parent 888565bba6
commit e494754df7
15 changed files with 358 additions and 134 deletions
@@ -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
+6
View File
@@ -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]
+23 -1
View File
@@ -43,7 +43,9 @@ pub fn parse_positive_integer_prefix(value: &str) -> Option<u32> {
}
pub fn parse_non_negative_length_attribute(value: &str) -> Option<usize> {
value.parse::<usize>().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<i32> {
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::<String>();
if digits.is_empty() {
return None;
}
let value = sign * digits.parse::<i64>().ok()?;
i32::try_from(value).ok()
}
+4 -4
View File
@@ -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}"#
);
}
@@ -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<Self> {
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::<EditingCommand>() 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::<EditingCommand>().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::<EditingCommand>() {
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<String> {
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<DomHandle> {
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);
@@ -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,
@@ -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,
@@ -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<i32> {
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::<String>();
if digits.is_empty() {
return None;
}
let value = sign * digits.parse::<i64>().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>,
@@ -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;
};
@@ -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,
};
@@ -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<u16> {
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") {
@@ -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(&current_value) as u32;
let _ = runtime.set_selection_range(handle, end, end);
}
rv.set_undefined();
@@ -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() {
@@ -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,
'<b>A</b><br>B<script>C</script>&amp;'
);
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/");
+10 -10
View File
@@ -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";
'''