Handle selection and caret

Signed-off-by: stevennovaryo <steven.novaryo@gmail.com>
This commit is contained in:
stevennovaryo 2025-05-16 20:27:41 +08:00
parent 4e44ecafd2
commit f638c05710
7 changed files with 116 additions and 27 deletions

View file

@ -118,7 +118,7 @@ rustls-pemfile = "2.0"
rustls-pki-types = "1.12" rustls-pki-types = "1.12"
script_layout_interface = { path = "components/shared/script_layout" } script_layout_interface = { path = "components/shared/script_layout" }
script_traits = { path = "components/shared/script" } script_traits = { path = "components/shared/script" }
selectors = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } selectors = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
serde = "1.0.219" serde = "1.0.219"
serde_bytes = "0.11" serde_bytes = "0.11"
serde_json = "1.0" serde_json = "1.0"
@ -126,7 +126,7 @@ servo-media = { git = "https://github.com/servo/media" }
servo-media-dummy = { git = "https://github.com/servo/media" } servo-media-dummy = { git = "https://github.com/servo/media" }
servo-media-gstreamer = { git = "https://github.com/servo/media" } servo-media-gstreamer = { git = "https://github.com/servo/media" }
servo-tracing = { path = "components/servo_tracing" } servo-tracing = { path = "components/servo_tracing" }
servo_arc = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } servo_arc = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
smallbitvec = "2.6.0" smallbitvec = "2.6.0"
smallvec = "1.15" smallvec = "1.15"
snapshot = { path = "./components/shared/snapshot" } snapshot = { path = "./components/shared/snapshot" }
@ -135,12 +135,12 @@ string_cache = "0.8"
string_cache_codegen = "0.5" string_cache_codegen = "0.5"
strum = "0.26" strum = "0.26"
strum_macros = "0.26" strum_macros = "0.26"
stylo = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
stylo_atoms = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo_atoms = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
stylo_config = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo_config = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
stylo_dom = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo_dom = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
stylo_malloc_size_of = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo_malloc_size_of = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
stylo_traits = { git = "https://github.com/servo/stylo", branch = "2025-05-01" } stylo_traits = { git = "https://github.com/stevennovaryo/stylo", branch = "text-editing-root" }
surfman = { git = "https://github.com/servo/surfman", rev = "f7688b4585f9e0b5d4bf8ee8e4a91e82349610b1", features = ["chains"] } surfman = { git = "https://github.com/servo/surfman", rev = "f7688b4585f9e0b5d4bf8ee8e4a91e82349610b1", features = ["chains"] }
syn = { version = "2", default-features = false, features = ["clone-impls", "derive", "parsing"] } syn = { version = "2", default-features = false, features = ["clone-impls", "derive", "parsing"] }
synstructure = "0.13" synstructure = "0.13"
@ -223,7 +223,7 @@ codegen-units = 1
# #
# Or for Stylo: # Or for Stylo:
# #
# [patch."https://github.com/servo/stylo"] # [patch."https://github.com/stylo/stylo"]
# selectors = { path = "../stylo/selectors" } # selectors = { path = "../stylo/selectors" }
# servo_arc = { path = "../stylo/servo_arc" } # servo_arc = { path = "../stylo/servo_arc" }
# stylo = { path = "../stylo/style" } # stylo = { path = "../stylo/style" }

View file

