diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index efd2d8d5ee..1f11638495 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -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; diff --git a/moli-renderer-v8/src/native_bridge/document/insert_html.rs b/moli-renderer-v8/src/native_bridge/document/insert_html.rs new file mode 100644 index 0000000000..44ed72df26 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/document/insert_html.rs @@ -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::>(); + 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::>(); + 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 + } +} diff --git a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs index 728b3ba798..4fc63e2961 100644 --- a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs @@ -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 { @@ -705,49 +698,6 @@ fn exec_command_insert_html_target(runtime: &JsContextHost) -> Option 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, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 7baab27cf7..c6c8173e8f 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -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::{ diff --git a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs index ed89fe9fe6..f3089f9903 100644 --- a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs +++ b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs @@ -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, } } } diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 398cfc5e51..0a5f559920 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -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::{ diff --git a/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs b/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs index b16945bb0f..8cba0f83a8 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/text_control.rs @@ -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::{ diff --git a/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs b/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs index 62a4444340..5ad0c4606a 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/text_control/selection.rs @@ -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( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs index 76ee95d17b..6591839199 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/text_controls.rs @@ -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}" ); } diff --git a/moli-renderer-v8/tests/fixtures/exec-command-insert-html.js b/moli-renderer-v8/tests/fixtures/exec-command-insert-html.js new file mode 100644 index 0000000000..0c71c3bddf --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/exec-command-insert-html.js @@ -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', '
A
B
', 'AB', 'A\nB'], + ['paragraphs', '

A

B

', 'AB', 'A\n\nB'], + ['nested-blocks', '
A
B
C
D', 'ABCD', 'A\nB\nC\nD'], + ['empty-block', 'A
B', 'AB', 'A\nB'], + ['leading-empty-block', '
B
', 'B', 'B'], + ['trailing-empty-block', '
A
', 'A', 'A'], + ['br', 'A
B', 'A B', 'A\nB'], + ['leading-br', '
A', ' A', '\nA'], + ['trailing-br', 'A
', 'A', 'A'], + ['two-brs', 'A

B', 'A B', 'A\n\nB'], + ['two-trailing-brs', 'A

', 'A', 'A\n'], + ['block-br', '
A
B
', 'A B', 'A\nB'], + ['empty-line', 'A

B', 'A B', 'A\n\nB'], + ['hidden', 'AB', 'AHB', 'AB'], + ['hidden-subtree', 'AB', 'AH XB', 'AB'], + ['display-none', 'AHB', 'AHB', 'AB'], + ['hidden-br', 'AB', 'A B', 'AB'], + ['visibility-hidden', 'AHB', 'AHB', 'AHB'], + ['script', 'AB', + 'AglobalThis.__insertHtmlScriptRan = true;B', 'AB'], + ['style', 'AB', 'Ap{color:red}B', 'AB'], + ['inline', 'AB&', 'AB&', 'AB&'], + ['whitespace', ' A\n B\t C ', ' A B\t C ', ' A\n B\t C '], + ['nested-whitespace', ' A\n B\t C ', ' A B\t C ', ' A\n B\t C '], + ['pre', '
A\n B
', 'A B', 'A\n B'], + ['inline-block-tags', '
A
B
', 'AB', 'AB'], + ['block-inline-tags', 'AB', 'AB', 'A\nB'], + ['hidden-override', 'AB', 'AHB', 'AHB'], + ['rendered-script', 'AB', 'AHB', 'AHB'], + ['fragment-stylesheet', 'AHB', 'span{display:none}AHB', 'AB'], + ['table', '
AB
CD
', 'ABCD', 'A\tB\nC\tD'], + ['empty', '', '', ''], + ['only-hidden', '', 'H', ''], + ['only-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, '
๐Ÿ˜€
XY
', + 'L๐Ÿ˜€R', [1, 3], 5, 'L' + inserted + 'R', 4, inserted); + probe('maxlength-surrogate-boundary', tag, cancel, '๐Ÿ˜€', + 'L๐Ÿ˜€R', [1, 3], 3, 'LR', 1, ''); + probe('maxlength-noop', tag, cancel, '๐Ÿ˜€', + 'LR', [1, 1], 3, 'LR', 1, ''); + probe('empty-at-caret', tag, cancel, '', 'XY', [1, 1], null, 'XY', 1, ''); + probe('replace-same-value', tag, cancel, 'X', 'X', [0, 1], null, 'X', 1, 'X'); + } + } + if (globalThis.__insertHtmlScriptRan !== undefined) { + failures.push('InsertHTML must not execute script elements'); + } + return JSON.stringify(failures); +})()