LibWeb: Expose document caretPositionFromPoint

Add the CSSOM View CaretPosition interface and expose the Document API.
Use retained hit-test data to populate offsetNode, offset, and
getClientRect().

Update IDL coverage and the window property baseline. Add a text test.
This commit is contained in:
Andreas Kling 2026-05-30 09:57:03 +02:00 committed by Andreas Kling
parent e621739189
commit 5c26e86354
17 changed files with 305 additions and 23 deletions

View file

@ -334,6 +334,7 @@ set(SOURCES
DOM/AnchorNameMap.cpp
DOM/Attr.cpp
DOM/CDATASection.cpp
DOM/CaretPosition.cpp
DOM/CharacterData.cpp
DOM/Comment.cpp
DOM/CustomEvent.cpp

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/Bindings/CaretPosition.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/DOM/CaretPosition.h>
#include <LibWeb/Geometry/DOMRect.h>
namespace Web::DOM {
GC_DEFINE_ALLOCATOR(CaretPosition);
GC::Ref<CaretPosition> CaretPosition::create(JS::Realm& realm, GC::Ref<Node> offset_node, WebIDL::UnsignedLong offset, Optional<Gfx::FloatRect> client_rect)
{
return realm.create<CaretPosition>(realm, offset_node, offset, move(client_rect));
}
CaretPosition::CaretPosition(JS::Realm& realm, GC::Ref<Node> offset_node, WebIDL::UnsignedLong offset, Optional<Gfx::FloatRect> client_rect)
: Bindings::PlatformObject(realm)
, m_offset_node(offset_node)
, m_offset(offset)
, m_client_rect(move(client_rect))
{
}
CaretPosition::~CaretPosition() = default;
void CaretPosition::initialize(JS::Realm& realm)
{
WEB_SET_PROTOTYPE_FOR_INTERFACE(CaretPosition);
Base::initialize(realm);
}
void CaretPosition::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_offset_node);
}
GC::Ptr<Geometry::DOMRect> CaretPosition::get_client_rect() const
{
if (!m_client_rect.has_value())
return nullptr;
return Geometry::DOMRect::create(realm(), *m_client_rect);
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibGfx/Rect.h>
#include <LibWeb/Bindings/PlatformObject.h>
#include <LibWeb/DOM/Node.h>
#include <LibWeb/Forward.h>
#include <LibWeb/WebIDL/Types.h>
namespace Web::DOM {
class CaretPosition final : public Bindings::PlatformObject {
WEB_PLATFORM_OBJECT(CaretPosition, Bindings::PlatformObject);
GC_DECLARE_ALLOCATOR(CaretPosition);
public:
[[nodiscard]] static GC::Ref<CaretPosition> create(JS::Realm&, GC::Ref<Node> offset_node, WebIDL::UnsignedLong offset, Optional<Gfx::FloatRect> client_rect);
virtual ~CaretPosition() override;
GC::Ref<Node> offset_node() const { return m_offset_node; }
WebIDL::UnsignedLong offset() const { return m_offset; }
GC::Ptr<Geometry::DOMRect> get_client_rect() const;
private:
CaretPosition(JS::Realm&, GC::Ref<Node> offset_node, WebIDL::UnsignedLong offset, Optional<Gfx::FloatRect> client_rect);
virtual void initialize(JS::Realm&) override;
virtual void visit_edges(Cell::Visitor&) override;
GC::Ref<Node> m_offset_node;
WebIDL::UnsignedLong m_offset { 0 };
Optional<Gfx::FloatRect> m_client_rect;
};
}

View file

@ -0,0 +1,7 @@
// https://drafts.csswg.org/cssom-view/#caretposition
[Exposed=Window]
interface CaretPosition {
readonly attribute Node offsetNode;
readonly attribute unsigned long offset;
[NewObject] DOMRect? getClientRect();
};

View file

@ -82,6 +82,7 @@
#include <LibWeb/DOM/AdoptedStyleSheets.h>
#include <LibWeb/DOM/Attr.h>
#include <LibWeb/DOM/CDATASection.h>
#include <LibWeb/DOM/CaretPosition.h>
#include <LibWeb/DOM/Comment.h>
#include <LibWeb/DOM/CustomEvent.h>
#include <LibWeb/DOM/DOMImplementation.h>
@ -7206,6 +7207,60 @@ GC::RootVector<GC::Ref<Element>> Document::elements_from_point(double x, double
return sequence;
}
static bool shadow_root_is_allowed_for_caret_position(ShadowRoot const& shadow_root, Bindings::CaretPositionFromPointOptions const& options)
{
for (auto const& allowed_shadow_root : options.shadow_roots) {
if (shadow_root.is_shadow_including_inclusive_ancestor_of(allowed_shadow_root))
return true;
}
return false;
}
// https://drafts.csswg.org/cssom-view/#dom-document-caretpositionfrompoint
GC::Ptr<CaretPosition> Document::caret_position_from_point(double x, double y, Bindings::CaretPositionFromPointOptions const& options)
{
// 1. If there is no viewport associated with the document, return null.
// 2. If either argument is negative, x is greater than the viewport width excluding the size of a rendered scroll
// bar (if any), or y is greater than the viewport height excluding the size of a rendered scroll bar (if any),
// return null.
auto viewport_rect = this->viewport_rect();
CSSPixelPoint position { x, y };
// FIXME: This should account for the size of the scroll bar.
if (x < 0 || y < 0 || position.x() > viewport_rect.width() || position.y() > viewport_rect.height())
return nullptr;
// Ensure the layout tree exists prior to hit testing.
update_layout(UpdateLayoutReason::DocumentCaretPositionFromPoint);
// 3. If at the coordinates x,y in the viewport no text insertion point indicator would have been inserted when
// applying the transforms that apply to the descendants of the viewport, return null.
auto caret_position = caret_position_from_point(position);
if (!caret_position.has_value())
return nullptr;
// FIXME: 4. If at the coordinates x,y in the viewport a text insertion point indicator would have been inserted
// in a text entry widget which is also a replaced element, when applying the transforms that apply to
// the descendants of the viewport, return a caret position for the text entry widget.
// 5. Otherwise, retarget shadow tree positions whose roots are not allowed by options.shadowRoots.
auto start_node = caret_position->boundary.node;
auto start_offset = caret_position->boundary.offset;
auto* shadow_root = as_if<ShadowRoot>(start_node->root());
while (shadow_root && !shadow_root_is_allowed_for_caret_position(*shadow_root, options)) {
auto* host = shadow_root->host();
auto* host_parent = host->parent();
if (!host_parent)
return nullptr;
start_offset = host->index();
start_node = *host_parent;
shadow_root = as_if<ShadowRoot>(start_node->root());
}
return CaretPosition::create(realm(), start_node, start_offset, caret_position->debug_rect.map([](auto const& rect) {
return rect.template to_type<float>();
}));
}
// https://drafts.csswg.org/cssom-view/#dom-document-scrollingelement
GC::Ptr<Element const> Document::scrolling_element() const
{

View file

@ -83,6 +83,7 @@ enum class InvalidateLayoutTreeReason {
X(Debugging) \
X(DocumentElementFromPoint) \
X(DocumentElementsFromPoint) \
X(DocumentCaretPositionFromPoint) \
X(DocumentFindMatchingText) \
X(DocumentSetDesignMode) \
X(DumpDisplayList) \
@ -981,6 +982,7 @@ public:
Painting::HitTestDisplayList const* ensure_hit_test_display_list();
Optional<Painting::HitTestResult> hit_test(CSSPixelPoint, Painting::HitTestType);
Optional<Painting::CaretPosition> caret_position_from_point(CSSPixelPoint);
GC::Ptr<CaretPosition> caret_position_from_point(double x, double y, Bindings::CaretPositionFromPointOptions const&);
TraversalDecision hit_test_all(CSSPixelPoint, Function<TraversalDecision(Painting::HitTestResult)> const&);
void set_needs_to_record_display_list();

View file

@ -109,6 +109,7 @@ interface Document : Node {
// https://drafts.csswg.org/cssom-view/#extensions-to-the-document-interface
Element? elementFromPoint(double x, double y);
sequence<Element> elementsFromPoint(double x, double y);
CaretPosition? caretPositionFromPoint(double x, double y, optional CaretPositionFromPointOptions options = {});
readonly attribute Element? scrollingElement;
// https://w3c.github.io/editing/docs/execCommand/
@ -134,6 +135,10 @@ interface Document : Node {
undefined exitPointerLock();
};
dictionary CaretPositionFromPointOptions {
sequence<ShadowRoot> shadowRoots = [];
};
// https://dom.spec.whatwg.org/#dictdef-elementcreationoptions
dictionary ElementCreationOptions {
CustomElementRegistry? customElementRegistry;

View file

@ -531,6 +531,7 @@ class AccessibilityTreeNode;
class AnchorNameMap;
class Attr;
class CDATASection;
class CaretPosition;
class CharacterData;
class Comment;
class CustomEvent;

View file

@ -488,6 +488,7 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_for_item(Item const&
return CaretPosition {
.paintable = item.paintable,
.boundary = { const_cast<DOM::Node&>(*fragment_dom_node), static_cast<WebIDL::UnsignedLong>(index_in_node) },
.debug_rect = fragment.range_rect(Paintable::SelectionState::StartAndEnd, index_in_node, index_in_node),
};
}
case ItemKind::EmptyEditable: {
@ -497,6 +498,7 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_for_item(Item const&
return CaretPosition {
.paintable = item.paintable,
.boundary = { *dom_node, 0 },
.debug_rect = item.caret_rect,
};
}
case ItemKind::Box: {
@ -521,6 +523,7 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_for_item(Item const&
.paintable = item.paintable,
.boundary = point_is_before_box ? before_boundary : after_boundary,
.secondary_boundary = point_is_before_box ? after_boundary : before_boundary,
.debug_rect = item.caret_rect,
};
}
case ItemKind::ChromeWidget:
@ -538,6 +541,7 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_for_hit_container(Ite
return CaretPosition {
.paintable = item.paintable,
.boundary = { const_cast<DOM::Node&>(*dom_node), 0 },
.debug_rect = item.caret_rect,
};
}
@ -699,8 +703,12 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_from_point(CSSPixelPo
};
if (topmost_item_index.has_value() && (!topmost_hit_item_index.has_value() || topmost_caret_item_matches_hit_item())) {
VERIFY(topmost_item_local_point.has_value());
if (auto caret_position = caret_position_for_item(m_items[*topmost_item_index], *topmost_item_local_point); caret_position.has_value())
if (auto caret_position = caret_position_for_item(m_items[*topmost_item_index], *topmost_item_local_point); caret_position.has_value()) {
auto const& item = m_items[*topmost_item_index];
if (caret_position->debug_rect.has_value())
caret_position->debug_rect = viewport_rect_for_item(item, *caret_position->debug_rect, viewport_paintable, device_pixels_per_css_pixel);
return caret_position;
}
}
// If the point is over a non-caret item, only consider caret lines inside that item's event-dispatch node first.
@ -814,21 +822,31 @@ Optional<CaretPosition> HitTestDisplayList::caret_position_from_point(CSSPixelPo
}
if (!closest_line.index.has_value()) {
if (topmost_hit_item_index.has_value())
return caret_position_for_hit_container(m_items[*topmost_hit_item_index]);
if (topmost_hit_item_index.has_value()) {
auto const& item = m_items[*topmost_hit_item_index];
auto caret_position = caret_position_for_hit_container(item);
if (caret_position.has_value() && caret_position->debug_rect.has_value())
caret_position->debug_rect = viewport_rect_for_item(item, *caret_position->debug_rect, viewport_paintable, device_pixels_per_css_pixel);
return caret_position;
}
return {};
}
VERIFY(closest_line.local_point.has_value());
auto caret_position = caret_position_for_line(m_caret_lines[*closest_line.index], *closest_line.local_point);
if (!caret_position.has_value())
return {};
if (caret_position->debug_rect.has_value())
caret_position->debug_rect = viewport_rect_for_item(m_items[m_caret_item_indices[m_caret_lines[*closest_line.index].first_caret_item_index]], *caret_position->debug_rect, viewport_paintable, device_pixels_per_css_pixel);
if (topmost_hit_item_index.has_value()) {
auto const& topmost_hit_item = m_items[*topmost_hit_item_index];
if (auto const* topmost_hit_dom_node = event_dispatch_dom_node_for_item(topmost_hit_item); topmost_hit_dom_node && !topmost_hit_dom_node->is_inclusive_ancestor_of(*caret_position->boundary.node)) {
if (item_can_produce_caret_position(topmost_hit_item) && item_is_direct_caret_target(topmost_hit_item)) {
VERIFY(topmost_hit_item_local_point.has_value());
return caret_position_for_item(topmost_hit_item, *topmost_hit_item_local_point);
auto caret_position_for_topmost_hit_item = caret_position_for_item(topmost_hit_item, *topmost_hit_item_local_point);
if (caret_position_for_topmost_hit_item.has_value() && caret_position_for_topmost_hit_item->debug_rect.has_value())
caret_position_for_topmost_hit_item->debug_rect = viewport_rect_for_item(topmost_hit_item, *caret_position_for_topmost_hit_item->debug_rect, viewport_paintable, device_pixels_per_css_pixel);
return caret_position_for_topmost_hit_item;
}
return {};
}

View file

@ -39,6 +39,7 @@ struct CaretPosition {
NonnullRefPtr<Paintable> paintable;
DOM::BoundaryPoint boundary;
Optional<DOM::BoundaryPoint> secondary_boundary {};
Optional<CSSPixelRect> debug_rect {};
};
enum class HitTestType : u8 {

View file

@ -109,6 +109,7 @@ libweb_js_bindings(DOM/AbortSignal)
libweb_js_bindings(DOM/AbstractRange)
libweb_js_bindings(DOM/Attr)
libweb_js_bindings(DOM/CDATASection)
libweb_js_bindings(DOM/CaretPosition)
libweb_js_bindings(DOM/CharacterData)
libweb_js_bindings(DOM/ChildNode)
libweb_js_bindings(DOM/Comment)

View file

@ -100,6 +100,7 @@ CacheStorage
CanvasGradient
CanvasPattern
CanvasRenderingContext2D
CaretPosition
ChannelMergerNode
ChannelSplitterNode
CharacterData

View file

@ -0,0 +1,6 @@
without shadowRoots retargets to host parent: true
without shadowRoots retargets to host index: true
unrelated shadowRoot still retargets to host parent: true
unrelated shadowRoot still retargets to host index: true
allowed shadowRoot exposes text node: true
allowed shadowRoot exposes text offset: true

View file

@ -0,0 +1,6 @@
negative x returns null: true
negative y returns null: true
caret position exists: true
offset node is text node: true
offset is valid for text node: true
client rect exists: true

View file

@ -2,8 +2,8 @@ Harness status: OK
Found 400 tests
326 Pass
74 Fail
343 Pass
57 Fail
Pass idl_test setup
Pass idl_test validation
Pass Partial interface Window: original interface defined
@ -106,20 +106,20 @@ Pass Screen interface: screen must inherit property "width" with the proper type
Pass Screen interface: screen must inherit property "height" with the proper type
Pass Screen interface: screen must inherit property "colorDepth" with the proper type
Pass Screen interface: screen must inherit property "pixelDepth" with the proper type
Fail CaretPosition interface: existence and properties of interface object
Fail CaretPosition interface object length
Fail CaretPosition interface object name
Fail CaretPosition interface: existence and properties of interface prototype object
Fail CaretPosition interface: existence and properties of interface prototype object's "constructor" property
Fail CaretPosition interface: existence and properties of interface prototype object's @@unscopables property
Fail CaretPosition interface: attribute offsetNode
Fail CaretPosition interface: attribute offset
Fail CaretPosition interface: operation getClientRect()
Fail CaretPosition must be primary interface of document.caretPositionFromPoint(5, 5)
Fail Stringification of document.caretPositionFromPoint(5, 5)
Fail CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "offsetNode" with the proper type
Fail CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "offset" with the proper type
Fail CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "getClientRect()" with the proper type
Pass CaretPosition interface: existence and properties of interface object
Pass CaretPosition interface object length
Pass CaretPosition interface object name
Pass CaretPosition interface: existence and properties of interface prototype object
Pass CaretPosition interface: existence and properties of interface prototype object's "constructor" property
Pass CaretPosition interface: existence and properties of interface prototype object's @@unscopables property
Pass CaretPosition interface: attribute offsetNode
Pass CaretPosition interface: attribute offset
Pass CaretPosition interface: operation getClientRect()
Pass CaretPosition must be primary interface of document.caretPositionFromPoint(5, 5)
Pass Stringification of document.caretPositionFromPoint(5, 5)
Pass CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "offsetNode" with the proper type
Pass CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "offset" with the proper type
Pass CaretPosition interface: document.caretPositionFromPoint(5, 5) must inherit property "getClientRect()" with the proper type
Pass VisualViewport interface: existence and properties of interface object
Pass VisualViewport interface object length
Pass VisualViewport interface object name
@ -309,7 +309,7 @@ Pass Window interface: window must inherit property "outerHeight" with the prope
Pass Window interface: window must inherit property "devicePixelRatio" with the proper type
Pass Document interface: operation elementFromPoint(double, double)
Pass Document interface: operation elementsFromPoint(double, double)
Fail Document interface: operation caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions)
Pass Document interface: operation caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions)
Pass Document interface: attribute scrollingElement
Fail Document interface: operation getBoxQuads(optional BoxQuadOptions)
Fail Document interface: operation convertQuadFromNode(DOMQuadInit, GeometryNode, optional ConvertCoordinateOptions)
@ -319,8 +319,8 @@ Pass Document interface: document must inherit property "elementFromPoint(double
Pass Document interface: calling elementFromPoint(double, double) on document with too few arguments must throw TypeError
Pass Document interface: document must inherit property "elementsFromPoint(double, double)" with the proper type
Pass Document interface: calling elementsFromPoint(double, double) on document with too few arguments must throw TypeError
Fail Document interface: document must inherit property "caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions)" with the proper type
Fail Document interface: calling caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions) on document with too few arguments must throw TypeError
Pass Document interface: document must inherit property "caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions)" with the proper type
Pass Document interface: calling caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions) on document with too few arguments must throw TypeError
Pass Document interface: document must inherit property "scrollingElement" with the proper type
Fail Document interface: document must inherit property "getBoxQuads(optional BoxQuadOptions)" with the proper type
Fail Document interface: calling getBoxQuads(optional BoxQuadOptions) on document with too few arguments must throw TypeError

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
font: 16px Arial, sans-serif;
margin: 0;
}
#container {
margin: 20px;
}
</style>
<div id="container"><span id="host"></span><span id="other-host"></span></div>
<script>
function textRect(textNode, text) {
const index = textNode.data.indexOf(text);
const range = document.createRange();
range.setStart(textNode, index);
range.setEnd(textNode, index + text.length);
return range.getBoundingClientRect();
}
test(() => {
const host = document.getElementById("host");
const otherHost = document.getElementById("other-host");
const shadowRoot = host.attachShadow({ mode: "open" });
const inner = document.createElement("span");
inner.textContent = "shadow text";
shadowRoot.append(inner);
const otherShadowRoot = otherHost.attachShadow({ mode: "open" });
otherShadowRoot.append("other shadow text");
document.body.offsetWidth;
const textNode = inner.firstChild;
const rect = textRect(textNode, "shadow");
const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
const hostIndex = Array.prototype.indexOf.call(container.childNodes, host);
const retargetedPosition = document.caretPositionFromPoint(x, y);
println(`without shadowRoots retargets to host parent: ${retargetedPosition.offsetNode === container}`);
println(`without shadowRoots retargets to host index: ${retargetedPosition.offset === hostIndex}`);
const unrelatedShadowRootPosition = document.caretPositionFromPoint(x, y, { shadowRoots: [otherShadowRoot] });
println(`unrelated shadowRoot still retargets to host parent: ${unrelatedShadowRootPosition.offsetNode === container}`);
println(`unrelated shadowRoot still retargets to host index: ${unrelatedShadowRootPosition.offset === hostIndex}`);
const exposedPosition = document.caretPositionFromPoint(x, y, { shadowRoots: [shadowRoot] });
println(`allowed shadowRoot exposes text node: ${exposedPosition.offsetNode === textNode}`);
println(`allowed shadowRoot exposes text offset: ${exposedPosition.offset >= 0 && exposedPosition.offset <= textNode.length}`);
});
</script>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
font: 16px Arial, sans-serif;
margin: 0;
}
#target {
margin: 20px;
}
</style>
<div id="target">hello world</div>
<script>
test(() => {
document.body.offsetWidth;
const textNode = target.firstChild;
const range = document.createRange();
range.setStart(textNode, 1);
range.setEnd(textNode, 1);
const rect = range.getBoundingClientRect();
const caretPosition = document.caretPositionFromPoint(rect.x, rect.y + rect.height / 2);
println(`negative x returns null: ${document.caretPositionFromPoint(-1, 0) === null}`);
println(`negative y returns null: ${document.caretPositionFromPoint(0, -1) === null}`);
println(`caret position exists: ${caretPosition instanceof CaretPosition}`);
println(`offset node is text node: ${caretPosition.offsetNode === textNode}`);
println(`offset is valid for text node: ${caretPosition.offset >= 0 && caretPosition.offset <= textNode.length}`);
println(`client rect exists: ${caretPosition.getClientRect() instanceof DOMRect}`);
});
</script>