diff --git a/components/layout/dom_traversal.rs b/components/layout/dom_traversal.rs index 8bf6d919fa3..4da6aa48b55 100644 --- a/components/layout/dom_traversal.rs +++ b/components/layout/dom_traversal.rs @@ -59,8 +59,14 @@ impl<'dom> NodeAndStyleInfo<'dom> { } } + /// Whether this is a container for the editable text within a single-line text input. + /// This is used to solve the special case of line height for a text editor. + /// + // FIXME(stevennovaryo): Now, this would also refer to HTMLInputElement, to handle input + // elements without shadow DOM. pub(crate) fn is_single_line_text_input(&self) -> bool { - self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement) + self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement) || + self.node.is_text_control_inner_editor() } pub(crate) fn pseudo( diff --git a/components/script/dom/document.rs b/components/script/dom/document.rs index fd4faf0ee43..68efa07537c 100644 --- a/components/script/dom/document.rs +++ b/components/script/dom/document.rs @@ -1556,11 +1556,14 @@ impl Document { return; } + // For a node within a text input UA shadow DOM, delegate the focus target into its shadow host. + // TODO: This focus delegation should be done with shadow DOM delegateFocus attribute. + let target_el = el.find_focusable_shadow_host_if_necessary(); + self.begin_focus_transaction(); - // Try to focus `el`. If it's not focusable, focus the document - // instead. + // Try to focus `el`. If it's not focusable, focus the document instead. self.request_focus(None, FocusInitiator::Local, can_gc); - self.request_focus(Some(&*el), FocusInitiator::Local, can_gc); + self.request_focus(target_el.as_deref(), FocusInitiator::Local, can_gc); } let dom_event = DomRoot::upcast::(MouseEvent::for_platform_mouse_event( diff --git a/components/script/dom/element.rs b/components/script/dom/element.rs index af8dbebd20e..311991804e9 100644 --- a/components/script/dom/element.rs +++ b/components/script/dom/element.rs @@ -1662,6 +1662,27 @@ impl Element { ) } + /// Returns the focusable shadow host if this is a text control inner editor. + /// This is a workaround for the focus delegation of shadow DOM and should be + /// used only to delegate focusable inner editor of [HTMLInputElement] and + /// [HTMLTextAreaElement]. + pub(crate) fn find_focusable_shadow_host_if_necessary(&self) -> Option> { + if self.is_focusable_area() { + Some(DomRoot::from_ref(self)) + } else if self.upcast::().is_text_control_inner_editor() { + let containing_shadow_host = self.containing_shadow_root().map(|root| root.Host()); + assert!( + containing_shadow_host + .as_ref() + .is_some_and(|e| e.is_focusable_area()), + "Containing shadow host is not focusable" + ); + containing_shadow_host + } else { + None + } + } + pub(crate) fn is_actually_disabled(&self) -> bool { let node = self.upcast::(); match node.type_id() { diff --git a/components/script/dom/htmlinputelement.rs b/components/script/dom/htmlinputelement.rs index e914ad7f5d1..ee61421dfe8 100644 --- a/components/script/dom/htmlinputelement.rs +++ b/components/script/dom/htmlinputelement.rs @@ -101,6 +101,29 @@ const DEFAULT_RESET_VALUE: &str = "Reset"; const PASSWORD_REPLACEMENT_CHAR: char = '●'; const DEFAULT_FILE_INPUT_VALUE: &str = "No file chosen"; +#[derive(Clone, JSTraceable, MallocSizeOf)] +#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] +/// Contains reference to text control inner editor and placeholder container element in the UA +/// shadow tree for ``. The following is the structure of the shadow tree. +/// +/// ``` +/// +/// #shadow-root +///
+///
+///
+///
+/// +/// ``` +// TODO(stevennovaryo): We are trying to use CSS to mimic Chrome and Firefox's layout for the element. +// But, this could be slower in performance and does have some discrepancies. For example, +// they would try to vertically align text baseline with the baseline of other +// TextNode within an inline flow. Another example is the horizontal scroll. +struct InputTypeTextShadowTree { + text_container: Dom, + placeholder_container: Dom, +} + #[derive(Clone, JSTraceable, MallocSizeOf)] #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] /// Contains references to the elements in the shadow tree for ``. @@ -111,10 +134,49 @@ struct InputTypeColorShadowTree { color_value: Dom, } +// FIXME: These styles should be inside UA stylesheet, but it is not possible without internal pseudo element support. +const TEXT_TREE_STYLE: &str = " +#input-editor::selection { + background: rgba(176, 214, 255, 1.0); + color: black; +} + +:host:not(:placeholder-shown) #input-placeholder { + visibility: hidden !important +} + +#input-editor { + overflow-wrap: normal; + pointer-events: auto; +} + +#input-container { + position: relative; + height: 100%; + pointer-events: none; + display: flex; +} + +#input-editor, #input-placeholder { + white-space: pre; + margin-block: auto !important; + inset-block: 0 !important; + block-size: fit-content !important; +} + +#input-placeholder { + overflow: hidden !important; + position: absolute !important; + color: grey; + pointer-events: none !important; +} +"; + #[derive(Clone, JSTraceable, MallocSizeOf)] #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] #[non_exhaustive] enum ShadowTree { + Text(InputTypeTextShadowTree), Color(InputTypeColorShadowTree), // TODO: Add shadow trees for other input types (range etc) here } @@ -1071,7 +1133,7 @@ impl HTMLInputElement { ShadowRootMode::Closed, false, false, - false, + true, SlotAssignmentMode::Manual, can_gc, ) @@ -1079,6 +1141,92 @@ impl HTMLInputElement { }) } + fn create_text_shadow_tree(&self, can_gc: CanGc) { + let document = self.owner_document(); + let shadow_root = self.shadow_root(can_gc); + Node::replace_all(None, shadow_root.upcast::(), can_gc); + + let inner_container = + HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + inner_container + .upcast::() + .SetId(DOMString::from("input-container"), can_gc); + shadow_root + .upcast::() + .AppendChild(inner_container.upcast::(), can_gc) + .unwrap(); + + let placeholder_container = + HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + placeholder_container + .upcast::() + .SetId(DOMString::from("input-placeholder"), can_gc); + inner_container + .upcast::() + .AppendChild(placeholder_container.upcast::(), can_gc) + .unwrap(); + + let text_container = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + text_container + .upcast::() + .SetId(DOMString::from("input-editor"), can_gc); + text_container + .upcast::() + .set_text_control_inner_editor(); + inner_container + .upcast::() + .AppendChild(text_container.upcast::(), can_gc) + .unwrap(); + + let style = HTMLStyleElement::new( + local_name!("style"), + None, + &document, + None, + ElementCreator::ScriptCreated, + can_gc, + ); + // TODO(stevennovaryo): Either use UA stylesheet with internal pseudo element or preemptively parse + // the stylesheet to reduce the costly operation and avoid CSP related error. + style + .upcast::() + .SetTextContent(Some(DOMString::from(TEXT_TREE_STYLE)), can_gc); + shadow_root + .upcast::() + .AppendChild(style.upcast::(), can_gc) + .unwrap(); + + let _ = self + .shadow_tree + .borrow_mut() + .insert(ShadowTree::Text(InputTypeTextShadowTree { + text_container: text_container.as_traced(), + placeholder_container: placeholder_container.as_traced(), + })); + } + + fn text_shadow_tree(&self, can_gc: CanGc) -> Ref { + let has_text_shadow_tree = self + .shadow_tree + .borrow() + .as_ref() + .is_some_and(|shadow_tree| matches!(shadow_tree, ShadowTree::Text(_))); + if !has_text_shadow_tree { + self.create_text_shadow_tree(can_gc); + } + + let shadow_tree = self.shadow_tree.borrow(); + Ref::filter_map(shadow_tree, |shadow_tree| { + let shadow_tree = shadow_tree.as_ref()?; + match shadow_tree { + ShadowTree::Text(text_tree) => Some(text_tree), + _ => None, + } + }) + .ok() + .expect("UA shadow tree was not created") + } + fn create_color_shadow_tree(&self, can_gc: CanGc) { let document = self.owner_document(); let shadow_root = self.shadow_root(can_gc); @@ -1136,27 +1284,53 @@ impl HTMLInputElement { let shadow_tree = self.shadow_tree.borrow(); Ref::filter_map(shadow_tree, |shadow_tree| { let shadow_tree = shadow_tree.as_ref()?; - let ShadowTree::Color(color_tree) = shadow_tree; - Some(color_tree) + match shadow_tree { + ShadowTree::Color(color_tree) => Some(color_tree), + _ => None, + } }) .ok() .expect("UA shadow tree was not created") } fn update_shadow_tree_if_needed(&self, can_gc: CanGc) { - if self.input_type() == InputType::Color { - let color_shadow_tree = self.color_shadow_tree(can_gc); - let mut value = self.Value(); - if value.str().is_valid_simple_color_string() { - value.make_ascii_lowercase(); - } else { - value = DOMString::from("#000000"); - } - let style = format!("background-color: {value}"); - color_shadow_tree - .color_value - .upcast::() - .set_string_attribute(&local_name!("style"), style.into(), can_gc); + match self.input_type() { + InputType::Text => { + let text_shadow_tree = self.text_shadow_tree(can_gc); + let value = self.Value(); + + // The addition of zero-width space here forces the text input to have an inline formatting + // context that might otherwise be trimmed if there's no text. This is important to ensure + // that the input element is at least as tall as the line gap of the caret: + // . + // + // This is also used to ensure that the caret will still be rendered when the input is empty. + // TODO: Is there a less hacky way to do this? + let value_text = match value.is_empty() { + false => value, + true => "\u{200B}".into(), + }; + + text_shadow_tree + .text_container + .upcast::() + .SetTextContent(Some(value_text), can_gc); + }, + InputType::Color => { + let color_shadow_tree = self.color_shadow_tree(can_gc); + let mut value = self.Value(); + if value.str().is_valid_simple_color_string() { + value.make_ascii_lowercase(); + } else { + value = DOMString::from("#000000"); + } + let style = format!("background-color: {value}"); + color_shadow_tree + .color_value + .upcast::() + .set_string_attribute(&local_name!("style"), style.into(), can_gc); + }, + _ => {}, } } } @@ -1465,22 +1639,29 @@ impl HTMLInputElementMethods for HTMLInputElement { fn SetValue(&self, mut value: DOMString, can_gc: CanGc) -> ErrorResult { match self.value_mode() { ValueMode::Value => { - // Step 3. - self.value_dirty.set(true); + { + // Step 3. + self.value_dirty.set(true); - // Step 4. - self.sanitize_value(&mut value); + // Step 4. + self.sanitize_value(&mut value); - let mut textinput = self.textinput.borrow_mut(); - - // Step 5. - if *textinput.single_line_content() != value { - // Steps 1-2 - textinput.set_content(value); + let mut textinput = self.textinput.borrow_mut(); // Step 5. - textinput.clear_selection_to_limit(Direction::Forward); + if *textinput.single_line_content() != value { + // Steps 1-2 + textinput.set_content(value); + + // Step 5. + textinput.clear_selection_to_limit(Direction::Forward); + } } + + // Additionaly, update the placeholder shown state. This is + // normally being done in the attributed mutated. And, being + // done in another scope to prevent borrow checker issues. + self.update_placeholder_shown_state(); }, ValueMode::Default | ValueMode::DefaultOn => { self.upcast::() @@ -2063,6 +2244,19 @@ impl HTMLInputElement { el.set_placeholder_shown_state(has_placeholder && !has_value); } + // Update the placeholder text in the text shadow tree. + // To increase the performance, we would only do this when it is necessary. + fn update_text_shadow_tree_placeholder(&self, can_gc: CanGc) { + if self.input_type() != InputType::Text { + return; + } + + self.text_shadow_tree(can_gc) + .placeholder_container + .upcast::() + .SetTextContent(Some(self.placeholder.borrow().clone()), can_gc); + } + // https://html.spec.whatwg.org/multipage/#file-upload-state-(type=file) // Select files by invoking UI or by passed in argument fn select_files(&self, opt_test_paths: Option>, can_gc: CanGc) { @@ -2688,8 +2882,11 @@ impl VirtualMethods for HTMLInputElement { }, } + self.update_text_shadow_tree_placeholder(can_gc); self.update_placeholder_shown_state(); }, + // FIXME(stevennovaryo): This is only reachable by Default and DefaultOn value mode. While others + // are being handled in [Self::SetValue]. Should we merge this two together? local_name!("value") if !self.value_dirty.get() => { let value = mutation.new_value(attr).map(|value| (**value).to_owned()); let mut value = value.map_or(DOMString::new(), DOMString::from); @@ -2738,6 +2935,7 @@ impl VirtualMethods for HTMLInputElement { .extend(attr.value().chars().filter(|&c| c != '\n' && c != '\r')); } } + self.update_text_shadow_tree_placeholder(can_gc); self.update_placeholder_shown_state(); }, local_name!("readonly") => { diff --git a/components/script/dom/node.rs b/components/script/dom/node.rs index 691b3a38f19..cd0a41ab9f6 100644 --- a/components/script/dom/node.rs +++ b/components/script/dom/node.rs @@ -230,6 +230,10 @@ bitflags! { /// Whether this node has a weird parser insertion mode. i.e whether setting innerHTML /// needs extra work or not const HAS_WEIRD_PARSER_INSERTION_MODE = 1 << 11; + + /// Whether this node serves as the text container for editable content of + /// or