From 7b82b0a94f7011834ac87703c68b60807734fcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 17 Sep 2026 23:45:05 +0200 Subject: [PATCH] Implement text control select-all defaults --- .../CMakeLists.txt | 5 + .../generated/webscene_dom_bindings.inc | 8 +- .../native/webscene_native_dom.h | 2 + .../native/webscene_native_form_state.h | 135 +++++++- .../native/webscene_v8_runtime.cpp | 3 + .../native/webscene_v8_runtime_document.inc | 2 + .../native/webscene_v8_runtime_dom_core.inc | 80 ++++- .../webscene_v8_runtime_dom_properties.inc | 76 ++++- .../native/webscene_v8_runtime_html.inc | 4 + .../native/webscene_v8_runtime_tasks.inc | 61 +++- .../tests/native_v8_runtime_input_tests.inc | 302 ++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 9 + tools/webidl-v8-bindings/dom-exposure.json | 1 + 13 files changed, 665 insertions(+), 23 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt index d1a740c29..1952f3697 100644 --- a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt +++ b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt @@ -866,6 +866,7 @@ if(WEBSCENE_NATIVE_ENGINE_ENABLE_V8) LABELS "native;css;syntax;resource;security;performance;lifecycle") add_test(NAME webscene_native_idle_v8_platform COMMAND webscene_native_engine_tests) add_test(NAME webscene_native_dom_punctuation_keyboard COMMAND webscene_native_engine_tests) + add_test(NAME webscene_native_text_control_select_all COMMAND webscene_native_engine_tests) add_test(NAME webscene_native_shortcut_activation COMMAND webscene_native_engine_tests) add_test(NAME webscene_native_select_popup_context COMMAND webscene_native_engine_tests) add_test(NAME webscene_native_html_select_add COMMAND webscene_native_engine_tests) @@ -907,6 +908,10 @@ if(WEBSCENE_NATIVE_ENGINE_ENABLE_V8) ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=idle-v8-platform") set_tests_properties(webscene_native_dom_punctuation_keyboard PROPERTIES ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=dom-punctuation-keyboard") + set_tests_properties(webscene_native_text_control_select_all PROPERTIES + ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=text-control-select-all" + TIMEOUT 30 + LABELS "native;runtime;input;security;performance;lifecycle") set_tests_properties(webscene_native_shortcut_activation PROPERTIES ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=shortcut-activation" TIMEOUT 30 diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc index 5ef217727..ef2112b65 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc @@ -1,5 +1,5 @@ // Generated by tools/webidl-v8-bindings/generate.mjs. Do not edit. -// Exposure manifest SHA-256: 062d2720b7d3bba71dc78ccab5b888f0fbeb25a03dc2a9b5c65709abd108bf69 +// Exposure manifest SHA-256: 953ce70922f2281f59114019b4cbb112cf9667ab6c3012b53e8d6078d26d9de5 // Inputs: @webref/idl 3.82.1, webidl2 24.5.0. enum class generated_dom_interface : uint8_t { @@ -4342,6 +4342,12 @@ void install_generated_dom_templates(v8::Local) isolate, element_set_selection_range, v8::Local(), generated_HTMLElement_signature, 2, v8::ConstructorBehavior::kThrow)); + generated_HTMLElement_template->PrototypeTemplate()->Set( + js_string(isolate, "setRangeText"), + v8::FunctionTemplate::New( + isolate, element_set_range_text, v8::Local(), + generated_HTMLElement_signature, 1, + v8::ConstructorBehavior::kThrow)); generated_HTMLElement_template->PrototypeTemplate()->Set( js_string(isolate, "click"), v8::FunctionTemplate::New( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index c1d848656..ce2499e55 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -1137,6 +1137,8 @@ struct dom_node final { std::string value; size_t selection_start{0}; size_t selection_end{0}; + uint8_t selection_start_utf16_suboffset{0}; + uint8_t selection_end_utf16_suboffset{0}; text_selection_direction selection_direction{text_selection_direction::none}; bool selection_explicitly_set{false}; bool selectedness_initialized{false}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h index c64fbb802..e20d95eff 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h @@ -139,10 +139,9 @@ inline void set_select_value(dom_node& select,std::string_view value) { select.mutable_form_control().selection_explicitly_empty=!matched; } -inline bool is_text_control(const dom_node* node) +inline bool supports_text_selection(const dom_node* node) { - if (node == nullptr || (node->tag != "input" && node->tag != "textarea") - || node->attributes.contains("disabled")) { + if (node == nullptr || (node->tag != "input" && node->tag != "textarea")) { return false; } if (node->tag == "textarea") return true; @@ -155,6 +154,12 @@ inline bool is_text_control(const dom_node* node) && type->second != "color"; } +inline bool is_text_control(const dom_node* node) + { + return supports_text_selection(node) + && !node->attributes.contains("disabled"); + } + // Initialize the live value once from authored markup. No HTML parser is needed. inline void ensure_text_value(dom_node& node) { auto& control=node.mutable_form_control(); @@ -178,6 +183,8 @@ inline void ensure_text_value(dom_node& node) { } control.value_initialized=true; control.selection_start=control.selection_end=control.value.size(); + control.selection_start_utf16_suboffset=0U; + control.selection_end_utf16_suboffset=0U; control.selection_direction=text_selection_direction::none; } @@ -195,4 +202,126 @@ inline size_t next_utf8_boundary(const std::string& value,size_t index) { return index; } +inline std::pair utf8_scalar_extent( + std::string_view value,size_t index) { + if(index>=value.size()) return {0U,0U}; + const auto first=static_cast(value[index]); + size_t bytes=1U; + size_t utf16_units=1U; + if((first&0xe0U)==0xc0U) bytes=2U; + else if((first&0xf0U)==0xe0U) bytes=3U; + else if((first&0xf8U)==0xf0U) {bytes=4U;utf16_units=2U;} + if(index+bytes>value.size()) return {1U,1U}; + for(size_t offset=1U;offset(value[index+offset])&0xc0U)!=0x80U) + return {1U,1U}; + return {bytes,utf16_units}; +} + +// Form-control storage remains byte-oriented so edits never split UTF-8. +// Selection APIs expose browser UTF-16 code-unit offsets at the JS boundary. +struct utf8_selection_offset { + size_t byte_offset{}; + uint8_t utf16_suboffset{}; +}; + +inline size_t utf16_offset_from_utf8_selection( + std::string_view value,utf8_selection_offset selection) { + const auto byte_offset=selection.byte_offset; + const auto limit=std::min(byte_offset,value.size()); + size_t bytes=0U,units=0U; + while(byteslimit) break; + bytes+=length; + units+=utf16_units; + } + if(bytes(selection.utf16_suboffset,utf16_units-1U); + } + return units; +} + +inline utf8_selection_offset utf8_selection_from_utf16_offset( + std::string_view value,size_t utf16_offset) { + size_t bytes=0U,units=0U; + while(bytesutf16_offset) + return {bytes,static_cast(utf16_offset-units)}; + bytes+=length; + units+=utf16_units; + } + return {bytes,0U}; +} + +inline bool selection_offset_less( + utf8_selection_offset left,utf8_selection_offset right) { + return left.byte_offset utf16_units_from_utf8(std::string_view value) { + std::vector result; + result.reserve(value.size()); + for(size_t index=0;index(value[index]); + if(length>1U) { + scalar&=length==2U?0x1fU:length==3U?0x0fU:0x07U; + for(size_t offset=1U;offset(value[index+offset])&0x3fU); + } + if(utf16_length==2U) { + scalar-=0x10000U; + result.push_back(static_cast(0xd800U+(scalar>>10U))); + result.push_back(static_cast(0xdc00U+(scalar&0x3ffU))); + } else result.push_back(static_cast(scalar)); + index+=length; + } + return result; +} + +inline void append_wtf8_scalar(std::string& result,uint32_t scalar) { + if(scalar<0x80U) result.push_back(static_cast(scalar)); + else if(scalar<0x800U) { + result.push_back(static_cast(0xc0U|(scalar>>6U))); + result.push_back(static_cast(0x80U|(scalar&0x3fU))); + } else if(scalar<0x10000U) { + result.push_back(static_cast(0xe0U|(scalar>>12U))); + result.push_back(static_cast(0x80U|((scalar>>6U)&0x3fU))); + result.push_back(static_cast(0x80U|(scalar&0x3fU))); + } else { + result.push_back(static_cast(0xf0U|(scalar>>18U))); + result.push_back(static_cast(0x80U|((scalar>>12U)&0x3fU))); + result.push_back(static_cast(0x80U|((scalar>>6U)&0x3fU))); + result.push_back(static_cast(0x80U|(scalar&0x3fU))); + } +} + +inline std::string wtf8_from_utf16_units(const std::vector& units) { + std::string result; + result.reserve(units.size()*3U); + for(size_t index=0;index=0xd800U && scalar<=0xdbffU && index+1U=0xdc00U && units[index+1U]<=0xdfffU) { + scalar=0x10000U+((scalar-0xd800U)<<10U)+(units[++index]-0xdc00U); + } + append_wtf8_scalar(result,scalar); + } + return result; +} + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 5b6c09209..1faa64b35 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -741,6 +741,9 @@ struct v8_dom_runtime::implementation final { element->PrototypeTemplate()->Set( js_string(isolate, "setSelectionRange"), v8::FunctionTemplate::New(isolate, element_set_selection_range)); + element->PrototypeTemplate()->Set( + js_string(isolate, "setRangeText"), + v8::FunctionTemplate::New(isolate, element_set_range_text)); element->PrototypeTemplate()->Set( js_string(isolate, "click"), v8::FunctionTemplate::New(isolate, element_click)); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc index ae326d69e..0bda1c557 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc @@ -748,6 +748,8 @@ control.value_initialized = true; control.selection_start = value.size(); control.selection_end = value.size(); + control.selection_start_utf16_suboffset = 0U; + control.selection_end_utf16_suboffset = 0U; control.selection_direction = text_selection_direction::none; } } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 0992543bb..a2661d9f6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -4443,6 +4443,83 @@ } } + static void element_set_range_text(const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto* node = unwrap_node(info.This()); + if (node == nullptr || !forms::supports_text_selection(node)) return; + forms::ensure_text_value(*node); + auto& control = node->mutable_form_control(); + const auto old_start = forms::utf16_offset_from_utf8_selection( + control.value, + {control.selection_start, control.selection_start_utf16_suboffset}); + const auto old_end = forms::utf16_offset_from_utf8_selection( + control.value, + {control.selection_end, control.selection_end_utf16_suboffset}); + const auto replacement = info.Length() > 0 + ? forms::utf16_units_from_utf8(to_wtf8(info.GetIsolate(), info[0])) + : std::vector{}; + auto start = old_start; + auto end = old_end; + const auto context = info.GetIsolate()->GetCurrentContext(); + if (info.Length() >= 3) { + start = static_cast(std::max( + 0, info[1]->Int32Value(context).FromMaybe(0))); + end = static_cast(std::max( + 0, info[2]->Int32Value(context).FromMaybe(0))); + } + auto units = forms::utf16_units_from_utf8(control.value); + start = std::min(start, units.size()); + end = std::min(end, units.size()); + if (start > end) std::swap(start, end); + units.erase( + units.begin() + static_cast(start), + units.begin() + static_cast(end)); + units.insert( + units.begin() + static_cast(start), + replacement.begin(), replacement.end()); + control.value = forms::wtf8_from_utf16_units(units); + control.value_initialized = true; + control.dirty_value = true; + + const auto replacement_end = start + replacement.size(); + auto selection_start = old_start; + auto selection_end = old_end; + const auto mode = info.Length() > 3 + ? to_utf8(info.GetIsolate(), info[3]) + : std::string{"preserve"}; + if (mode == "select") { + selection_start = start; + selection_end = replacement_end; + } else if (mode == "start") { + selection_start = selection_end = start; + } else if (mode == "end") { + selection_start = selection_end = replacement_end; + } else { + const auto delta = static_cast(replacement.size()) + - static_cast(end - start); + const auto adjust = [&](size_t position) { + if (position <= start) return position; + if (position >= end) return static_cast( + static_cast(position) + delta); + return start; + }; + selection_start = adjust(old_start); + selection_end = adjust(old_end); + } + const auto start_offset = forms::utf8_selection_from_utf16_offset( + control.value, selection_start); + const auto end_offset = forms::utf8_selection_from_utf16_offset( + control.value, selection_end); + control.selection_start = start_offset.byte_offset; + control.selection_start_utf16_suboffset = start_offset.utf16_suboffset; + control.selection_end = end_offset.byte_offset; + control.selection_end_utf16_suboffset = end_offset.utf16_suboffset; + control.selection_direction = text_selection_direction::none; + control.selection_explicitly_set = true; + self->document.mark_dirty(); + } + static void element_select(const v8::FunctionCallbackInfo& info) { auto* self = current(info.GetIsolate()); @@ -4458,7 +4535,8 @@ local_context, js_string(info.GetIsolate(), "value")).ToLocal(&value) && !value->IsNullOrUndefined()) { - length = static_cast(to_utf8(info.GetIsolate(), value).size()); + length = static_cast(decode_wtf8_to_utf16( + to_wtf8(info.GetIsolate(), value)).size()); } v8::Local arguments[] = { v8::Integer::New(info.GetIsolate(), 0), diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc index 09c7ea78b..db4ba3bd8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc @@ -182,6 +182,8 @@ control.dirty_value = false; control.selection_start = control.value.size(); control.selection_end = control.value.size(); + control.selection_start_utf16_suboffset = 0U; + control.selection_end_utf16_suboffset = 0U; control.selection_direction = text_selection_direction::none; } @@ -3129,10 +3131,14 @@ node->mutable_form_control().value_initialized = true; node->mutable_form_control().selection_start = node->mutable_form_control().value.size(); node->mutable_form_control().selection_end = node->mutable_form_control().value.size(); + node->mutable_form_control().selection_start_utf16_suboffset = 0U; + node->mutable_form_control().selection_end_utf16_suboffset = 0U; node->mutable_form_control().selection_direction = text_selection_direction::none; } } - info.GetReturnValue().Set(js_string(info.GetIsolate(), node->mutable_form_control().value.c_str())); + info.GetReturnValue().Set(forms::supports_text_selection(node) + ? js_dom_string(info.GetIsolate(), node->mutable_form_control().value) + : js_string(info.GetIsolate(), node->mutable_form_control().value.c_str())); } static void get_form_owner( @@ -3204,13 +3210,17 @@ } node->mutable_form_control().value = value->IsNullOrUndefined() ? std::string{} - : to_utf8(info.GetIsolate(), value); + : forms::supports_text_selection(node) + ? to_wtf8(info.GetIsolate(), value) + : to_utf8(info.GetIsolate(), value); node->mutable_form_control().value_initialized = true; if (node->tag == "textarea") { node->mutable_form_control().dirty_value = true; } node->mutable_form_control().selection_start = std::min(node->mutable_form_control().selection_start, node->mutable_form_control().value.size()); node->mutable_form_control().selection_end = std::min(node->mutable_form_control().selection_end, node->mutable_form_control().value.size()); + node->mutable_form_control().selection_start_utf16_suboffset = 0U; + node->mutable_form_control().selection_end_utf16_suboffset = 0U; if (node->mutable_form_control().selection_start == node->mutable_form_control().selection_end) { node->mutable_form_control().selection_direction = text_selection_direction::none; } @@ -3224,7 +3234,11 @@ auto* node = unwrap_node(info.Holder()); if (node != nullptr) { info.GetReturnValue().Set(v8::Integer::New( - info.GetIsolate(), static_cast(node->mutable_form_control().selection_start))); + info.GetIsolate(), static_cast( + forms::utf16_offset_from_utf8_selection( + node->mutable_form_control().value, + {node->mutable_form_control().selection_start, + node->mutable_form_control().selection_start_utf16_suboffset})))); } } @@ -3236,10 +3250,26 @@ auto* node = unwrap_node(info.Holder()); if (node == nullptr) return; const auto index = value->Int32Value(info.GetIsolate()->GetCurrentContext()).FromMaybe(0); - node->mutable_form_control().selection_start = std::min(std::max(0, index), node->mutable_form_control().value.size()); - node->mutable_form_control().selection_end = std::max(node->mutable_form_control().selection_end, node->mutable_form_control().selection_start); + const auto selection=forms::utf8_selection_from_utf16_offset( + node->mutable_form_control().value, + static_cast(std::max(0, index))); + node->mutable_form_control().selection_start=selection.byte_offset; + node->mutable_form_control().selection_start_utf16_suboffset= + selection.utf16_suboffset; + const forms::utf8_selection_offset end{ + node->mutable_form_control().selection_end, + node->mutable_form_control().selection_end_utf16_suboffset}; + if(forms::selection_offset_less(end,selection)) { + node->mutable_form_control().selection_end=selection.byte_offset; + node->mutable_form_control().selection_end_utf16_suboffset= + selection.utf16_suboffset; + } node->mutable_form_control().selection_explicitly_set = true; - if (node->mutable_form_control().selection_start == node->mutable_form_control().selection_end) { + if (forms::selection_offset_equal( + {node->mutable_form_control().selection_start, + node->mutable_form_control().selection_start_utf16_suboffset}, + {node->mutable_form_control().selection_end, + node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } } @@ -3251,7 +3281,11 @@ auto* node = unwrap_node(info.Holder()); if (node != nullptr) { info.GetReturnValue().Set(v8::Integer::New( - info.GetIsolate(), static_cast(node->mutable_form_control().selection_end))); + info.GetIsolate(), static_cast( + forms::utf16_offset_from_utf8_selection( + node->mutable_form_control().value, + {node->mutable_form_control().selection_end, + node->mutable_form_control().selection_end_utf16_suboffset})))); } } @@ -3263,10 +3297,26 @@ auto* node = unwrap_node(info.Holder()); if (node == nullptr) return; const auto index = value->Int32Value(info.GetIsolate()->GetCurrentContext()).FromMaybe(0); - node->mutable_form_control().selection_end = std::min(std::max(0, index), node->mutable_form_control().value.size()); - node->mutable_form_control().selection_start = std::min(node->mutable_form_control().selection_start, node->mutable_form_control().selection_end); + const auto selection=forms::utf8_selection_from_utf16_offset( + node->mutable_form_control().value, + static_cast(std::max(0, index))); + node->mutable_form_control().selection_end=selection.byte_offset; + node->mutable_form_control().selection_end_utf16_suboffset= + selection.utf16_suboffset; + const forms::utf8_selection_offset start{ + node->mutable_form_control().selection_start, + node->mutable_form_control().selection_start_utf16_suboffset}; + if(forms::selection_offset_less(selection,start)) { + node->mutable_form_control().selection_start=selection.byte_offset; + node->mutable_form_control().selection_start_utf16_suboffset= + selection.utf16_suboffset; + } node->mutable_form_control().selection_explicitly_set = true; - if (node->mutable_form_control().selection_start == node->mutable_form_control().selection_end) { + if (forms::selection_offset_equal( + {node->mutable_form_control().selection_start, + node->mutable_form_control().selection_start_utf16_suboffset}, + {node->mutable_form_control().selection_end, + node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } } @@ -3302,7 +3352,11 @@ ? text_selection_direction::backward : text_selection_direction::none; node->mutable_form_control().selection_explicitly_set = true; - if (node->mutable_form_control().selection_start == node->mutable_form_control().selection_end) { + if (forms::selection_offset_equal( + {node->mutable_form_control().selection_start, + node->mutable_form_control().selection_start_utf16_suboffset}, + {node->mutable_form_control().selection_end, + node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc index e01e4446d..ca74c1fcd 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc @@ -316,6 +316,8 @@ form.dirty_value = false; form.selection_start = form.value.size(); form.selection_end = form.value.size(); + form.selection_start_utf16_suboffset = 0U; + form.selection_end_utf16_suboffset = 0U; form.selection_direction = text_selection_direction::none; } } else if (node.tag == "textarea") { @@ -568,6 +570,8 @@ node.mutable_form_control().dirty_value = false; node.mutable_form_control().selection_start = node.mutable_form_control().value.size(); node.mutable_form_control().selection_end = node.mutable_form_control().value.size(); + node.mutable_form_control().selection_start_utf16_suboffset = 0U; + node.mutable_form_control().selection_end_utf16_suboffset = 0U; node.mutable_form_control().selection_direction = text_selection_direction::none; } } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index f060f2a6f..6d9020e43 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2301,6 +2301,8 @@ next->form_control().value.size(); next->mutable_form_control().selection_end = next->form_control().value.size(); + next->mutable_form_control().selection_start_utf16_suboffset = 0U; + next->mutable_form_control().selection_end_utf16_suboffset = 0U; next->mutable_form_control().selection_direction = text_selection_direction::none; next->mutable_form_control().selection_explicitly_set = true; @@ -2453,14 +2455,36 @@ if (prevented) return true; ensure_form_value(*target); const auto text = utf8_scalar(static_cast(input.x)); - const auto start = std::min(target->mutable_form_control().selection_start, target->mutable_form_control().value.size()); - const auto end = std::min( - std::max(target->mutable_form_control().selection_start, target->mutable_form_control().selection_end), - target->mutable_form_control().value.size()); - target->mutable_form_control().value.replace(start, end - start, text); + const forms::utf8_selection_offset start{ + std::min(target->mutable_form_control().selection_start, + target->mutable_form_control().value.size()), + target->mutable_form_control().selection_start_utf16_suboffset}; + const forms::utf8_selection_offset end{ + std::min(target->mutable_form_control().selection_end, + target->mutable_form_control().value.size()), + target->mutable_form_control().selection_end_utf16_suboffset}; + auto units=forms::utf16_units_from_utf8( + target->mutable_form_control().value); + const auto start_units=forms::utf16_offset_from_utf8_selection( + target->mutable_form_control().value,start); + const auto end_units=forms::utf16_offset_from_utf8_selection( + target->mutable_form_control().value,end); + const auto inserted_units=forms::utf16_units_from_utf8(text); + units.erase(units.begin()+static_cast(start_units), + units.begin()+static_cast(end_units)); + units.insert(units.begin()+static_cast(start_units), + inserted_units.begin(),inserted_units.end()); + target->mutable_form_control().value=forms::wtf8_from_utf16_units(units); target->mutable_form_control().dirty_value = true; - target->mutable_form_control().selection_start = start + text.size(); - target->mutable_form_control().selection_end = target->mutable_form_control().selection_start; + const auto caret=forms::utf8_selection_from_utf16_offset( + target->mutable_form_control().value, + start_units+inserted_units.size()); + target->mutable_form_control().selection_start = caret.byte_offset; + target->mutable_form_control().selection_end = caret.byte_offset; + target->mutable_form_control().selection_start_utf16_suboffset = + caret.utf16_suboffset; + target->mutable_form_control().selection_end_utf16_suboffset = + caret.utf16_suboffset; target->mutable_form_control().selection_direction = text_selection_direction::none; target->mutable_form_control().selection_explicitly_set = true; target->mutable_form_control().caret_visible = true; @@ -2581,6 +2605,23 @@ const auto clipboard_modifier = (input.flags & (WEBSCENE_INPUT_MODIFIER_CONTROL | WEBSCENE_INPUT_MODIFIER_META)) != 0U && (input.flags & WEBSCENE_INPUT_MODIFIER_ALT) == 0U; + if (!prevented && clipboard_modifier + && (key_code == 'A' || key_code == 'a') + && is_text_control(target)) { + ensure_form_value(*target); + target->mutable_form_control().selection_start = 0U; + target->mutable_form_control().selection_end = + target->mutable_form_control().value.size(); + target->mutable_form_control().selection_start_utf16_suboffset = 0U; + target->mutable_form_control().selection_end_utf16_suboffset = 0U; + target->mutable_form_control().selection_direction = + text_selection_direction::none; + target->mutable_form_control().selection_explicitly_set = true; + target->mutable_form_control().caret_visible = true; + caret_blink_epoch_ms = last_animation_frame_timestamp_ms; + document.mark_dirty(); + return true; + } if (!prevented && clipboard_modifier) { if (key_code == 'C' || key_code == 'c') return dispatch_clipboard_shortcut(*target, "copy"); @@ -2625,6 +2666,8 @@ ? end : boundary; target->mutable_form_control().selection_start = target->mutable_form_control().selection_end = position; + target->mutable_form_control().selection_start_utf16_suboffset = 0U; + target->mutable_form_control().selection_end_utf16_suboffset = 0U; target->mutable_form_control().selection_direction = text_selection_direction::none; } else { const auto anchor = @@ -2644,6 +2687,8 @@ : next_utf8_boundary(target->mutable_form_control().value, focus); target->mutable_form_control().selection_start = std::min(anchor, next_focus); target->mutable_form_control().selection_end = std::max(anchor, next_focus); + target->mutable_form_control().selection_start_utf16_suboffset = 0U; + target->mutable_form_control().selection_end_utf16_suboffset = 0U; target->mutable_form_control().selection_direction = next_focus < anchor ? text_selection_direction::backward : next_focus > anchor @@ -2666,6 +2711,8 @@ target->mutable_form_control().dirty_value = true; target->mutable_form_control().selection_start = target->mutable_form_control().selection_end = start; + target->mutable_form_control().selection_start_utf16_suboffset = 0U; + target->mutable_form_control().selection_end_utf16_suboffset = 0U; target->mutable_form_control().selection_direction = text_selection_direction::none; target->mutable_form_control().selection_explicitly_set = true; if (!dispatch_input_event_type(input, "input", *target)) return false; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 7aa41f28e..07ccb24a5 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -384,6 +384,308 @@ void keyboard_input( require(webscene_engine_enqueue(engine, &input) != 0, "keyboard input was rejected"); } +void test_text_control_select_all_default_action(webscene_engine* engine) +{ +#if defined(__APPLE__) || defined(__linux__) + const auto peak_rss_bytes = [] { + rusage usage{}; + if (getrusage(RUSAGE_SELF, &usage) != 0) return uint64_t{0}; +#if defined(__APPLE__) + return static_cast(usage.ru_maxrss); +#else + return static_cast(usage.ru_maxrss) * 1024U; +#endif + }; +#else + const auto peak_rss_bytes = [] { return uint64_t{0}; }; +#endif + resize(engine, 640, 360, 97700U); + execute_and_wait(engine, R"JS( + (() => { + document.body.innerHTML = ` + + + + + + + + `; + const cancelled = document.getElementById('cancelled'); + cancelled.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key === 'A') { + event.preventDefault(); + } + }); + const performance = document.getElementById('performance'); + globalThis.__selectAllPerformanceEvents = 0; + performance.addEventListener('keydown', event => { + ++__selectAllPerformanceEvents; + if ((__selectAllPerformanceEvents % 10) === 0) event.preventDefault(); + }); + document.getElementById('path').focus(); + })() + )JS", "text-control-select-all-setup.js"); + + uint64_t sequence = 97701U; + const auto send_and_wait = [&](uint32_t kind, uint32_t value, uint32_t flags) { + const auto consumed_before = consumed_input_count(engine); + keyboard_input(engine, kind, value, sequence++, flags); + wait_for_consumed_inputs( + engine, consumed_before + 1U, + "text-control select-all input was not consumed"); + }; + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_META); + auto result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + return { + start: input.selectionStart, + end: input.selectionEnd, + length: input.value.length + }; + })())JS", "text-control-select-all-meta-result.js"); + require( + result == R"JSON({"start":0,"end":33,"length":33})JSON", + "Meta+A did not select the complete Unicode path value: " + result); + send_and_wait(WEBSCENE_INPUT_TEXT, 'Z', 0U); + require( + evaluate(engine, "({value: document.getElementById('path').value})", + "text-control-select-all-meta-replacement.js") + == R"JSON({"value":"Z"})JSON", + "text after Meta+A did not replace the complete path value"); + + execute_and_wait(engine, R"JS((() => { + const input = document.getElementById('path'); + input.value = 'Aβ🙂Z'; + input.focus(); + input.setSelectionRange(2, 4); + })())JS", "text-control-select-all-unicode-range.js"); + result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + return {selection: [input.selectionStart, input.selectionEnd], + length: input.value.length}; + })())JS", "text-control-select-all-unicode-range-result.js"); + require( + result == R"JSON({"selection":[2,4],"length":5})JSON", + "UTF-16 selection indices diverged from Chromium: " + result); + send_and_wait(WEBSCENE_INPUT_TEXT, 'R', 0U); + require( + evaluate(engine, "({value: document.getElementById('path').value})", + "text-control-select-all-unicode-range-replacement.js") + == R"JSON({"value":"AβRZ"})JSON", + "UTF-16 selection did not map to safe UTF-8 replacement boundaries"); + + execute_and_wait(engine, R"JS((() => { + const input = document.getElementById('path'); + input.value = 'A🙂Z'; + input.focus(); + input.setSelectionRange(2, 2); + })())JS", "text-control-mid-surrogate-range.js"); + result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + return {selection: [input.selectionStart, input.selectionEnd], + length: input.value.length}; + })())JS", "text-control-mid-surrogate-range-result.js"); + require( + result == R"JSON({"selection":[2,2],"length":4})JSON", + "mid-surrogate UTF-16 caret was clamped: " + result); + result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + input.setRangeText('R', input.selectionStart, input.selectionEnd, 'end'); + return {selection: [input.selectionStart, input.selectionEnd], + length: input.value.length, + units: Array.from({length: input.value.length}, + (_, index) => input.value.charCodeAt(index))}; + })())JS", "text-control-mid-surrogate-replacement.js"); + require( + result == R"JSON({"selection":[3,3],"length":5,"units":[65,55357,82,56898,90]})JSON", + "mid-surrogate replacement diverged from Chromium: " + result); + + execute_and_wait(engine, R"JS((() => { + const textarea = document.getElementById('notes'); + textarea.focus(); + textarea.setSelectionRange(4, 4); + })())JS", "text-control-select-all-textarea-focus.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_CONTROL); + send_and_wait(WEBSCENE_INPUT_TEXT, 'Y', 0U); + require( + evaluate(engine, "({value: document.getElementById('notes').value})", + "text-control-select-all-control-replacement.js") + == R"JSON({"value":"Y"})JSON", + "text after Ctrl+A did not replace the complete textarea value"); + + execute_and_wait(engine, R"JS((() => { + const input = document.getElementById('cancelled'); + input.focus(); + input.setSelectionRange(2, 4); + })())JS", "text-control-select-all-cancel-focus.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_META); + result = evaluate(engine, R"JS([ + document.getElementById('cancelled').selectionStart, + document.getElementById('cancelled').selectionEnd + ])JS", "text-control-select-all-cancel-result.js"); + require(result == "[2,4]", "preventDefault did not cancel select-all: " + result); + + execute_and_wait(engine, R"JS((() => { + const input = document.getElementById('path'); + input.value = 'plain'; + input.focus(); + input.setSelectionRange(1, 2); + })())JS", "text-control-select-all-negative-focus.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', + WEBSCENE_INPUT_MODIFIER_CONTROL | WEBSCENE_INPUT_MODIFIER_ALT); + result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + return [input.selectionStart, input.selectionEnd]; + })())JS", "text-control-select-all-alt-result.js"); + require(result == "[1,2]", "Alt-modified A widened the selection: " + result); + send_and_wait(WEBSCENE_INPUT_KEY_DOWN, 'A', 0U); + result = evaluate(engine, R"JS((() => { + const input = document.getElementById('path'); + return [input.selectionStart, input.selectionEnd]; + })())JS", "text-control-select-all-plain-result.js"); + require(result == "[1,2]", "plain A widened the selection: " + result); + + execute_and_wait(engine, R"JS((() => { + const path = document.getElementById('path'); + const disabled = document.getElementById('disabled'); + disabled.setSelectionRange(1, 3); + path.focus(); + disabled.focus(); + })())JS", "text-control-select-all-negative-targets.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_META); + result = evaluate(engine, R"JS((() => { + const disabled = document.getElementById('disabled'); + return {selection: [disabled.selectionStart, disabled.selectionEnd], + active: document.activeElement.id}; + })())JS", "text-control-select-all-disabled-result.js"); + require( + result == R"JSON({"selection":[1,3],"active":"path"})JSON", + "disabled input accepted focus or select-all: " + result); + + execute_and_wait(engine, R"JS((() => { + const path = document.getElementById('path'); + const inert = document.getElementById('inert'); + inert.setSelectionRange(1, 3); + path.focus(); + inert.focus(); + })())JS", "text-control-select-all-inert-target.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_META); + result = evaluate(engine, R"JS((() => { + const inert = document.getElementById('inert'); + return {selection: [inert.selectionStart, inert.selectionEnd], + active: document.activeElement.id}; + })())JS", "text-control-select-all-inert-result.js"); + require( + result == R"JSON({"selection":[1,3],"active":"path"})JSON", + "inert input accepted focus or select-all: " + result); + + execute_and_wait(engine, + "document.getElementById('button').focus()", + "text-control-select-all-button-target.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_META); + result = evaluate(engine, "document.activeElement.id", + "text-control-select-all-button-result.js"); + require(result == R"JSON("button")JSON", + "non-text button did not retain focus after Meta+A: " + result); + + execute_and_wait(engine, + "document.getElementById('empty').focus()", + "text-control-select-all-empty-target.js"); + send_and_wait( + WEBSCENE_INPUT_KEY_DOWN, 'A', WEBSCENE_INPUT_MODIFIER_CONTROL); + result = evaluate(engine, R"JS((() => { + const empty = document.getElementById('empty'); + return [empty.selectionStart, empty.selectionEnd]; + })())JS", "text-control-select-all-empty-result.js"); + require(result == "[0,0]", "empty select-all changed selection: " + result); + + execute_and_wait(engine, R"JS((() => { + const input = document.getElementById('performance'); + input.value = 'x'; + input.focus(); + input.setSelectionRange(1, 1); + })())JS", "text-control-select-all-performance-setup.js"); + webscene_engine_memory_metrics memory_before{ + sizeof(webscene_engine_memory_metrics)}; + require(webscene_engine_get_memory_metrics(engine, &memory_before) != 0, + "select-all memory baseline was unavailable"); + const auto rss_before = peak_rss_bytes(); + constexpr uint64_t cycles = 10000U; + const auto consumed_before = consumed_input_count(engine); + const auto started = std::chrono::steady_clock::now(); + constexpr uint64_t batch_cycles = 256U; + for (uint64_t offset = 0; offset < cycles; offset += batch_cycles) { + const auto count = std::min(batch_cycles, cycles - offset); + const auto batch_consumed_before = consumed_input_count(engine); + for (uint64_t index = 0; index < count; ++index) { + keyboard_input( + engine, WEBSCENE_INPUT_KEY_DOWN, 'A', sequence++, + ((offset + index) & 1U) == 0U + ? WEBSCENE_INPUT_MODIFIER_META + : WEBSCENE_INPUT_MODIFIER_CONTROL); + keyboard_input(engine, WEBSCENE_INPUT_TEXT, 'x', sequence++); + } + wait_for_consumed_inputs( + engine, batch_consumed_before + count * 2U, + "select/replace/cancel batch was not consumed"); + } + wait_for_consumed_inputs( + engine, consumed_before + cycles * 2U, + "10,000 select/replace/cancel cycles were not consumed"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 1.0, + "10,000 select/replace/cancel cycles exceeded one second"); + result = evaluate(engine, R"JS(({ + value: document.getElementById('performance').value, + events: __selectAllPerformanceEvents, + nodes: document.querySelectorAll('*').length + }))JS", "text-control-select-all-performance-result.js"); + require( + result == R"JSON({"value":"xx","events":10000,"nodes":9})JSON", + "select-all lifecycle state was not bounded after 10,000 cycles: " + result); + + require(webscene_engine_request_low_memory(engine) != 0, + "select-all low-memory request was rejected"); + webscene_engine_memory_metrics memory_after{ + sizeof(webscene_engine_memory_metrics)}; + for (auto attempt = 0; attempt < 250; ++attempt) { + require(webscene_engine_get_memory_metrics(engine, &memory_after) != 0, + "select-all final memory metrics were unavailable"); + if (memory_after.low_memory_notifications + > memory_before.low_memory_notifications) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require( + memory_after.low_memory_notifications + > memory_before.low_memory_notifications + && memory_after.v8_used_heap_bytes + <= memory_before.v8_used_heap_bytes + 8U * 1024U * 1024U + && memory_after.native_dom_node_count + <= memory_before.native_dom_node_count + 4U, + "select-all 10,000-cycle heap/DOM lifecycle gate exceeded its bound"); + const auto rss_after = peak_rss_bytes(); + require( + rss_before == 0U || rss_after <= rss_before + 64U * 1024U * 1024U, + "select-all 10,000-cycle RSS growth exceeded 64 MiB"); + std::cout << "Text-control select-all gate: cycles=" << cycles + << " seconds=" << elapsed + << " heapBefore=" << memory_before.v8_used_heap_bytes + << " heapAfter=" << memory_after.v8_used_heap_bytes + << " nodesBefore=" << memory_before.native_dom_node_count + << " nodesAfter=" << memory_after.native_dom_node_count + << " rssBefore=" << rss_before + << " rssAfter=" << rss_after << '\n'; +} + void test_dom_punctuation_keyboard_event_identity(webscene_engine* engine) { constexpr std::array punctuation_codes{ diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 24a1482b6..e786d64a5 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -238,6 +238,14 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "text-control-select-all") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "text-control select-all engine creation failed"); + test_text_control_select_all_default_action(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if (selected == "shortcut-activation") { auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, @@ -1357,6 +1365,7 @@ int main() #endif test_native_text_input_focus_events_and_caret(engine); test_dom_punctuation_keyboard_event_identity(engine); + test_text_control_select_all_default_action(engine); test_native_shortcut_activation_focus_retarget_and_publication(engine); test_chart_printable_key_does_not_duplicate_into_newly_focused_search_input(engine); test_svg_dom_parser_preserves_fill_rule(engine); diff --git a/tools/webidl-v8-bindings/dom-exposure.json b/tools/webidl-v8-bindings/dom-exposure.json index fa9f4cf76..e61701de8 100644 --- a/tools/webidl-v8-bindings/dom-exposure.json +++ b/tools/webidl-v8-bindings/dom-exposure.json @@ -315,6 +315,7 @@ { "name": "blur", "callback": "element_blur", "length": 0 }, { "name": "select", "callback": "element_select", "length": 0 }, { "name": "setSelectionRange", "callback": "element_set_selection_range", "length": 2 }, + { "name": "setRangeText", "callback": "element_set_range_text", "length": 1 }, { "name": "click", "callback": "element_click", "length": 0 }, { "name": "reset", "callback": "form_reset", "length": 0 } ],