We now apply first letter styles by splitting text with a first-letter style applied into 2 `TextSliceNode` objects. The `DOM::Text` layout node always points at the non first-letter slice and the first-letter slice is reachable via `TextSliceNode::first_letter_slice()`. First letter splitting works by `TreeBuilder` walking a block container's inline descendants to find the first typographic letter unit per the pattern given in css-pseudo level 4, which is then wrapped in an anonymous inline box styled with the `::first-letter` computed properties. Consumers that map between DOM offsets and layout geometry are updated to visit all slices of a `DOM::Text` through `TextOffsetMapping`.
413 lines
17 KiB
C++
413 lines
17 KiB
C++
/*
|
|
* Copyright (c) 2022-2023, Andreas Kling <andreas@ladybird.org>
|
|
* Copyright (c) 2025, Sam Atkins <sam@ladybird.org>
|
|
* Copyright (c) 2026, Jelle Raaijmakers <jelle@ladybird.org>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <LibWeb/CSS/ComputedProperties.h>
|
|
#include <LibWeb/CSS/SystemColor.h>
|
|
#include <LibWeb/DOM/Document.h>
|
|
#include <LibWeb/DOM/Element.h>
|
|
#include <LibWeb/DOM/ShadowRoot.h>
|
|
#include <LibWeb/DOM/Text.h>
|
|
#include <LibWeb/Layout/Node.h>
|
|
#include <LibWeb/Layout/TextNode.h>
|
|
#include <LibWeb/Layout/TextOffsetMapping.h>
|
|
#include <LibWeb/Painting/DisplayListRecorder.h>
|
|
#include <LibWeb/Painting/DisplayListRecordingContext.h>
|
|
#include <LibWeb/Painting/Paintable.h>
|
|
#include <LibWeb/Painting/PaintableBox.h>
|
|
#include <LibWeb/Painting/PaintableWithLines.h>
|
|
#include <LibWeb/Painting/StackingContext.h>
|
|
#include <LibWeb/Painting/ViewportPaintable.h>
|
|
|
|
namespace Web::Painting {
|
|
|
|
Paintable::Paintable(Layout::Node const& layout_node)
|
|
: m_layout_node(layout_node)
|
|
{
|
|
auto& computed_values = layout_node.computed_values();
|
|
if ((layout_node.is_flex_item() || layout_node.is_grid_item()) && computed_values.z_index().has_value()) {
|
|
// https://drafts.csswg.org/css-flexbox-1/#painting
|
|
// https://drafts.csswg.org/css-grid-2/#z-order
|
|
// Flex and grid items with z-index values other than "auto" behave as if position were "relative".
|
|
m_positioned = true;
|
|
} else {
|
|
m_positioned = computed_values.position() != CSS::Positioning::Static;
|
|
}
|
|
|
|
m_fixed_position = computed_values.position() == CSS::Positioning::Fixed;
|
|
m_sticky_position = computed_values.position() == CSS::Positioning::Sticky;
|
|
m_absolutely_positioned = computed_values.position() == CSS::Positioning::Absolute;
|
|
m_floating = layout_node.is_floating();
|
|
m_inline = layout_node.is_inline();
|
|
m_display = layout_node.display();
|
|
}
|
|
|
|
Paintable::~Paintable() = default;
|
|
|
|
String Paintable::debug_description() const
|
|
{
|
|
return MUST(String::formatted("{}({})", class_name(), layout_node().debug_description()));
|
|
}
|
|
|
|
DOM::Document const& Paintable::document() const
|
|
{
|
|
return layout_node().document();
|
|
}
|
|
|
|
DOM::Document& Paintable::document()
|
|
{
|
|
return layout_node().document();
|
|
}
|
|
|
|
RefPtr<PaintableBox> Paintable::containing_block() const
|
|
{
|
|
if (m_containing_block.has_value()) {
|
|
if (auto containing_block = m_containing_block->strong_ref())
|
|
return containing_block;
|
|
}
|
|
|
|
auto containing_block = [&] -> RefPtr<PaintableBox> {
|
|
auto containing_layout_box = layout_node().containing_block();
|
|
if (!containing_layout_box)
|
|
return nullptr;
|
|
auto paintable_box = containing_layout_box->paintable_box();
|
|
if (!paintable_box)
|
|
return nullptr;
|
|
return const_cast<PaintableBox&>(*paintable_box);
|
|
}();
|
|
m_containing_block = containing_block;
|
|
return containing_block;
|
|
}
|
|
|
|
CSS::ImmutableComputedValues const& Paintable::computed_values() const
|
|
{
|
|
return layout_node().computed_values();
|
|
}
|
|
|
|
bool Paintable::visible_for_hit_testing() const
|
|
{
|
|
if (auto node = dom_node(); node && node->is_inert())
|
|
return false;
|
|
return computed_values().pointer_events() != CSS::PointerEvents::None;
|
|
}
|
|
|
|
void Paintable::set_dom_node(GC::Ptr<DOM::Node> dom_node)
|
|
{
|
|
m_dom_node = dom_node.ptr();
|
|
}
|
|
|
|
GC::Ptr<DOM::Node> Paintable::dom_node()
|
|
{
|
|
return m_dom_node.ptr();
|
|
}
|
|
|
|
GC::Ptr<DOM::Node const> Paintable::dom_node() const
|
|
{
|
|
return m_dom_node.ptr();
|
|
}
|
|
|
|
GC::Ptr<HTML::Navigable> Paintable::navigable() const
|
|
{
|
|
return document().navigable();
|
|
}
|
|
|
|
bool Paintable::handle_mousewheel(Badge<EventHandler>, CSSPixelPoint, unsigned, unsigned, double, double)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
TraversalDecision Paintable::hit_test(CSSPixelPoint, HitTestType, Function<TraversalDecision(HitTestResult)> const&) const
|
|
{
|
|
return TraversalDecision::Continue;
|
|
}
|
|
|
|
bool Paintable::has_stacking_context() const
|
|
{
|
|
if (auto const* paintable_box = as_if<PaintableBox>(this))
|
|
return paintable_box->stacking_context();
|
|
return false;
|
|
}
|
|
|
|
RefPtr<StackingContext> Paintable::enclosing_stacking_context()
|
|
{
|
|
for (auto ancestor = parent(); ancestor; ancestor = ancestor->parent()) {
|
|
auto* paintable_box = as_if<PaintableBox>(ancestor.ptr());
|
|
if (!paintable_box)
|
|
continue;
|
|
if (auto stacking_context = paintable_box->stacking_context())
|
|
return stacking_context;
|
|
}
|
|
// We should always reach the viewport's stacking context.
|
|
VERIFY_NOT_REACHED();
|
|
}
|
|
|
|
void Paintable::paint_inspector_overlay(DisplayListRecordingContext& context) const
|
|
{
|
|
auto& display_list_recorder = context.display_list_recorder();
|
|
RefPtr<PaintableBox const> paintable_box;
|
|
if (is<PaintableBox>(*this))
|
|
paintable_box = static_cast<PaintableBox const&>(*this);
|
|
else
|
|
paintable_box = first_ancestor_of_type<PaintableBox>();
|
|
|
|
if (paintable_box) {
|
|
auto viewport_paintable = document().paintable();
|
|
VERIFY(viewport_paintable);
|
|
auto& visual_context_tree = const_cast<ViewportPaintable&>(*viewport_paintable).visual_context_tree();
|
|
auto visual_context_index = paintable_box->accumulated_visual_context_index();
|
|
|
|
if (visual_context_index.value()) {
|
|
Vector<VisualContextIndex> relevant_indices;
|
|
for (auto i = visual_context_index; i.value(); i = visual_context_tree.node_at(i).parent_index) {
|
|
auto should_keep = visual_context_tree.node_at(i).data.visit(
|
|
[](ScrollData const&) { return true; },
|
|
[](ClipData const&) { return false; },
|
|
[](TransformData const&) { return true; },
|
|
[](PerspectiveData const&) { return true; },
|
|
[](ClipPathData const&) { return false; },
|
|
[](EffectsData const&) { return false; },
|
|
[](ScrollCompensation const&) { return true; });
|
|
if (should_keep)
|
|
relevant_indices.append(i);
|
|
}
|
|
|
|
VisualContextIndex overlay_visual_context_index {};
|
|
for (auto const& source_visual_context_index : relevant_indices.in_reverse())
|
|
overlay_visual_context_index = visual_context_tree.append(visual_context_tree.node_at(source_visual_context_index).data, overlay_visual_context_index);
|
|
|
|
if (overlay_visual_context_index.value())
|
|
display_list_recorder.set_accumulated_visual_context(overlay_visual_context_index);
|
|
}
|
|
}
|
|
|
|
paint_inspector_overlay_internal(context);
|
|
display_list_recorder.set_accumulated_visual_context({});
|
|
}
|
|
|
|
void Paintable::set_needs_repaint(InvalidateDisplayList should_invalidate_display_list)
|
|
{
|
|
if (should_invalidate_display_list == InvalidateDisplayList::Yes) {
|
|
if (auto containing_block = this->containing_block())
|
|
containing_block->invalidate_paint_cache();
|
|
}
|
|
document().set_needs_repaint(Badge<Painting::Paintable> {}, should_invalidate_display_list);
|
|
}
|
|
|
|
CSSPixelPoint Paintable::box_type_agnostic_position() const
|
|
{
|
|
if (auto const* paintable_box = as_if<PaintableBox>(this))
|
|
return paintable_box->absolute_position();
|
|
|
|
VERIFY(is_inline());
|
|
|
|
CSSPixelPoint position;
|
|
if (auto block = containing_block(); block && is<Painting::PaintableWithLines>(*block)) {
|
|
auto const& fragments = static_cast<Painting::PaintableWithLines const&>(*block).fragments();
|
|
if (!fragments.is_empty()) {
|
|
position = fragments[0].absolute_rect().location();
|
|
}
|
|
}
|
|
|
|
return position;
|
|
}
|
|
|
|
Painting::BorderRadiiData normalize_border_radii_data(Layout::Node const& node, CSSPixelRect const& border_rect, CSSPixelRect const& reference_rect, CSS::BorderRadiusData const& top_left_radius, CSS::BorderRadiusData const& top_right_radius, CSS::BorderRadiusData const& bottom_right_radius, CSS::BorderRadiusData const& bottom_left_radius)
|
|
{
|
|
Painting::BorderRadiiData radii_px {
|
|
.top_left = {
|
|
top_left_radius.horizontal_radius.to_px(node, reference_rect.width()),
|
|
top_left_radius.vertical_radius.to_px(node, reference_rect.height()) },
|
|
.top_right = { top_right_radius.horizontal_radius.to_px(node, reference_rect.width()), top_right_radius.vertical_radius.to_px(node, reference_rect.height()) },
|
|
.bottom_right = { bottom_right_radius.horizontal_radius.to_px(node, reference_rect.width()), bottom_right_radius.vertical_radius.to_px(node, reference_rect.height()) },
|
|
.bottom_left = { bottom_left_radius.horizontal_radius.to_px(node, reference_rect.width()), bottom_left_radius.vertical_radius.to_px(node, reference_rect.height()) }
|
|
};
|
|
|
|
// Scale overlapping curves according to https://www.w3.org/TR/css-backgrounds-3/#corner-overlap
|
|
// Let f = min(Li/Si), where i ∈ {top, right, bottom, left},
|
|
// Si is the sum of the two corresponding radii of the corners on side i,
|
|
// and Ltop = Lbottom = the width of the box, and Lleft = Lright = the height of the box.
|
|
//
|
|
// NOTE: We iterate twice as a form of iterative refinement. A single scaling pass using
|
|
// fixed-point arithmetic can result in small rounding errors, causing the scaled radii to
|
|
// still slightly overflow the box dimensions. A second pass corrects this remaining error.
|
|
for (int iteration = 0; iteration < 2; ++iteration) {
|
|
auto s_top = radii_px.top_left.horizontal_radius + radii_px.top_right.horizontal_radius;
|
|
auto s_right = radii_px.top_right.vertical_radius + radii_px.bottom_right.vertical_radius;
|
|
auto s_bottom = radii_px.bottom_right.horizontal_radius + radii_px.bottom_left.horizontal_radius;
|
|
auto s_left = radii_px.bottom_left.vertical_radius + radii_px.top_left.vertical_radius;
|
|
|
|
CSSPixelFraction f = 1;
|
|
if (s_top > border_rect.width())
|
|
f = min(f, border_rect.width() / s_top);
|
|
if (s_right > border_rect.height())
|
|
f = min(f, border_rect.height() / s_right);
|
|
if (s_bottom > border_rect.width())
|
|
f = min(f, border_rect.width() / s_bottom);
|
|
if (s_left > border_rect.height())
|
|
f = min(f, border_rect.height() / s_left);
|
|
|
|
// If f is 1 or more, the radii fit perfectly and no more scaling is needed
|
|
if (f >= 1)
|
|
break;
|
|
|
|
Painting::BorderRadiusData* corners[] = {
|
|
&radii_px.top_left, &radii_px.top_right, &radii_px.bottom_right, &radii_px.bottom_left
|
|
};
|
|
|
|
for (auto* corner : corners) {
|
|
corner->horizontal_radius *= f;
|
|
corner->vertical_radius *= f;
|
|
}
|
|
}
|
|
|
|
return radii_px;
|
|
}
|
|
|
|
// https://drafts.csswg.org/css-pseudo-4/#highlight-styling
|
|
// FIXME: Support additional ::selection properties: text-underline-offset, text-underline-position, stroke-color,
|
|
// fill-color, stroke-width, and CSS custom properties.
|
|
Paintable::SelectionStyle Paintable::selection_style() const
|
|
{
|
|
auto color_scheme = computed_values().color_scheme();
|
|
SelectionStyle default_style { CSS::SystemColor::highlight(color_scheme), {}, {}, {} };
|
|
|
|
// For text nodes, check the parent element since text nodes don't have computed properties.
|
|
auto node = dom_node();
|
|
if (!node)
|
|
return default_style;
|
|
|
|
DOM::Element const* element = as_if<DOM::Element>(*node);
|
|
if (!element)
|
|
element = node->parent_element();
|
|
if (!element)
|
|
return default_style;
|
|
|
|
auto style_from_element = [&](DOM::Element const& element) -> Optional<SelectionStyle> {
|
|
auto element_layout_node = element.layout_node();
|
|
if (!element_layout_node)
|
|
return {};
|
|
|
|
auto computed_selection_style = element.computed_properties(CSS::PseudoElement::Selection);
|
|
if (!computed_selection_style)
|
|
return {};
|
|
|
|
auto context = CSS::ColorResolutionContext::for_layout_node_with_style(*element_layout_node);
|
|
|
|
SelectionStyle style;
|
|
style.background_color = computed_selection_style->color(CSS::PropertyID::BackgroundColor, context);
|
|
|
|
// Only use text color if it was explicitly set in the ::selection rule, not inherited.
|
|
if (!computed_selection_style->is_property_inherited(CSS::PropertyID::Color))
|
|
style.text_color = computed_selection_style->color(CSS::PropertyID::Color, context);
|
|
|
|
// Only use text-shadow if it was explicitly set in the ::selection rule, not inherited.
|
|
if (!computed_selection_style->is_property_inherited(CSS::PropertyID::TextShadow)) {
|
|
auto const& css_shadows = computed_selection_style->text_shadow(*element_layout_node);
|
|
Vector<ShadowData> shadows;
|
|
shadows.ensure_capacity(css_shadows.size());
|
|
for (auto const& shadow : css_shadows)
|
|
shadows.unchecked_append(ShadowData::from_css(shadow, *element_layout_node));
|
|
style.text_shadow = move(shadows);
|
|
}
|
|
|
|
// Only use text-decoration if it was explicitly set in the ::selection rule, not inherited.
|
|
if (!computed_selection_style->is_property_inherited(CSS::PropertyID::TextDecorationLine)) {
|
|
style.text_decoration = TextDecorationStyle {
|
|
.line = computed_selection_style->text_decoration_line(),
|
|
.style = computed_selection_style->text_decoration_style(),
|
|
.color = computed_selection_style->color(CSS::PropertyID::TextDecorationColor, context),
|
|
};
|
|
}
|
|
|
|
// Only return a style if there's a meaningful customization. This allows us to continue checking shadow hosts
|
|
// when the current element only has UA default styles.
|
|
if (!style.has_styling())
|
|
return {};
|
|
|
|
return style;
|
|
};
|
|
|
|
// Check the element itself.
|
|
if (auto style = style_from_element(*element); style.has_value())
|
|
return style.release_value();
|
|
|
|
// If inside a shadow tree, check the shadow host. This enables ::selection styling on elements like <input> to
|
|
// apply to text rendered inside their shadow DOM.
|
|
if (auto shadow_root = element->containing_shadow_root(); shadow_root && shadow_root->is_user_agent_internal()) {
|
|
if (auto const* host = shadow_root->host()) {
|
|
if (auto style = style_from_element(*host); style.has_value())
|
|
return style.release_value();
|
|
}
|
|
}
|
|
|
|
return default_style;
|
|
}
|
|
|
|
void Paintable::set_selection_state(SelectionState state)
|
|
{
|
|
if (m_selection_state == state)
|
|
return;
|
|
m_selection_state = state;
|
|
if (auto* box = as_if<PaintableBox>(this)) {
|
|
box->invalidate_paint_cache();
|
|
} else if (auto containing_block = this->containing_block()) {
|
|
containing_block->invalidate_paint_cache();
|
|
for (auto const* ancestor = layout_node().parent(); ancestor && ancestor != &containing_block->layout_node(); ancestor = ancestor->parent()) {
|
|
for (auto& paintable : ancestor->paintables()) {
|
|
if (auto* ancestor_box = as_if<PaintableBox>(paintable.ptr()))
|
|
ancestor_box->invalidate_paint_cache();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void Paintable::scroll_ancestor_to_offset_into_view(size_t offset)
|
|
{
|
|
auto scroll_to_cursor = [&](PaintableFragment const& fragment, Paintable const& fragment_paintable) {
|
|
auto cursor_rect = fragment.range_rect(SelectionState::StartAndEnd, offset, offset);
|
|
for (auto ancestor = fragment_paintable.containing_block(); ancestor; ancestor = ancestor->containing_block()) {
|
|
if (ancestor->has_scrollable_overflow()) {
|
|
ancestor->scroll_into_view(cursor_rect);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Find the paintable fragment containing the cursor offset and scroll it into view.
|
|
auto scan_layout_fragment = [&](Paintable const& slice_paintable) -> bool {
|
|
auto paintable_with_lines = slice_paintable.first_ancestor_of_type<PaintableWithLines>();
|
|
if (!paintable_with_lines)
|
|
return false;
|
|
for (auto const& fragment : paintable_with_lines->fragments()) {
|
|
if (&fragment.paintable() != &slice_paintable)
|
|
continue;
|
|
if (offset < fragment.dom_start_offset_in_node() || offset > fragment.dom_end_offset_in_node())
|
|
continue;
|
|
scroll_to_cursor(fragment, slice_paintable);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (auto const* text = as_if<DOM::Text>(dom_node().ptr())) {
|
|
Layout::TextOffsetMapping mapping { *text };
|
|
bool scrolled = false;
|
|
mapping.for_each_fragment([&](Layout::TextNode const& slice) {
|
|
if (scrolled)
|
|
return;
|
|
if (auto slice_paintable = slice.first_paintable()) {
|
|
if (scan_layout_fragment(*slice_paintable))
|
|
scrolled = true;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
scan_layout_fragment(*this);
|
|
}
|
|
|
|
}
|