diff --git a/Libraries/LibWeb/DOM/Node.cpp b/Libraries/LibWeb/DOM/Node.cpp index a52755195ab23..ec2ce7d2327b4 100644 --- a/Libraries/LibWeb/DOM/Node.cpp +++ b/Libraries/LibWeb/DOM/Node.cpp @@ -1686,12 +1686,24 @@ void Node::recompute_editable_subtree_flags_and_repaint() // display list, so a flip must invalidate the recorded output. if (node.recompute_editable_subtree_flag()) node.set_needs_repaint(); - // Editing-host status is stamped into layout NodeData at layout node construction; - // contenteditable and designMode changes reach here without a layout tree rebuild, - // so the stamp must be refreshed. A node's editing-host status can flip even when - // its own editable-subtree flag did not, hence unconditionally for every node. - if (auto* layout_node = node.unsafe_layout_node()) - layout_node->set_is_editing_host(node.is_editing_host()); + // Editing-host status and the empty-text fragment behavior of text nodes are + // stamped into layout NodeData at layout node construction; contenteditable and + // designMode changes reach here without a layout tree rebuild, so the stamps must + // be refreshed. A node's stamps can flip even when its own editable-subtree flag + // did not, hence unconditionally for every node. A flipped stamp changes geometry + // (an editing host gains a minimum block size, an empty editable text node gains + // a zero-width fragment), so the affected node also needs a relayout. + if (auto* layout_node = node.unsafe_layout_node()) { + auto is_editing_host = node.is_editing_host(); + if (layout_node->is_editing_host() != is_editing_host) { + layout_node->set_is_editing_host(is_editing_host); + node.set_needs_layout_update(SetNeedsLayoutReason::EditableStateChange); + } + if (auto* layout_text_node = as_if(*layout_node)) { + if (layout_text_node->update_produces_line_box_fragment_when_empty_flag()) + node.set_needs_layout_update(SetNeedsLayoutReason::EditableStateChange); + } + } return TraversalDecision::Continue; }); } diff --git a/Libraries/LibWeb/DOM/Node.h b/Libraries/LibWeb/DOM/Node.h index 3870fd71cf6d6..393fd5bf93c9b 100644 --- a/Libraries/LibWeb/DOM/Node.h +++ b/Libraries/LibWeb/DOM/Node.h @@ -67,6 +67,7 @@ enum class ShouldComputeRole { #define ENUMERATE_SET_NEEDS_LAYOUT_REASONS(X) \ X(CharacterDataReplaceData) \ + X(EditableStateChange) \ X(FinalizeACrossDocumentNavigation) \ X(GeneratedContentImageFinishedLoading) \ X(HTMLCanvasElementWidthOrHeightChange) \ diff --git a/Libraries/LibWeb/HTML/HTMLInputElement.cpp b/Libraries/LibWeb/HTML/HTMLInputElement.cpp index 5d7f65da051cd..4a1f158bb3160 100644 --- a/Libraries/LibWeb/HTML/HTMLInputElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLInputElement.cpp @@ -1189,7 +1189,7 @@ void HTMLInputElement::create_text_input_shadow_tree() overflow: auto; scrollbar-width: none; text-overflow: clip; - white-space: nowrap; + white-space: pre; )~~~"sv); } m_inner_text_element->set_inline_style(*style); diff --git a/Libraries/LibWeb/Layout/LayoutRustBridge.cpp b/Libraries/LibWeb/Layout/LayoutRustBridge.cpp index 5c3415fd2a1d7..9dc50ab3cc4a8 100644 --- a/Libraries/LibWeb/Layout/LayoutRustBridge.cpp +++ b/Libraries/LibWeb/Layout/LayoutRustBridge.cpp @@ -38,12 +38,9 @@ #include #include #include -#include -#include #include #include #include -#include #include #include #include @@ -95,23 +92,6 @@ static_assert(to_underlying(CSS::StyleGroupIndex::SizingValues) == RustFFI::STYL static_assert(to_underlying(CSS::StyleGroupIndex::SurroundValues) == RustFFI::STYLE_GROUP_INDEX_SURROUND); static_assert(to_underlying(CSS::StyleGroupIndex::BoxValues) == RustFFI::STYLE_GROUP_INDEX_BOX); -static bool is_empty_editable_text_node(TextNode const& text_node) -{ - if (!text_node.text_for_rendering().is_empty()) - return false; - auto const* dom_text = text_node.dom_text(); - if (!dom_text) - return false; - - auto is_empty_editable = false; - if (auto const* shadow_root = as_if(dom_text->root())) { - if (auto const* form_associated_element = as_if(shadow_root->host())) - is_empty_editable = form_associated_element->text_control_to_html_element().is_mutable(); - } - is_empty_editable |= dom_text->parent() && dom_text->parent()->is_editing_host(); - return is_empty_editable; -} - static CSS::GridTrackSizeList build_used_grid_track_list(RustFFI::FfiUsedGridTrackList const& list) { auto result = list.is_subgrid ? CSS::GridTrackSizeList::make_subgrid() : CSS::GridTrackSizeList::make_none(); @@ -1266,16 +1246,6 @@ RustFFI::FfiLayoutFcCallbacks LayoutRustBridge::formatting_context_callbacks() facts.marker_list_style_position = static_cast(to_underlying(marker->list_style_position())); } return facts; }, - .text_node_is_empty_editable = [](void*, void* node) { - auto const* text_node = as_if(*static_cast(node)); - VERIFY(text_node); - return is_empty_editable_text_node(*text_node); }, - .document_cursor_is_on_node = [](void*, void* node) { - auto const* dom_node = static_cast(node)->dom_node(); - if (!dom_node) - return false; - auto cursor_position = dom_node->document().cursor_position(); - return cursor_position && cursor_position->node() == dom_node; }, .build_svg_facts = [](void*, void* node) { auto const* node_with_style = as_if(*static_cast(node)); VERIFY(node_with_style); diff --git a/Libraries/LibWeb/Layout/Node.h b/Libraries/LibWeb/Layout/Node.h index 9f352716353a6..e1fa1c40b1d92 100644 --- a/Libraries/LibWeb/Layout/Node.h +++ b/Libraries/LibWeb/Layout/Node.h @@ -262,6 +262,7 @@ class WEB_API Node bool children_are_inline() const { return has_flag(RustFFI::NodeFlag::ChildrenAreInline); } void set_children_are_inline(bool value) { set_flag(RustFFI::NodeFlag::ChildrenAreInline, value); } + bool is_editing_host() const { return has_flag(RustFFI::NodeFlag::IsEditingHost); } void set_is_editing_host(bool value) { set_flag(RustFFI::NodeFlag::IsEditingHost, value); } u32 initial_quote_nesting_level() const { return m_data->initial_quote_nesting_level; } diff --git a/Libraries/LibWeb/Layout/TextNode.cpp b/Libraries/LibWeb/Layout/TextNode.cpp index ebca33eba8e77..89660c433f73e 100644 --- a/Libraries/LibWeb/Layout/TextNode.cpp +++ b/Libraries/LibWeb/Layout/TextNode.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -25,12 +27,14 @@ TextNode::TextNode(DOM::Document& document, DOM::Text& text) : Node(document, &text) { enroll_for_arena_text_content_sync(); + update_produces_line_box_fragment_when_empty_flag(); } TextNode::TextNode(DOM::Document& document, DOM::Text& text, AttachToDOMNode attach_to_dom_node) : Node(document, &text, attach_to_dom_node) { enroll_for_arena_text_content_sync(); + update_produces_line_box_fragment_when_empty_flag(); } TextNode::TextNode(DOM::Document& document) @@ -39,6 +43,28 @@ TextNode::TextNode(DOM::Document& document) enroll_for_arena_text_content_sync(); } +bool TextNode::update_produces_line_box_fragment_when_empty_flag() +{ + // Text controls and editing hosts rely on their text node producing a zero-width fragment even + // when it has no text: the fragment keeps the line box alive with real font metrics, giving the + // caret an anchor to paint at and the control its baseline. Stamping this as a node flag keeps + // layout itself unaware of editing state. + auto produces_line_box_fragment_when_empty = [&] { + auto const* dom_text = this->dom_text(); + if (!dom_text) + return false; + if (auto const* shadow_root = as_if(dom_text->root())) { + if (as_if(shadow_root->host())) + return true; + } + return dom_text->parent() && dom_text->parent()->is_editing_host(); + }(); + if (has_flag(RustFFI::NodeFlag::ProducesLineBoxFragmentWhenEmpty) == produces_line_box_fragment_when_empty) + return false; + set_flag(RustFFI::NodeFlag::ProducesLineBoxFragmentWhenEmpty, produces_line_box_fragment_when_empty); + return true; +} + TextNode::~TextNode() = default; DOM::Element const* TextNode::parent_element_for_text_transform() const diff --git a/Libraries/LibWeb/Layout/TextNode.h b/Libraries/LibWeb/Layout/TextNode.h index 84c3a88365633..fabe77528897e 100644 --- a/Libraries/LibWeb/Layout/TextNode.h +++ b/Libraries/LibWeb/Layout/TextNode.h @@ -48,6 +48,8 @@ class TextNode : public Node { void set_needs_repaint(InvalidateDisplayList = InvalidateDisplayList::Yes) const; + bool update_produces_line_box_fragment_when_empty_flag(); + protected: TextNode(DOM::Document&, DOM::Text&, AttachToDOMNode); explicit TextNode(DOM::Document&); diff --git a/Libraries/LibWeb/Rust/src/layout/formatting_context.rs b/Libraries/LibWeb/Rust/src/layout/formatting_context.rs index 55b3098a9729e..d87d0e7a0ea45 100644 --- a/Libraries/LibWeb/Rust/src/layout/formatting_context.rs +++ b/Libraries/LibWeb/Rust/src/layout/formatting_context.rs @@ -900,8 +900,6 @@ pub struct FfiLayoutFcCallbacks { pub release_anchor_name_handle: crate::layout::FfiReleaseAnchorNameHandleCallback, pub build_replaced_content_facts: unsafe extern "C" fn(*mut c_void, *mut c_void) -> crate::layout::FfiReplacedContentFacts, pub build_list_item_facts: unsafe extern "C" fn(*mut c_void, *mut c_void) -> crate::layout::FfiListItemFacts, - pub text_node_is_empty_editable: unsafe extern "C" fn(*mut c_void, *mut c_void) -> bool, - pub document_cursor_is_on_node: unsafe extern "C" fn(*mut c_void, *mut c_void) -> bool, pub build_svg_facts: unsafe extern "C" fn(*mut c_void, *mut c_void) -> FfiSvgElementFacts, pub read_paintable_geometry: unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void, *mut crate::layout::FfiPaintableGeometry) -> bool, diff --git a/Libraries/LibWeb/Rust/src/layout/inline_formatting_context.rs b/Libraries/LibWeb/Rust/src/layout/inline_formatting_context.rs index ff6ff6405b276..be7d744a5e613 100644 --- a/Libraries/LibWeb/Rust/src/layout/inline_formatting_context.rs +++ b/Libraries/LibWeb/Rust/src/layout/inline_formatting_context.rs @@ -1225,11 +1225,6 @@ impl<'context, 'pass> InlineFormattingContext<'context, 'pass> { } impl LineBoxTextProvider for InlineFormattingContext<'_, '_> { - fn document_cursor_is_on_node(&self, node: Node) -> bool { - // SAFETY: The host reads document state synchronously. - unsafe { (self.callbacks.document_cursor_is_on_node)(self.callbacks.context, self.callbacks.shell(node)) } - } - fn font_glyph_width(&self, font: *const c_void, code_point: u32) -> f32 { font_glyph_width(font, code_point) } diff --git a/Libraries/LibWeb/Rust/src/layout/inline_level_iterator.rs b/Libraries/LibWeb/Rust/src/layout/inline_level_iterator.rs index ac877c043e908..fc437d345c13c 100644 --- a/Libraries/LibWeb/Rust/src/layout/inline_level_iterator.rs +++ b/Libraries/LibWeb/Rust/src/layout/inline_level_iterator.rs @@ -397,18 +397,14 @@ impl<'iterator, 'context, 'pass> InlineLevelIteratorGenerator<'iterator, 'contex text_context.next_chunk_index += 1; } let mut is_last_chunk = text_context.next_chunk_index >= chunks.len(); - let is_empty_editable = chunk.is_none() + let synthesize_zero_length_chunk = chunk.is_none() && is_first_chunk && is_last_chunk && text_context.text.is_empty() - && { - let callbacks = self.context().callbacks; - // SAFETY: The host reads the live TextNode synchronously. - unsafe { (callbacks.text_node_is_empty_editable)(callbacks.context, callbacks.shell(text_node)) } - }; + && self.context().facts(text_node).produces_line_box_fragment_when_empty(); let chunk = if let Some(chunk) = chunk { chunk - } else if is_empty_editable { + } else if synthesize_zero_length_chunk { text_context.next_chunk_index = 1; let parent_style = self.context().style(self.context().parent_node(text_node)); TextChunk { @@ -490,7 +486,7 @@ impl<'iterator, 'context, 'pass> InlineLevelIteratorGenerator<'iterator, 'contex style.letter_spacing().to_double() as f32, ); let chunk_inline_size = CssPixels::nearest_value_for_f32(glyphs.width + inline_offset); - let generated_empty = is_empty_editable + let generated_empty = synthesize_zero_length_chunk || (self.context().facts(text_node).is_generated_for_pseudo_element() && chunk.length == 0); let mut item = Item::new(ItemType::Text, text_node); item.glyphs = Some(glyphs); diff --git a/Libraries/LibWeb/Rust/src/layout/layout_state.rs b/Libraries/LibWeb/Rust/src/layout/layout_state.rs index 9c2e3be56d67c..accadc65131c5 100644 --- a/Libraries/LibWeb/Rust/src/layout/layout_state.rs +++ b/Libraries/LibWeb/Rust/src/layout/layout_state.rs @@ -561,6 +561,10 @@ impl<'pass> NodeFacts<'pass> { crate::layout::has_flag(self.data(), NodeFlag::IsEditingHost) } + pub(crate) fn produces_line_box_fragment_when_empty(&self) -> bool { + crate::layout::has_flag(self.data(), NodeFlag::ProducesLineBoxFragmentWhenEmpty) + } + pub(crate) fn uses_button_layout(&self) -> bool { crate::layout::has_flag(self.data(), NodeFlag::UsesButtonLayout) } diff --git a/Libraries/LibWeb/Rust/src/layout/line_box.rs b/Libraries/LibWeb/Rust/src/layout/line_box.rs index 81434c66e2341..d2400f5d79576 100644 --- a/Libraries/LibWeb/Rust/src/layout/line_box.rs +++ b/Libraries/LibWeb/Rust/src/layout/line_box.rs @@ -5,7 +5,6 @@ */ pub(crate) trait LineBoxTextProvider { - fn document_cursor_is_on_node(&self, node: Node) -> bool; fn font_glyph_width(&self, font: *const c_void, code_point: u32) -> f32; } @@ -161,9 +160,6 @@ impl LineBoxData { } fragment_index -= 1; let fragment = &self.fragments[fragment_index]; - if provider.document_cursor_is_on_node(fragment.layout_node) { - return whitespace_inline_size; - } if !matches!( fragment.white_space_collapse, white_space_collapse::COLLAPSE | white_space_collapse::PRESERVE_BREAKS diff --git a/Libraries/LibWeb/Rust/src/layout/node_data.rs b/Libraries/LibWeb/Rust/src/layout/node_data.rs index 02c8e67e3b0db..f116b8bdcf58c 100644 --- a/Libraries/LibWeb/Rust/src/layout/node_data.rs +++ b/Libraries/LibWeb/Rust/src/layout/node_data.rs @@ -141,6 +141,7 @@ pub enum NodeFlag { HasSavedAbsposLayoutInputs = 1 << 19, SavedAbsposCbDerivesFromOwnComputedValues = 1 << 20, SavedAbsposAlignmentDerivesFromOwnComputedValues = 1 << 21, + ProducesLineBoxFragmentWhenEmpty = 1 << 22, } #[repr(C)] @@ -235,5 +236,6 @@ mod tests { assert_eq!(NodeFlag::UsesButtonLayout as u32, 1 << 16); assert_eq!(NodeFlag::IsEditingHost as u32, 1 << 17); assert_eq!(NodeFlag::ReplacedBoxCanHaveChildren as u32, 1 << 18); + assert_eq!(NodeFlag::ProducesLineBoxFragmentWhenEmpty as u32, 1 << 22); } } diff --git a/Tests/LibWeb/Text/expected/Editing/caret-in-empty-text-node-after-contenteditable-toggle.txt b/Tests/LibWeb/Text/expected/Editing/caret-in-empty-text-node-after-contenteditable-toggle.txt new file mode 100644 index 0000000000000..cd5fa711db430 --- /dev/null +++ b/Tests/LibWeb/Text/expected/Editing/caret-in-empty-text-node-after-contenteditable-toggle.txt @@ -0,0 +1,4 @@ +caret rect exists in freshly editable empty text node: true +caret has line height: true +empty editing host has line height while editable: true +empty div collapses after contenteditable is removed: true diff --git a/Tests/LibWeb/Text/expected/input-caret-advances-past-typed-trailing-space.txt b/Tests/LibWeb/Text/expected/input-caret-advances-past-typed-trailing-space.txt new file mode 100644 index 0000000000000..aab3c86960c2f --- /dev/null +++ b/Tests/LibWeb/Text/expected/input-caret-advances-past-typed-trailing-space.txt @@ -0,0 +1,3 @@ +input caret advances past first trailing space: true +input caret advances past second trailing space: true +editing host caret advances past trailing space: true diff --git a/Tests/LibWeb/Text/input/Editing/caret-in-empty-text-node-after-contenteditable-toggle.html b/Tests/LibWeb/Text/input/Editing/caret-in-empty-text-node-after-contenteditable-toggle.html new file mode 100644 index 0000000000000..063a1b593222f --- /dev/null +++ b/Tests/LibWeb/Text/input/Editing/caret-in-empty-text-node-after-contenteditable-toggle.html @@ -0,0 +1,27 @@ + + +
+ diff --git a/Tests/LibWeb/Text/input/input-caret-advances-past-typed-trailing-space.html b/Tests/LibWeb/Text/input/input-caret-advances-past-typed-trailing-space.html new file mode 100644 index 0000000000000..8971f69725574 --- /dev/null +++ b/Tests/LibWeb/Text/input/input-caret-advances-past-typed-trailing-space.html @@ -0,0 +1,30 @@ + + + +
+