Selecting text without custom ::selection styling changed the foreground color of the selected content. This was especially visible for links, where the text changed color but the underline did not. The default selection style supplied both a selection background and a foreground color from the palette or HighlightText system color. That made ordinary selections behave as if the page had explicitly styled ::selection color. Only provide a default selection background, so selected content keeps its own foreground color unless CSS overrides it. Remove the now-unused SelectionText palette role.
452 lines
18 KiB
C++
452 lines
18 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/StyleValues/ColorSchemeStyleValue.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/Box.h>
|
|
#include <LibWeb/Layout/Node.h>
|
|
#include <LibWeb/Layout/TextNode.h>
|
|
#include <LibWeb/Layout/TextOffsetMapping.h>
|
|
#include <LibWeb/Page/Page.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;
|
|
}
|
|
|
|
bool Paintable::has_stacking_context() const
|
|
{
|
|
if (auto const* paintable_box = as_if<PaintableBox>(this))
|
|
return paintable_box->stacking_context();
|
|
return false;
|
|
}
|
|
|
|
DOM::Node* HitTestResult::dom_node()
|
|
{
|
|
for (auto* current = paintable.ptr(); current; current = current->parent()) {
|
|
if (auto node = current->dom_node())
|
|
return node;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
DOM::Node const* HitTestResult::dom_node() const
|
|
{
|
|
for (auto const* current = paintable.ptr(); current; current = current->parent()) {
|
|
if (auto node = current->dom_node())
|
|
return node;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
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
|
|
{
|
|
paint_with_inspector_overlay_context(context, [&] {
|
|
paint_inspector_overlay_internal(context);
|
|
});
|
|
}
|
|
|
|
void Paintable::paint_with_inspector_overlay_context(DisplayListRecordingContext& context, Function<void()> const& callback) const
|
|
{
|
|
auto& display_list_recorder = context.display_list_recorder();
|
|
auto previous_visual_context_index = display_list_recorder.accumulated_visual_context();
|
|
|
|
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 != VISUAL_VIEWPORT_NODE_INDEX) {
|
|
Vector<VisualContextIndex> relevant_indices;
|
|
for (auto i = visual_context_index; i != VISUAL_VIEWPORT_NODE_INDEX; 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);
|
|
}
|
|
|
|
auto overlay_visual_context_index = VISUAL_VIEWPORT_NODE_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 != VISUAL_VIEWPORT_NODE_INDEX)
|
|
display_list_recorder.set_accumulated_visual_context(overlay_visual_context_index);
|
|
}
|
|
}
|
|
|
|
callback();
|
|
display_list_recorder.set_accumulated_visual_context(previous_visual_context_index);
|
|
}
|
|
|
|
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.
|
|
auto border_width = max(CSSPixels(0), border_rect.width());
|
|
auto border_height = max(CSSPixels(0), border_rect.height());
|
|
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 > 0 && s_top > border_width)
|
|
f = min(f, border_width / s_top);
|
|
if (s_right > 0 && s_right > border_height)
|
|
f = min(f, border_height / s_right);
|
|
if (s_bottom > 0 && s_bottom > border_width)
|
|
f = min(f, border_width / s_bottom);
|
|
if (s_left > 0 && s_left > border_height)
|
|
f = min(f, border_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 default_style_for_color_scheme = [&](CSS::PreferredColorScheme color_scheme, bool use_palette_for_normal_color_scheme = true) {
|
|
auto palette = document().page().palette();
|
|
auto palette_color_scheme = palette.is_dark() ? CSS::PreferredColorScheme::Dark : CSS::PreferredColorScheme::Light;
|
|
if (color_scheme == palette_color_scheme || use_palette_for_normal_color_scheme)
|
|
return SelectionStyle { CSS::SystemColor::transform_selection_background_color(palette.selection()) };
|
|
|
|
return SelectionStyle {
|
|
CSS::SystemColor::transform_selection_background_color(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_for_color_scheme(computed_values().color_scheme());
|
|
|
|
DOM::Element const* element = as_if<DOM::Element>(*node);
|
|
if (!element)
|
|
element = node->parent_element();
|
|
if (!element)
|
|
return default_style_for_color_scheme(computed_values().color_scheme());
|
|
|
|
auto color_scheme_is_normal = element->computed_properties()->property(CSS::PropertyID::ColorScheme).as_color_scheme().schemes().is_empty();
|
|
auto use_palette_for_normal_color_scheme = color_scheme_is_normal && !document().supported_color_schemes().has_value();
|
|
auto default_style = default_style_for_color_scheme(computed_values().color_scheme(), use_palette_for_normal_color_scheme);
|
|
|
|
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);
|
|
}
|
|
|
|
}
|