LibWeb: Implement Range's extension method
Some checks are pending
CI / Lagom (false, FUZZ, ubuntu-22.04, Linux, Clang) (push) Waiting to run
CI / Lagom (false, NO_FUZZ, macos-14, macOS, Clang) (push) Waiting to run
CI / Lagom (false, NO_FUZZ, ubuntu-22.04, Linux, GNU) (push) Waiting to run
CI / Lagom (true, NO_FUZZ, ubuntu-22.04, Linux, Clang) (push) Waiting to run
Package the js repl as a binary artifact / build-and-package (macos-14, macOS, macOS-universal2) (push) Waiting to run
Package the js repl as a binary artifact / build-and-package (ubuntu-22.04, Linux, Linux-x86_64) (push) Waiting to run
Run test262 and test-wasm / run_and_update_results (push) Waiting to run
Lint Code / lint (push) Waiting to run
Push notes / build (push) Waiting to run

This patch implements `Range::getClientRects` and
`Range::getBoundingClientRect`. Since the rects returned by invoking
getClientRects can be accessed without adding them to the Selection,
`ViewportPaintable::recompute_selection_states` has been updated to
accept a Range as a parameter, rather than acquiring it through the
Document's Selection.

With this change, the following tests now pass:

- wpt[css/cssom-view/range-bounding-client-rect-with-nested-text.html]
- wpt[css/cssom-view/DOMRectList.html]

Note: The test
"css/cssom-view/range-bounding-client-rect-with-display-contents.html"
still fails due to an issue with Element::getClientRects, which will
be addressed in a future commit.
This commit is contained in:
Annya 2024-09-16 19:38:14 +08:00 committed by Alexander Kalenik
parent a3472aef24
commit 75c7dbc5d2
Notes: github-actions[bot] 2024-09-20 17:59:18 +00:00
12 changed files with 225 additions and 38 deletions

View file

@ -0,0 +1,2 @@
Hello Ladybird Ladybird again World 6
4

View file

@ -0,0 +1,2 @@
x [object DOMRectList]
[object DOMRect]

View file

@ -0,0 +1,47 @@
<!--refer to https://wpt.live/css/cssom-view/range-bounding-client-rect-with-nested-text.html -->
<!DOCTYPE html>
<meta charset="utf-8">
<title>All the rectangles for the text nodes must included in getClientRects</title>
<script src="../include.js"></script>
<style>
.nullDims {
width: 0;
height: 0;
}
.nullDims > div {
position: absolute;
left: 10px;
}
</style>
<div id="container">
<div class="nullDims">
<div id="first" style="top: 10px">Hello</div>
</div>
<div class="nullDims">
<div id="second" style="top: 40px">Ladybird</div>
</div>
<div class="nullDims">
<div id="third" style="top: 70px">Ladybird again</div>
</div>
<div class="nullDims">
<div id="last" style="top: 100px">World</div>
</div>
</div>
<script>
test(function () {
const first = document.getElementById("first");
const last = document.getElementById("last");
const range = document.createRange();
range.setStart(first.firstChild, 0);
range.setEnd(last.firstChild, 3);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
let rects = Array.from(range.getClientRects());
println(rects.length);
rects = rects.filter(({ width, height }) => width > 0 && height > 0);
println(rects.length);
})
</script>

View file

@ -0,0 +1,19 @@
<!--refer to https://wpt.live/css/cssom-view/DOMRectList.html -->
<body>
<div id="x">x</div>
<script src="../include.js"></script>
<script>
function print_class_string(object) {
println({}.toString.call(object));
}
test(() => {
const x = document.getElementById("x");
const range = document.createRange();
range.selectNodeContents(x);
const domRectList = range.getClientRects();
print_class_string(domRectList);
print_class_string(domRectList.item(0));
});
</script>
</body>

View file

@ -1194,7 +1194,7 @@ void Document::update_layout()
page().client().page_did_layout();
}
paintable()->recompute_selection_states();
paintable()->update_selection();
m_needs_layout = false;

View file

