fix(editing): distinguish input and textarea InsertHTML behavior

This commit is contained in:
ldm0
2026-10-13 23:01:28 +08:00
committed by Donough Liu
parent 3239d00706
commit 315b2e6c25
10 changed files with 416 additions and 70 deletions
@@ -34,6 +34,7 @@ mod detached_install;
mod detached_objects;
mod detached_surface;
mod hit_test;
mod insert_html;
mod lifecycle;
mod live_collections;
mod queries;
@@ -0,0 +1,213 @@
use super::JsContextHost;
use crate::{
document_runtime::DomHandle,
dom::native::{DomHost, NativeDom, Node, NodeData},
parser::HtmlParser,
style_engine::{
ComputedDisplayKind, FullStyleWorldSnapshot, MoliStyleEngine,
OwnedStyleSourceDocumentContext, StyleViewport, StyloStylesheetSource,
},
};
pub(super) fn text_control_text_from_html(
runtime: &JsContextHost,
target: DomHandle,
markup: &str,
) -> String {
let parsed =
HtmlParser::with_scripting_enabled(runtime.node_document_scripting_enabled(target))
.parse_fragment_without_declarative_shadow_roots(
runtime.host_document().url().clone(),
"http://www.w3.org/1999/xhtml",
"body",
markup.to_owned(),
);
let root = parsed
.body_node_id()
.or_else(|| parsed.document_element_node_id())
.unwrap_or_else(|| parsed.document_node_id());
let multiline = runtime
.dom_host()
.node(target)
.and_then(Node::as_element)
.is_some_and(|element| element.is_html_textarea());
if multiline {
textarea_fragment_text(parsed, root)
} else {
input_fragment_text(&parsed, root)
}
}
fn input_fragment_text(dom: &NativeDom, root: DomHandle) -> String {
// Blink's input path uses textContent with br-to-newline conversion. Hidden
// elements and script/style source therefore remain part of the text.
let mut text = String::new();
let mut pending = dom.child_ids_reversed(root).collect::<Vec<_>>();
while let Some(handle) = pending.pop() {
let Some(node) = dom.node(handle) else {
continue;
};
if node.is_html_element_named("br") {
text.push('\n');
continue;
}
match node.data() {
NodeData::Text(value) => text.push_str(value.data()),
NodeData::CDataSection(value) => text.push_str(value.data()),
_ => pending.extend(dom.child_ids_reversed(handle)),
}
}
text
}
fn textarea_fragment_text(dom: NativeDom, root: DomHandle) -> String {
// Resolve styles in an inert DOM: no resource loading, script execution,
// custom-element reactions, or mutations visible in the caller's document.
let host = DomHost::from_dom(dom);
let document = host.document_handle();
let context = OwnedStyleSourceDocumentContext::new(document);
let url = host.dom().final_url().expect("parsed fragment URL");
let engine = MoliStyleEngine::new();
let inputs = FullStyleWorldSnapshot {
document_stylesheet_sources: host
.dom()
.nodes()
.iter()
.filter(|node| node.is_connected() && node.is_html_element_named("style"))
.map(|node| StyloStylesheetSource::new(node.text_content(host.dom()), url.clone()))
.collect(),
..FullStyleWorldSnapshot::default()
};
let mut writer = TextareaFragmentText::default();
let mut pending = host
.child_handles_reversed(root)
.map(FragmentTextTask::Node)
.collect::<Vec<_>>();
while let Some(task) = pending.pop() {
let handle = match task {
FragmentTextTask::Node(handle) => handle,
FragmentTextTask::Break(count) => {
writer.require_breaks(count);
continue;
}
FragmentTextTask::EndCell => {
writer.pending_tab = true;
continue;
}
};
let Some(node) = host.node(handle) else {
continue;
};
if let Some(text) = node.as_text() {
writer.push_text(text.data());
continue;
}
let Some(element) = node.as_element() else {
continue;
};
let Some(style) = engine.computed_style_snapshot_after_style_update_with_document_context(
&host,
url,
handle,
&inputs,
context.as_ref(),
document,
StyleViewport::default(),
) else {
continue;
};
let style = style.rendered_style_facts();
if style.display == ComputedDisplayKind::None || style.hides_contents {
continue;
}
if element.is_html_element("br") {
writer.push_br();
continue;
}
// Like Blink's fragment TextIterator, retain original text, including
// whitespace, and ignore visibility and text-transform.
let breaks = match style.display {
ComputedDisplayKind::Block if element.is_html_element("p") => 2,
ComputedDisplayKind::Block
| ComputedDisplayKind::ListItem
| ComputedDisplayKind::Table
| ComputedDisplayKind::TableRow => 1,
_ => 0,
};
if breaks != 0 {
writer.require_breaks(breaks);
pending.push(FragmentTextTask::Break(breaks));
} else if style.display == ComputedDisplayKind::TableCell {
pending.push(FragmentTextTask::EndCell);
}
pending.extend(
host.child_handles_reversed(handle)
.map(FragmentTextTask::Node),
);
}
writer.finish()
}
enum FragmentTextTask {
Node(DomHandle),
Break(u8),
EndCell,
}
#[derive(Default)]
struct TextareaFragmentText {
text: String,
pending_breaks: u8,
pending_tab: bool,
ends_with_br: bool,
}
impl TextareaFragmentText {
fn require_breaks(&mut self, count: u8) {
self.pending_breaks = self.pending_breaks.max(count);
self.pending_tab = false;
}
fn flush_boundaries(&mut self) {
if !self.text.is_empty() {
let trailing_breaks = self
.text
.chars()
.rev()
.take(self.pending_breaks as usize)
.take_while(|ch| *ch == '\n')
.count();
for _ in trailing_breaks..self.pending_breaks as usize {
self.text.push('\n');
}
if self.pending_tab {
self.text.push('\t');
}
}
self.pending_breaks = 0;
self.pending_tab = false;
}
fn push_text(&mut self, text: &str) {
if !text.is_empty() {
self.flush_boundaries();
self.text.push_str(text);
self.ends_with_br = false;
}
}
fn push_br(&mut self) {
self.flush_boundaries();
self.text.push('\n');
self.ends_with_br = true;
}
fn finish(mut self) -> String {
// A final br in a rendered fragment is the last line's placeholder;
// authored newlines in a text node remain part of the inserted value.
if self.ends_with_br {
self.text.pop();
}
self.text
}
}
@@ -10,13 +10,12 @@ use crate::native_bridge::element::{
TextEditInputType, 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,
replace_text_control_selection, replace_text_control_selection_for_insert_html,
text_control_value,
};
use crate::{
custom_elements,
document_runtime::DomHandle,
dom::native::{NativeDom, NodeData},
parser::HtmlParser,
util::{
call_object_method, node_wrapper_from_handle, utf16_next_scalar_boundary,
utf16_previous_scalar_boundary, utf16_replace_units_range_lossy,
@@ -681,14 +680,8 @@ fn exec_command_insert_html(
let Some(target) = exec_command_insert_html_target(runtime) else {
return false;
};
let insertion_text = input_text_from_html_fragment(runtime, target, value);
replace_text_control_selection(
scope,
runtime_ptr,
target,
&insertion_text,
TextEditInputType::InsertText,
)
let insertion_text = super::insert_html::text_control_text_from_html(runtime, target, value);
replace_text_control_selection_for_insert_html(scope, runtime_ptr, target, &insertion_text)
}
fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option<DomHandle> {
@@ -705,49 +698,6 @@ fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option<DomHandle>
Some(handle)
}
fn input_text_from_html_fragment(
runtime: &JsContextHost,
target: DomHandle,
value: &str,
) -> String {
let parsed =
HtmlParser::with_scripting_enabled(runtime.node_document_scripting_enabled(target))
.parse_fragment_without_declarative_shadow_roots(
runtime.host_document().url().clone(),
"http://www.w3.org/1999/xhtml",
"body",
value.to_owned(),
);
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,
@@ -401,7 +401,7 @@ pub(crate) use forms::{control_label_handles, v8_pattern_is_usable};
pub(crate) use forms::{
dispatch_text_control_event, is_text_control, perform_implicit_submission_from_control,
queue_text_control_document_selection_change_event, replace_text_control_selection,
text_control_set_selection_range_internal,
replace_text_control_selection_for_insert_html, text_control_set_selection_range_internal,
text_control_set_selection_range_with_direction_internal, text_control_value,
};
pub(in crate::native_bridge) use forms::{
@@ -136,6 +136,7 @@ struct InputEventInitDeclaration<'scope> {
#[derive(Clone, Copy)]
pub(crate) enum TextEditInputType {
None,
InsertText,
InsertLineBreak,
InsertFromDrop,
@@ -146,6 +147,7 @@ pub(crate) enum TextEditInputType {
impl TextEditInputType {
fn as_str(self) -> &'static str {
match self {
Self::None => "",
Self::InsertText => "insertText",
Self::InsertLineBreak => "insertLineBreak",
Self::InsertFromDrop => "insertFromDrop",
@@ -157,9 +159,10 @@ impl TextEditInputType {
pub(crate) fn data(self, text: &str) -> Option<&str> {
match self {
Self::InsertText | Self::InsertFromDrop => Some(text),
Self::InsertLineBreak | Self::DeleteContentBackward | Self::DeleteContentForward => {
None
}
Self::None
| Self::InsertLineBreak
| Self::DeleteContentBackward
| Self::DeleteContentForward => None,
}
}
}
@@ -234,7 +234,7 @@ pub(crate) use self::submission::{
pub(crate) use self::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,
replace_text_control_selection_for_insert_html, text_control_set_selection_range_internal,
text_control_set_selection_range_with_direction_internal, text_control_value,
};
pub(in crate::native_bridge) use self::text_control::{
@@ -8,7 +8,8 @@ pub(crate) use events::{
dispatch_text_control_event, queue_text_control_document_selection_change_event,
};
pub(crate) use selection::{
replace_text_control_selection, text_control_set_selection_range_internal,
replace_text_control_selection, replace_text_control_selection_for_insert_html,
text_control_set_selection_range_internal,
text_control_set_selection_range_with_direction_internal,
};
pub(in crate::native_bridge) use selection::{
@@ -121,18 +121,73 @@ pub(crate) fn replace_text_control_selection(
handle: DomHandle,
replacement_text: &str,
input_type: TextEditInputType,
) -> bool {
replace_text_control_selection_with_events(
scope,
runtime_ptr,
handle,
replacement_text,
input_type,
TextControlEditEvents::BeforeInputAndInput,
)
}
pub(crate) fn replace_text_control_selection_for_insert_html(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
replacement_text: &str,
) -> bool {
let multiline = unsafe { &*runtime_ptr }
.dom_host()
.node(handle)
.and_then(Node::as_element)
.is_some_and(|element| element.is_html_textarea());
// DOM InsertHTML skips beforeinput. Textareas use the fragment editing
// command's unspecified inputType and null data, including for empty edits.
replace_text_control_selection_with_events(
scope,
runtime_ptr,
handle,
replacement_text,
if multiline {
TextEditInputType::None
} else {
TextEditInputType::InsertText
},
TextControlEditEvents::InputOnly {
include_noop: multiline,
},
)
}
#[derive(Clone, Copy)]
enum TextControlEditEvents {
BeforeInputAndInput,
InputOnly { include_noop: bool },
}
fn replace_text_control_selection_with_events(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
replacement_text: &str,
input_type: TextEditInputType,
events: TextControlEditEvents,
) -> bool {
if !is_text_control(unsafe { &*runtime_ptr }, handle) {
return false;
}
if !dispatch_beforeinput(
scope,
runtime_ptr,
handle,
input_type,
input_type.data(replacement_text),
) {
if matches!(events, TextControlEditEvents::BeforeInputAndInput)
&& !dispatch_beforeinput(
scope,
runtime_ptr,
handle,
input_type,
input_type.data(replacement_text),
)
{
return false;
}
@@ -200,6 +255,18 @@ pub(crate) fn replace_text_control_selection(
end,
replacement_text,
);
if matches!(
events,
TextControlEditEvents::InputOnly {
include_noop: false
}
) && start == end
&& replacement_units.is_empty()
{
// The value setter also reports dirty/user-edited state changes. An
// empty scripted input insertion must not turn those into an input event.
return true;
}
let next_value = utf16_replace_units_range_lossy(
&value_units,
start,
@@ -215,7 +282,13 @@ pub(crate) fn replace_text_control_selection(
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 {
if changed
|| selection_changed
|| matches!(
events,
TextControlEditEvents::InputOnly { include_noop: true }
)
{
// beforeinput describes the request; input describes the text actually
// inserted after single-line normalization and maxlength truncation.
let inserted_text = String::from_utf16_lossy(&replacement_units);
@@ -225,7 +298,7 @@ pub(crate) fn replace_text_control_selection(
let _ = dispatch_public_event(scope, runtime_ptr, handle, event);
}
}
changed || selection_changed
changed || selection_changed || matches!(events, TextControlEditEvents::InputOnly { .. })
}
fn text_control_user_edit_replacement_units(
@@ -1322,7 +1322,21 @@ fn exec_command_insert_html_enforces_maxlength_in_utf16_units() {
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}"#
r#"{"supported":true,"enabledBeforeFocus":false,"enabled":true,"returned":true,"value":"👨‍👩‍👧‍","valueLength":9,"selectionStart":9,"selectionEnd":9,"events":["input"],"markupReturned":true,"markupValue":"A BC&","rangeValue":"AxB","rangeSelection":[2,2],"enabledReadonly":false,"readonlyReturned":false}"#
);
}
#[test]
fn exec_command_insert_html_matches_chromium_text_control_matrix() {
let mut vm = new_storage_test_vm("https://exec-command-insert-html-matrix.test/");
let failures = vm
.eval(include_str!(
"../../../../tests/fixtures/exec-command-insert-html.js"
))
.expect("InsertHTML text control matrix should evaluate");
assert_eq!(
failures, "[]",
"InsertHTML compatibility failures: {failures}"
);
}
@@ -0,0 +1,91 @@
// Expectations verified in Chromium 145.0.7632.116. This expression can also
// run directly in a browser to compare its editing behavior with Moli.
(() => {
const root = document.documentElement || document.appendChild(document.createElement('html'));
const body = document.body || root.appendChild(document.createElement('body'));
const failures = [];
const cases = [
['blocks', '<div>A</div><div>B</div>', 'AB', 'A\nB'],
['paragraphs', '<p>A</p><p>B</p>', 'AB', 'A\n\nB'],
['nested-blocks', '<div>A<div>B</div>C</div>D', 'ABCD', 'A\nB\nC\nD'],
['empty-block', 'A<div></div>B', 'AB', 'A\nB'],
['leading-empty-block', '<div></div><div>B</div>', 'B', 'B'],
['trailing-empty-block', '<div>A</div><div></div>', 'A', 'A'],
['br', 'A<br>B', 'A B', 'A\nB'],
['leading-br', '<br>A', ' A', '\nA'],
['trailing-br', 'A<br>', 'A', 'A'],
['two-brs', 'A<br><br>B', 'A B', 'A\n\nB'],
['two-trailing-brs', 'A<br><br>', 'A', 'A\n'],
['block-br', '<div>A<br></div><div>B</div>', 'A B', 'A\nB'],
['empty-line', 'A<div><br></div>B', 'A B', 'A\n\nB'],
['hidden', 'A<span hidden>H</span>B', 'AHB', 'AB'],
['hidden-subtree', 'A<div hidden><div>H</div><br>X</div>B', 'AH XB', 'AB'],
['display-none', 'A<span style="display:none">H</span>B', 'AHB', 'AB'],
['hidden-br', 'A<br hidden>B', 'A B', 'AB'],
['visibility-hidden', 'A<span style="visibility:hidden">H</span>B', 'AHB', 'AHB'],
['script', 'A<script>globalThis.__insertHtmlScriptRan = true;</script>B',
'AglobalThis.__insertHtmlScriptRan = true;B', 'AB'],
['style', 'A<style>p{color:red}</style>B', 'Ap{color:red}B', 'AB'],
['inline', '<b>A</b><i>B</i>&amp;', 'AB&', 'AB&'],
['whitespace', ' A\n B\t C ', ' A B\t C ', ' A\n B\t C '],
['nested-whitespace', '<span> A\n B\t C </span>', ' A B\t C ', ' A\n B\t C '],
['pre', '<pre>A\n B</pre>', 'A B', 'A\n B'],
['inline-block-tags', '<div style="display:inline">A</div><div style="display:inline">B</div>', 'AB', 'AB'],
['block-inline-tags', '<span style="display:block">A</span><span style="display:block">B</span>', 'AB', 'A\nB'],
['hidden-override', 'A<span hidden style="display:inline">H</span>B', 'AHB', 'AHB'],
['rendered-script', 'A<script style="display:inline">H</script>B', 'AHB', 'AHB'],
['fragment-stylesheet', '<style>span{display:none}</style>A<span>H</span>B', 'span{display:none}AHB', 'AB'],
['table', '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>', 'ABCD', 'A\tB\nC\tD'],
['empty', '', '', ''],
['only-hidden', '<span hidden>H</span>', 'H', ''],
['only-br', '<br>', '', ''],
];
function probe(name, tag, cancel, markup, initial, selection, maxlength, expectedValue, caret, inserted) {
const control = body.appendChild(document.createElement(tag));
control.value = initial;
if (maxlength !== null) control.maxLength = maxlength;
control.focus();
control.setSelectionRange(selection[0], selection[1]);
const events = [];
for (const type of ['beforeinput', 'input']) {
control.addEventListener(type, event => {
events.push([event.type, event.inputType, event.data, event.bubbles, event.cancelable, event.composed]);
if (cancel && type === 'beforeinput') event.preventDefault();
});
}
const returned = document.execCommand('insertHTML', false, markup);
const actual = [returned, control.value, control.selectionStart, control.selectionEnd, events];
const emitsInput = tag === 'textarea' || expectedValue !== initial || caret !== selection[0] || caret !== selection[1];
const expectedEvents = emitsInput
? [['input', tag === 'input' ? 'insertText' : '', tag === 'input' ? inserted : null, true, false, true]]
: [];
const expected = [true, expectedValue, caret, caret, expectedEvents];
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures.push({ name, tag, cancel, expected, actual });
}
control.remove();
}
for (const tag of ['input', 'textarea']) {
for (const cancel of [false, true]) {
for (const [name, markup, inputValue, textareaValue] of cases) {
const value = tag === 'input' ? inputValue : textareaValue;
probe(name, tag, cancel, markup, '', [0, 0], null, value, value.length, value);
}
const inserted = tag === 'input' ? '😀X' : '😀\n';
probe('utf16-selection-maxlength', tag, cancel, '<div>😀</div><div>XY</div>',
'L😀R', [1, 3], 5, 'L' + inserted + 'R', 4, inserted);
probe('maxlength-surrogate-boundary', tag, cancel, '<b>😀</b>',
'L😀R', [1, 3], 3, 'LR', 1, '');
probe('maxlength-noop', tag, cancel, '<b>😀</b>',
'LR', [1, 1], 3, 'LR', 1, '');
probe('empty-at-caret', tag, cancel, '', 'XY', [1, 1], null, 'XY', 1, '');
probe('replace-same-value', tag, cancel, '<b>X</b>', 'X', [0, 1], null, 'X', 1, 'X');
}
}
if (globalThis.__insertHtmlScriptRan !== undefined) {
failures.push('InsertHTML must not execute script elements');
}
return JSON.stringify(failures);
})()