mirror of
https://github.com/lexmount/moli.git
synced 2026-11-02 08:07:11 +00:00
fix(editing): distinguish input and textarea InsertHTML behavior
This commit is contained in:
@@ -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>&', '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);
|
||||
})()
|
||||
Reference in New Issue
Block a user