@ -219,6 +219,16 @@ fn traverse_children_of<'dom>(
} else { } else {
handler.handle_text(&info, node_text_content); handler.handle_text(&info, node_text_content);
} }
} else if parent_element.is_text_editing_root() {
let info = NodeAndStyleInfo::new(
parent_element,
parent_element.style(context.shared_context()),
);
for child in iter_child_nodes(parent_element) {
if child.is_text_node() {
handler.handle_text(&info, child.to_threadsafe().node_text_content());
}
}
} else { } else {
for child in iter_child_nodes(parent_element) { for child in iter_child_nodes(parent_element) {
if child.is_text_node() { if child.is_text_node() {

View file

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use std::any::Any;
use std::borrow::Cow; use std::borrow::Cow;
use std::char::{ToLowercase, ToUppercase}; use std::char::{ToLowercase, ToUppercase};
@ -302,7 +303,6 @@ impl InlineFormattingContextBuilder {
if new_text.is_empty() { if new_text.is_empty() {
return; return;
} }
let selection_range = info.get_selection_range(); let selection_range = info.get_selection_range();
if let Some(last_character) = new_text.chars().next_back() { if let Some(last_character) = new_text.chars().next_back() {
self.on_word_boundary = last_character.is_whitespace(); self.on_word_boundary = last_character.is_whitespace();

View file

@ -865,6 +865,13 @@ impl LayoutDom<'_, Element> {
pub(super) fn focus_state(self) -> bool { pub(super) fn focus_state(self) -> bool {
self.unsafe_get().state.get().contains(ElementState::FOCUS) self.unsafe_get().state.get().contains(ElementState::FOCUS)
} }
pub(super) fn text_editing_root(self) -> bool {
self.unsafe_get()
.state
.get()
.contains(ElementState::TEXT_EDITING_ROOT)
}
} }
impl<'dom> LayoutElementHelpers<'dom> for LayoutDom<'dom, Element> { impl<'dom> LayoutElementHelpers<'dom> for LayoutDom<'dom, Element> {
@ -4879,6 +4886,10 @@ impl Element {
self.set_state(ElementState::FULLSCREEN, value) self.set_state(ElementState::FULLSCREEN, value)
} }
pub(crate) fn set_text_editing_root_state(&self, value: bool) {
self.set_state(ElementState::TEXT_EDITING_ROOT, value)
}
/// <https://dom.spec.whatwg.org/#connected> /// <https://dom.spec.whatwg.org/#connected>
pub(crate) fn is_connected(&self) -> bool { pub(crate) fn is_connected(&self) -> bool {
self.upcast::<Node>().is_connected() self.upcast::<Node>().is_connected()

View file

@ -119,22 +119,23 @@ struct InputTypeColorShadowTree {
color_value: Dom<HTMLDivElement>, color_value: Dom<HTMLDivElement>,
} }
// FIXME: These styles should be inside UA stylesheet,
// but we should support pseudo element first.
const TEXT_TREE_STYLE: &str = " const TEXT_TREE_STYLE: &str = "
#input-editing-root::selection, #input-placeholder::selection {
background: rgba(176, 214, 255, 1.0);
color: black;
}
#input-editing-root, #input-placeholder { #input-editing-root, #input-placeholder {
scrollbar-width: none; overflow-wrap: normal;
resize: none;
word-wrap: normal;
white-space: pre; white-space: pre;
pointer-events: none;
} }
#input-placeholder { #input-placeholder {
color: grey; color: grey;
overflow: hidden; overflow: hidden;
pointer-events: none;
user-select: none;
direction: inherit;
text-orientation: inherit;
writing-mode: inherit;
} }
"; ";
@ -1112,10 +1113,17 @@ impl HTMLInputElement {
let shadow_root = self.shadow_root(can_gc); let shadow_root = self.shadow_root(can_gc);
Node::replace_all(None, shadow_root.upcast::<Node>(), can_gc); Node::replace_all(None, shadow_root.upcast::<Node>(), can_gc);
let placeholder_container = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); let placeholder_container =
HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
placeholder_container placeholder_container
.upcast::<Element>() .upcast::<Element>()
.SetId(DOMString::from("input-placeholder"), can_gc); .SetId(DOMString::from("input-placeholder"), can_gc);
// MYNOTES:
// This is not a text editing root, but we should do this to show it's
// editing caret when placeholder is still visible
placeholder_container
.upcast::<Element>()
.set_text_editing_root_state(true);
shadow_root shadow_root
.upcast::<Node>() .upcast::<Node>()
.AppendChild(placeholder_container.upcast::<Node>(), can_gc) .AppendChild(placeholder_container.upcast::<Node>(), can_gc)
@ -1125,6 +1133,11 @@ impl HTMLInputElement {
text_container text_container
.upcast::<Element>() .upcast::<Element>()
.SetId(DOMString::from("input-editing-root"), can_gc); .SetId(DOMString::from("input-editing-root"), can_gc);
// We should probably use pseudo element to check this.
// Chrome is using (private?) element attrs,
text_container
.upcast::<Element>()
.set_text_editing_root_state(true);
shadow_root shadow_root
.upcast::<Node>() .upcast::<Node>()
.AppendChild(text_container.upcast::<Node>(), can_gc) .AppendChild(text_container.upcast::<Node>(), can_gc)
@ -1250,14 +1263,20 @@ impl HTMLInputElement {
let text_shadow_tree = self.text_shadow_tree(can_gc); let text_shadow_tree = self.text_shadow_tree(can_gc);
let value = self.Value(); let value = self.Value();
let placeholder_text = if value.len() == 0 { let placeholder_text = match (value.is_empty(), self.placeholder.borrow().is_empty()) {
self.placeholder.to_owned().take() (true, false) => self.placeholder.to_owned().take(),
} else { (true, true) => "\u{200B}".into(),
DOMString::new() _ => DOMString::new(),
}; };
text_shadow_tree.placeholder_container.upcast::<Node>().SetTextContent(Some(placeholder_text), can_gc); text_shadow_tree
text_shadow_tree.text_container.upcast::<Node>().SetTextContent(Some(value), can_gc); .placeholder_container
.upcast::<Node>()
.SetTextContent(Some(placeholder_text), can_gc);
text_shadow_tree
.text_container
.upcast::<Node>()
.SetTextContent(Some(value), can_gc);
} }
if self.input_type() == InputType::Color { if self.input_type() == InputType::Color {
let color_shadow_tree = self.color_shadow_tree(can_gc); let color_shadow_tree = self.color_shadow_tree(can_gc);

View file

@ -1614,6 +1614,12 @@ pub(crate) trait LayoutNodeHelpers<'dom> {
/// Whether this element is a `<input>` rendered as text or a `<textarea>`. /// Whether this element is a `<input>` rendered as text or a `<textarea>`.
fn is_text_input(&self) -> bool; fn is_text_input(&self) -> bool;
/// Whether this element is a text input with Shadow DOM.
fn is_text_input_with_shadow_dom(&self) -> bool;
/// Whether this element serve as a container of editable text for a text input.
fn text_editing_root(&self) -> bool;
fn text_content(self) -> Cow<'dom, str>; fn text_content(self) -> Cow<'dom, str>;
fn selection(self) -> Option<Range<usize>>; fn selection(self) -> Option<Range<usize>>;
fn image_url(self) -> Option<ServoUrl>; fn image_url(self) -> Option<ServoUrl>;
@ -1788,8 +1794,7 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
let input = self.unsafe_get().downcast::<HTMLInputElement>().unwrap(); let input = self.unsafe_get().downcast::<HTMLInputElement>().unwrap();
// FIXME: All the non-color and non-text input types currently render as text // FIXME: All the non-color and non-text input types currently render as text
input.input_type() != InputType::Color && !matches!(input.input_type(), InputType::Color | InputType::Text)
input.input_type() != InputType::Text
} else { } else {
type_id == type_id ==
NodeTypeId::Element(ElementTypeId::HTMLElement( NodeTypeId::Element(ElementTypeId::HTMLElement(
@ -1798,6 +1803,28 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
} }
} }
fn is_text_input_with_shadow_dom(&self) -> bool {
let type_id = self.type_id_for_layout();
if type_id ==
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLInputElement,
))
{
let input = self.unsafe_get().downcast::<HTMLInputElement>().unwrap();
matches!(input.input_type(), InputType::Color | InputType::Text)
} else {
false
}
}
fn text_editing_root(&self) -> bool {
match self.downcast::<Element>() {
Some(element) => element.text_editing_root(),
_ => false,
}
}
fn text_content(self) -> Cow<'dom, str> { fn text_content(self) -> Cow<'dom, str> {
if let Some(text) = self.downcast::<Text>() { if let Some(text) = self.downcast::<Text>() {
return text.upcast().data_for_layout().into(); return text.upcast().data_for_layout().into();
@ -1815,6 +1842,24 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
} }
fn selection(self) -> Option<Range<usize>> { fn selection(self) -> Option<Range<usize>> {
// This container is a text editing root of a <input> or <textarea> element.
// So we should find those corresponding element, and get its selection.
if self.text_editing_root() {
let mut maybe_parent_node = self.composed_parent_node_ref();
while let Some(parent_node) = maybe_parent_node {
if let Some(area) = parent_node.downcast::<HTMLTextAreaElement>() {
return area.selection_for_layout();
}
if let Some(input) = parent_node.downcast::<HTMLInputElement>() {
return input.selection_for_layout();
}
maybe_parent_node = parent_node.composed_parent_node_ref();
}
panic!("Text input element not found!");
}
if let Some(area) = self.downcast::<HTMLTextAreaElement>() { if let Some(area) = self.downcast::<HTMLTextAreaElement>() {
return area.selection_for_layout(); return area.selection_for_layout();
} }

View file

@ -100,6 +100,10 @@ impl<'dom> ServoLayoutNode<'dom> {
pub fn is_text_input(&self) -> bool { pub fn is_text_input(&self) -> bool {
self.node.is_text_input() self.node.is_text_input()
} }
pub fn is_text_editing_root(&self) -> bool {
self.node.text_editing_root()
}
} }
impl style::dom::NodeInfo for ServoLayoutNode<'_> { impl style::dom::NodeInfo for ServoLayoutNode<'_> {