mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
fix(editing): enforce maxlength for inserted text
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(¤t_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>&'
|
||||
);
|
||||
|
||||
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/");
|
||||
|
||||
@@ -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";
|
||||
'''
|
||||
|
||||
|
||||
Reference in New Issue
Block a user