ladybird/Libraries/LibWeb/Painting/PaintableFragment.cpp
Timothy Flynn 1f88e6819a LibWeb: Ensure hit testing is grapheme aware
Previously, clicking in the middle of a multi-code point grapheme would
place the cursor at a code unit index somewhere in the middle of the
grapheme. This was not only visually misleading, but the user could then
start typing and insert characters in the middle of the cluster. This
also made text select pretty wonky.

The main issue was that we were treating the glyph index in a glyph run
as a code unit index. We must instead map that glyph index back to a
code unit index with help from LibGfx (via harfbuzz).

The distance computation used here was also a bit off, especially for
the last glyph in a glyph run. We essentially want the cursor to end
up on whichever edge of the clicked glyph it is closest to. The result
of the distance computation limited us to the left edge of the last
glyph. Instead, we can use the same edge tracking we use for form-
associated elements to handle this for us.
2025-08-22 14:06:46 +02:00

214 lines
8.3 KiB
C++

/*
* Copyright (c) 2024-2025, Aliaksandr Kalenik <kalenik.aliaksandr@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/DOM/Range.h>
#include <LibWeb/GraphemeEdgeTracker.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/HTMLInputElement.h>
#include <LibWeb/HTML/HTMLTextAreaElement.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Painting/PaintableBox.h>
#include <LibWeb/Painting/TextPaintable.h>
namespace Web::Painting {
PaintableFragment::PaintableFragment(Layout::LineBoxFragment const& fragment)
: m_layout_node(fragment.layout_node())
, m_offset(fragment.offset())
, m_size(fragment.size())
, m_baseline(fragment.baseline())
, m_start_offset(fragment.start())
, m_length_in_code_units(fragment.length())
, m_glyph_run(fragment.glyph_run())
, m_writing_mode(fragment.writing_mode())
{
}
CSSPixelRect const PaintableFragment::absolute_rect() const
{
CSSPixelRect rect { {}, size() };
if (auto const* containing_block = paintable().containing_block())
rect.set_location(containing_block->absolute_position());
rect.translate_by(offset());
return rect;
}
size_t PaintableFragment::index_in_node_for_point(CSSPixelPoint position) const
{
if (!is<TextPaintable>(paintable()))
return 0;
auto relative_inline_offset = [&] {
switch (orientation()) {
case Orientation::Horizontal:
return (position.x() - absolute_rect().x()).to_float();
case Orientation::Vertical:
return (position.y() - absolute_rect().y()).to_float();
}
VERIFY_NOT_REACHED();
}();
if (relative_inline_offset < 0)
return 0;
GraphemeEdgeTracker tracker { relative_inline_offset };
for (auto const& glyph : m_glyph_run->glyphs()) {
if (tracker.update(glyph.length_in_code_units, glyph.glyph_width) == IterationDecision::Break)
break;
}
return m_start_offset + tracker.resolve();
}
CSSPixelRect PaintableFragment::range_rect(Paintable::SelectionState selection_state, size_t start_offset_in_code_units, size_t end_offset_in_code_units) const
{
if (selection_state == Paintable::SelectionState::None)
return {};
if (selection_state == Paintable::SelectionState::Full)
return absolute_rect();
auto const start_index = m_start_offset;
auto const end_index = m_start_offset + m_length_in_code_units;
auto const& font = glyph_run() ? glyph_run()->font() : layout_node().first_available_font();
auto text = this->text();
if (selection_state == Paintable::SelectionState::StartAndEnd) {
// we are in the start/end node (both the same)
if (start_index > end_offset_in_code_units)
return {};
if (end_index < start_offset_in_code_units)
return {};
if (start_offset_in_code_units == end_offset_in_code_units)
return {};
auto selection_start_in_this_fragment = max(0, start_offset_in_code_units - m_start_offset);
auto selection_end_in_this_fragment = min(m_length_in_code_units, end_offset_in_code_units - m_start_offset);
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;
auto rect = absolute_rect();
switch (orientation()) {
case Gfx::Orientation::Horizontal:
rect.set_x(rect.x() + pixel_distance_to_first_selected_character);
rect.set_width(pixel_width_of_selection);
break;
case Gfx::Orientation::Vertical:
rect.set_y(rect.y() + pixel_distance_to_first_selected_character);
rect.set_height(pixel_width_of_selection);
break;
default:
VERIFY_NOT_REACHED();
}
return rect;
}
if (selection_state == Paintable::SelectionState::Start) {
// we are in the start node
if (end_index < start_offset_in_code_units)
return {};
auto selection_start_in_this_fragment = max(0, start_offset_in_code_units - m_start_offset);
auto selection_end_in_this_fragment = m_length_in_code_units;
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;
auto rect = absolute_rect();
switch (orientation()) {
case Gfx::Orientation::Horizontal:
rect.set_x(rect.x() + pixel_distance_to_first_selected_character);
rect.set_width(pixel_width_of_selection);
break;
case Gfx::Orientation::Vertical:
rect.set_y(rect.y() + pixel_distance_to_first_selected_character);
rect.set_height(pixel_width_of_selection);
break;
default:
VERIFY_NOT_REACHED();
}
return rect;
}
if (selection_state == Paintable::SelectionState::End) {
// we are in the end node
if (start_index > end_offset_in_code_units)
return {};
auto selection_start_in_this_fragment = 0;
auto selection_end_in_this_fragment = min<int>(end_offset_in_code_units - m_start_offset, m_length_in_code_units);
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;
auto rect = absolute_rect();
switch (orientation()) {
case Gfx::Orientation::Horizontal:
rect.set_x(rect.x() + pixel_distance_to_first_selected_character);
rect.set_width(pixel_width_of_selection);
break;
case Gfx::Orientation::Vertical:
rect.set_y(rect.y() + pixel_distance_to_first_selected_character);
rect.set_height(pixel_width_of_selection);
break;
default:
VERIFY_NOT_REACHED();
}
return rect;
}
return {};
}
Gfx::Orientation PaintableFragment::orientation() const
{
switch (m_writing_mode) {
case CSS::WritingMode::HorizontalTb:
return Gfx::Orientation::Horizontal;
case CSS::WritingMode::VerticalRl:
case CSS::WritingMode::VerticalLr:
case CSS::WritingMode::SidewaysRl:
case CSS::WritingMode::SidewaysLr:
return Gfx::Orientation::Vertical;
default:
VERIFY_NOT_REACHED();
}
}
CSSPixelRect PaintableFragment::selection_rect() const
{
if (auto const* focused_element = paintable().document().focused_element(); focused_element && is<HTML::FormAssociatedTextControlElement>(*focused_element)) {
HTML::FormAssociatedTextControlElement const* text_control_element = nullptr;
if (is<HTML::HTMLInputElement>(*focused_element)) {
text_control_element = static_cast<HTML::HTMLInputElement const*>(focused_element);
} else if (is<HTML::HTMLTextAreaElement>(*focused_element)) {
text_control_element = static_cast<HTML::HTMLTextAreaElement const*>(focused_element);
} else {
VERIFY_NOT_REACHED();
}
auto selection_start = text_control_element->selection_start();
auto selection_end = text_control_element->selection_end();
return range_rect(paintable().selection_state(), selection_start, selection_end);
}
auto selection = paintable().document().get_selection();
if (!selection)
return {};
auto range = selection->range();
if (!range)
return {};
return range_rect(paintable().selection_state(), range->start_offset(), range->end_offset());
}
Utf16View PaintableFragment::text() const
{
if (!is<TextPaintable>(paintable()))
return {};
return static_cast<TextPaintable const&>(paintable()).text_for_rendering().substring_view(m_start_offset, m_length_in_code_units);
}
}