Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions Libraries/LibWeb/DOM/Node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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::TextNode>(*layout_node)) {
if (layout_text_node->update_produces_line_box_fragment_when_empty_flag())
node.set_needs_layout_update(SetNeedsLayoutReason::EditableStateChange);
}
}
return TraversalDecision::Continue;
});
}
Expand Down
1 change: 1 addition & 0 deletions Libraries/LibWeb/DOM/Node.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ enum class ShouldComputeRole {

#define ENUMERATE_SET_NEEDS_LAYOUT_REASONS(X) \
X(CharacterDataReplaceData) \
X(EditableStateChange) \
X(FinalizeACrossDocumentNavigation) \
X(GeneratedContentImageFinishedLoading) \
X(HTMLCanvasElementWidthOrHeightChange) \
Expand Down
2 changes: 1 addition & 1 deletion Libraries/LibWeb/HTML/HTMLInputElement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 0 additions & 30 deletions Libraries/LibWeb/Layout/LayoutRustBridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,9 @@
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/DOM/Node.h>
#include <LibWeb/DOM/Position.h>
#include <LibWeb/DOM/ShadowRoot.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/Dump.h>
#include <LibWeb/HTML/AttributeNames.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/HTMLElement.h>
#include <LibWeb/HTML/HTMLInputElement.h>
#include <LibWeb/Layout/Box.h>
Expand Down Expand Up @@ -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::ShadowRoot>(dom_text->root())) {
if (auto const* form_associated_element = as_if<HTML::FormAssociatedTextControlElement>(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();
Expand Down Expand Up @@ -1266,16 +1246,6 @@ RustFFI::FfiLayoutFcCallbacks LayoutRustBridge::formatting_context_callbacks()
facts.marker_list_style_position = static_cast<u8>(to_underlying(marker->list_style_position()));
}
return facts; },
.text_node_is_empty_editable = [](void*, void* node) {
auto const* text_node = as_if<TextNode>(*static_cast<Node const*>(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 const*>(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<NodeWithStyle>(*static_cast<Node const*>(node));
VERIFY(node_with_style);
Expand Down
1 change: 1 addition & 0 deletions Libraries/LibWeb/Layout/Node.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
26 changes: 26 additions & 0 deletions Libraries/LibWeb/Layout/TextNode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
#include <LibUnicode/CharacterTypes.h>
#include <LibUnicode/Locale.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/ShadowRoot.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/Layout/NodeArena.h>
#include <LibWeb/Layout/TextNode.h>
#include <LibWeb/Painting/InlinePaintable.h>
Expand All @@ -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)
Expand All @@ -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::ShadowRoot>(dom_text->root())) {
if (as_if<HTML::FormAssociatedTextControlElement>(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
Expand Down
2 changes: 2 additions & 0 deletions Libraries/LibWeb/Layout/TextNode.h
Original file line number Diff line number Diff line change
Expand Up @@ -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&);
Expand Down
2 changes: 0 additions & 2 deletions Libraries/LibWeb/Rust/src/layout/formatting_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 0 additions & 5 deletions Libraries/LibWeb/Rust/src/layout/inline_formatting_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 4 additions & 8 deletions Libraries/LibWeb/Rust/src/layout/inline_level_iterator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions Libraries/LibWeb/Rust/src/layout/layout_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 0 additions & 4 deletions Libraries/LibWeb/Rust/src/layout/line_box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Libraries/LibWeb/Rust/src/layout/node_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ pub enum NodeFlag {
HasSavedAbsposLayoutInputs = 1 << 19,
SavedAbsposCbDerivesFromOwnComputedValues = 1 << 20,
SavedAbsposAlignmentDerivesFromOwnComputedValues = 1 << 21,
ProducesLineBoxFragmentWhenEmpty = 1 << 22,
}

#[repr(C)]
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<div></div>
<script>
test(() => {
// An empty text node produces a zero-width line box fragment only while its parent is an
// editing host, and that fact is stamped on the layout node. Toggling contenteditable at
// runtime must therefore refresh the stamp and relayout, or the caret would have no
// fragment to anchor to until some unrelated relayout runs.
const host = document.querySelector("div");
host.appendChild(document.createTextNode(""));
host.getBoundingClientRect();

host.setAttribute("contenteditable", "");
host.focus();
getSelection().collapse(host.firstChild, 0);
const caret_rect = internals.currentCaretRect();
println(`caret rect exists in freshly editable empty text node: ${caret_rect !== null}`);
println(`caret has line height: ${caret_rect !== null && caret_rect.height > 0}`);

const height_while_editable = host.getBoundingClientRect().height;
host.removeAttribute("contenteditable");
const height_after_removal = host.getBoundingClientRect().height;
println(`empty editing host has line height while editable: ${height_while_editable > 0}`);
println(`empty div collapses after contenteditable is removed: ${height_after_removal === 0}`);
});
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="include.js"></script>
<input>
<div contenteditable></div>
<script>
test(() => {
// The caret must keep advancing while trailing spaces are typed, even though trailing
// whitespace at the end of a line would ordinarily be invisible: text control inner
// editors preserve all whitespace via white-space: pre, and typing into an editing host
// canonicalizes a would-collapse space into a non-breaking space.
const input_element = document.querySelector("input");
input_element.focus();
internals.sendText(input_element, "hi");
const input_caret_x_after_text = internals.currentCaretRect().x;
internals.sendText(input_element, " ");
const input_caret_x_after_first_trailing_space = internals.currentCaretRect().x;
internals.sendText(input_element, " ");
const input_caret_x_after_second_trailing_space = internals.currentCaretRect().x;
println(`input caret advances past first trailing space: ${input_caret_x_after_first_trailing_space > input_caret_x_after_text}`);
println(`input caret advances past second trailing space: ${input_caret_x_after_second_trailing_space > input_caret_x_after_first_trailing_space}`);

const editing_host = document.querySelector("div[contenteditable]");
editing_host.focus();
internals.sendText(editing_host, "hi");
const editing_host_caret_x_after_text = internals.currentCaretRect().x;
internals.sendText(editing_host, " ");
const editing_host_caret_x_after_trailing_space = internals.currentCaretRect().x;
println(`editing host caret advances past trailing space: ${editing_host_caret_x_after_trailing_space > editing_host_caret_x_after_text}`);
});
</script>