@ -24,7 +24,7 @@
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Namespace.h>
#include <LibWeb/Painting/Paintable.h>
#include <LibWeb/Painting/InlinePaintable.h>
#include <LibWeb/Painting/ViewportPaintable.h>
namespace Web::DOM {
@ -97,7 +97,8 @@ void Range::set_associated_selection(Badge<Selection::Selection>, JS::GCPtr<Sele
void Range::update_associated_selection()
{
if (auto* viewport = m_start_container->document().paintable()) {
viewport->recompute_selection_states();
viewport->recompute_selection_states(*this);
viewport->update_selection();
viewport->set_needs_display();
}
@ -1165,17 +1166,103 @@ WebIDL::ExceptionOr<void> Range::delete_contents()
}
// https://drafts.csswg.org/cssom-view/#dom-element-getclientrects
JS::NonnullGCPtr<Geometry::DOMRectList> Range::get_client_rects() const
// https://drafts.csswg.org/cssom-view/#extensions-to-the-range-interface
JS::NonnullGCPtr<Geometry::DOMRectList> Range::get_client_rects()
{
dbgln("(STUBBED) Range::get_client_rects()");
return Geometry::DOMRectList::create(realm(), {});
// 1. return an empty DOMRectList object if the range is not in the document
if (!start_container()->document().navigable())
return Geometry::DOMRectList::create(realm(), {});
start_container()->document().update_layout();
update_associated_selection();
Vector<JS::Handle<Geometry::DOMRect>> rects;
// FIXME: take Range collapsed into consideration
// 2. Iterate the node included in Range
auto start_node = start_container();
auto end_node = end_container();
if (!is<DOM::Text>(start_node)) {
start_node = start_node->child_at_index(m_start_offset);
}
if (!is<DOM::Text>(end_node)) {
// end offset shouldn't be 0
if (m_end_offset == 0)
return Geometry::DOMRectList::create(realm(), {});
end_node = end_node->child_at_index(m_end_offset - 1);
}
for (Node const* node = start_node; node && node != end_node->next_in_pre_order(); node = node->next_in_pre_order()) {
auto node_type = static_cast<NodeType>(node->node_type());
if (node_type == NodeType::ELEMENT_NODE) {
// 1. For each element selected by the range, whose parent is not selected by the range, include the border
// areas returned by invoking getClientRects() on the element.
if (contains_node(*node) && !contains_node(*node->parent())) {
auto const& element = static_cast<DOM::Element const&>(*node);
JS::NonnullGCPtr<Geometry::DOMRectList> const element_rects = element.get_client_rects();
for (u32 i = 0; i < element_rects->length(); i++) {
auto rect = element_rects->item(i);
rects.append(Geometry::DOMRect::create(realm(),
Gfx::FloatRect(rect->x(), rect->y(), rect->width(), rect->height())));
}
}
} else if (node_type == NodeType::TEXT_NODE) {
// 2. For each Text node selected or partially selected by the range (including when the boundary-points
// are identical), include scaled DOMRect object (for the part that is selected, not the whole line box).
auto const& text = static_cast<DOM::Text const&>(*node);
auto const* paintable = text.paintable();
if (paintable) {
auto const* containing_block = paintable->containing_block();
if (is<Painting::PaintableWithLines>(*containing_block)) {
auto const& paintable_lines = static_cast<Painting::PaintableWithLines const&>(*containing_block);
auto fragments = paintable_lines.fragments();
auto const& font = paintable->layout_node().first_available_font();
for (auto frag = fragments.begin(); frag != fragments.end(); frag++) {
auto rect = frag->range_rect(font, *this);
if (rect.is_empty())
continue;
rects.append(Geometry::DOMRect::create(realm(),
Gfx::FloatRect(rect)));
}
} else {
dbgln("FIXME: Failed to get client rects for node {}", node->debug_description());
}
}
}
}
return Geometry::DOMRectList::create(realm(), move(rects));
}
// https://w3c.github.io/csswg-drafts/cssom-view/#dom-range-getboundingclientrect
JS::NonnullGCPtr<Geometry::DOMRect> Range::get_bounding_client_rect() const
JS::NonnullGCPtr<Geometry::DOMRect> Range::get_bounding_client_rect()
{
dbgln("(STUBBED) Range::get_bounding_client_rect()");
return Geometry::DOMRect::construct_impl(realm(), 0, 0, 0, 0).release_value_but_fixme_should_propagate_errors();
// 1. Let list be the result of invoking getClientRects() on element.
auto list = get_client_rects();
// 2. If the list is empty return a DOMRect object whose x, y, width and height members are zero.
if (list->length() == 0)
return Geometry::DOMRect::construct_impl(realm(), 0, 0, 0, 0).release_value_but_fixme_should_propagate_errors();
// 3. If all rectangles in list have zero width or height, return the first rectangle in list.
auto all_rectangle_has_zero_width_or_height = true;
for (auto i = 0u; i < list->length(); ++i) {
auto const& rect = list->item(i);
if (rect->width() != 0 && rect->height() != 0) {
all_rectangle_has_zero_width_or_height = false;
break;
}
}
if (all_rectangle_has_zero_width_or_height)
return JS::NonnullGCPtr { *const_cast<Geometry::DOMRect*>(list->item(0)) };
// 4. Otherwise, return a DOMRect object describing the smallest rectangle that includes all of the rectangles in
// list of which the height or width is not zero.
auto const* first_rect = list->item(0);
auto bounding_rect = Gfx::Rect { first_rect->x(), first_rect->y(), first_rect->width(), first_rect->height() };
for (auto i = 1u; i < list->length(); ++i) {
auto const& rect = list->item(i);
if (rect->width() == 0 || rect->height() == 0)
continue;
bounding_rect = bounding_rect.united({ rect->x(), rect->y(), rect->width(), rect->height() });
}
return Geometry::DOMRect::create(realm(), bounding_rect.to_type<float>());
}
// https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-range-createcontextualfragment

View file

@ -88,8 +88,8 @@ public:
static HashTable<Range*>& live_ranges();
JS::NonnullGCPtr<Geometry::DOMRectList> get_client_rects() const;
JS::NonnullGCPtr<Geometry::DOMRect> get_bounding_client_rect() const;
JS::NonnullGCPtr<Geometry::DOMRectList> get_client_rects();
JS::NonnullGCPtr<Geometry::DOMRect> get_bounding_client_rect();
bool contains_node(Node const&) const;

View file

@ -61,6 +61,7 @@ public:
[[nodiscard]] bool is_absolutely_positioned() const { return m_absolutely_positioned; }
[[nodiscard]] bool is_floating() const { return m_floating; }
[[nodiscard]] bool is_inline() const { return m_inline; }
[[nodiscard]] bool is_selected() const { return m_selected; }
[[nodiscard]] CSS::Display display() const { return layout_node().display(); }
template<typename U, typename Callback>
@ -240,6 +241,7 @@ public:
SelectionState selection_state() const { return m_selection_state; }
void set_selection_state(SelectionState state) { m_selection_state = state; }
void set_selected(bool selected) { m_selected = selected; }
Gfx::AffineTransform compute_combined_css_transform() const;
@ -266,6 +268,7 @@ private:
bool m_absolutely_positioned : 1 { false };
bool m_floating : 1 { false };
bool m_inline : 1 { false };
bool m_selected : 1 { false };
};
inline DOM::Node* HitTestResult::dom_node()

View file

@ -56,8 +56,7 @@ int PaintableFragment::text_index_at(CSSPixels x) const
return m_start + m_length;
}
CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
CSSPixelRect PaintableFragment::range_rect(Gfx::Font const& font, DOM::Range const& range) const
{
if (paintable().selection_state() == Paintable::SelectionState::None)
return {};
@ -65,13 +64,6 @@ CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
if (paintable().selection_state() == Paintable::SelectionState::Full)
return absolute_rect();
auto selection = paintable().document().get_selection();
if (!selection)
return {};
auto range = selection->range();
if (!range)
return {};
// FIXME: m_start and m_length should be unsigned and then we won't need these casts.
auto const start_index = static_cast<unsigned>(m_start);
auto const end_index = static_cast<unsigned>(m_start) + static_cast<unsigned>(m_length);
@ -80,16 +72,16 @@ CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
if (paintable().selection_state() == Paintable::SelectionState::StartAndEnd) {
// we are in the start/end node (both the same)
if (start_index > range->end_offset())
if (start_index > range.end_offset())
return {};
if (end_index < range->start_offset())
if (end_index < range.start_offset())
return {};
if (range->start_offset() == range->end_offset())
if (range.start_offset() == range.end_offset())
return {};
auto selection_start_in_this_fragment = max(0, range->start_offset() - m_start);
auto selection_end_in_this_fragment = min(m_length, range->end_offset() - m_start);
auto selection_start_in_this_fragment = max(0, range.start_offset() - m_start);
auto selection_end_in_this_fragment = min(m_length, range.end_offset() - m_start);
auto pixel_distance_to_first_selected_character = CSSPixels::nearest_value_for(font.width(text.substring_view(0, selection_start_in_this_fragment)));
auto pixel_width_of_selection = CSSPixels::nearest_value_for(font.width(text.substring_view(selection_start_in_this_fragment, selection_end_in_this_fragment - selection_start_in_this_fragment))) + 1;
@ -101,10 +93,10 @@ CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
}
if (paintable().selection_state() == Paintable::SelectionState::Start) {
// we are in the start node
if (end_index < range->start_offset())
if (end_index < range.start_offset())
return {};
auto selection_start_in_this_fragment = max(0, range->start_offset() - m_start);
auto selection_start_in_this_fragment = max(0, range.start_offset() - m_start);
auto selection_end_in_this_fragment = m_length;
auto pixel_distance_to_first_selected_character = CSSPixels::nearest_value_for(font.width(text.substring_view(0, selection_start_in_this_fragment)));
auto pixel_width_of_selection = CSSPixels::nearest_value_for(font.width(text.substring_view(selection_start_in_this_fragment, selection_end_in_this_fragment - selection_start_in_this_fragment))) + 1;
@ -117,11 +109,11 @@ CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
}
if (paintable().selection_state() == Paintable::SelectionState::End) {
// we are in the end node
if (start_index > range->end_offset())
if (start_index > range.end_offset())
return {};
auto selection_start_in_this_fragment = 0;
auto selection_end_in_this_fragment = min(range->end_offset() - m_start, m_length);
auto selection_end_in_this_fragment = min(range.end_offset() - m_start, m_length);
auto pixel_distance_to_first_selected_character = CSSPixels::nearest_value_for(font.width(text.substring_view(0, selection_start_in_this_fragment)));
auto pixel_width_of_selection = CSSPixels::nearest_value_for(font.width(text.substring_view(selection_start_in_this_fragment, selection_end_in_this_fragment - selection_start_in_this_fragment))) + 1;
@ -134,6 +126,21 @@ CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
return {};
}
CSSPixelRect PaintableFragment::selection_rect(Gfx::Font const& font) const
{
if (!paintable().is_selected())
return {};
auto selection = paintable().document().get_selection();
if (!selection)
return {};
auto range = selection->range();
if (!range)
return {};
return range_rect(font, *range);
}
StringView PaintableFragment::string_view() const
{
if (!is<TextPaintable>(paintable()))

View file

@ -46,6 +46,7 @@ public:
RefPtr<Gfx::GlyphRun> glyph_run() const { return m_glyph_run; }
CSSPixelRect selection_rect(Gfx::Font const&) const;
CSSPixelRect range_rect(Gfx::Font const&, DOM::Range const&) const;
CSSPixels width() const { return m_size.width(); }
CSSPixels height() const { return m_size.height(); }

View file

@ -284,11 +284,11 @@ JS::GCPtr<Selection::Selection> ViewportPaintable::selection() const
return const_cast<DOM::Document&>(document()).get_selection();
}
void ViewportPaintable::recompute_selection_states()
void ViewportPaintable::update_selection()
{
// 1. Start by resetting the selection state of all layout nodes to None.
// 1. Start by setting all layout nodes to unselected.
for_each_in_inclusive_subtree([&](auto& layout_node) {
layout_node.set_selection_state(SelectionState::None);
layout_node.set_selected(false);
return TraversalDecision::Continue;
});
@ -303,10 +303,28 @@ void ViewportPaintable::recompute_selection_states()
auto* start_container = range->start_container();
auto* end_container = range->end_container();
// 3. If the selection starts and ends in the same node:
// 3. Mark the nodes included in range selected.
for (auto* node = start_container; node && node != end_container->next_in_pre_order(); node = node->next_in_pre_order()) {
if (auto* paintable = node->paintable())
paintable->set_selected(true);
}
}
void ViewportPaintable::recompute_selection_states(DOM::Range& range)
{
// 1. Start by resetting the selection state of all layout nodes to None.
for_each_in_inclusive_subtree([&](auto& layout_node) {
layout_node.set_selection_state(SelectionState::None);
return TraversalDecision::Continue;
});
auto* start_container = range.start_container();
auto* end_container = range.end_container();
// 2. If the selection starts and ends in the same node:
if (start_container == end_container) {
// 1. If the selection starts and ends at the same offset, return.
if (range->start_offset() == range->end_offset()) {
if (range.start_offset() == range.end_offset()) {
// NOTE: A zero-length selection should not be visible.
return;
}
@ -319,7 +337,7 @@ void ViewportPaintable::recompute_selection_states()
}
}
// 4. Mark the selection start node as Start (if text) or Full (if anything else).
// 3. Mark the selection start node as Start (if text) or Full (if anything else).
if (auto* paintable = start_container->paintable()) {
if (is<DOM::Text>(*start_container))
paintable->set_selection_state(SelectionState::Start);
@ -327,7 +345,7 @@ void ViewportPaintable::recompute_selection_states()
paintable->set_selection_state(SelectionState::Full);
}
// 5. Mark the selection end node as End (if text) or Full (if anything else).
// 4. Mark the selection end node as End (if text) or Full (if anything else).
if (auto* paintable = end_container->paintable()) {
if (is<DOM::Text>(*end_container))
paintable->set_selection_state(SelectionState::End);
@ -335,7 +353,7 @@ void ViewportPaintable::recompute_selection_states()
paintable->set_selection_state(SelectionState::Full);
}
// 6. Mark the nodes between start node and end node (in tree order) as Full.
// 5. Mark the nodes between start node and end node (in tree order) as Full.
for (auto* node = start_container->next_in_pre_order(); node && node != end_container; node = node->next_in_pre_order()) {
if (auto* paintable = node->paintable())
paintable->set_selection_state(SelectionState::Full);

View file

@ -32,7 +32,8 @@ public:
void resolve_paint_only_properties();
JS::GCPtr<Selection::Selection> selection() const;
void recompute_selection_states();
void recompute_selection_states(DOM::Range&);
void update_selection();
bool handle_mousewheel(Badge<EventHandler>, CSSPixelPoint, unsigned, unsigned, int wheel_delta_x, int wheel_delta_y) override;