Skip to content

elementFromPoint + elementsFromPoint #642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions src/browser/dom/element.zig
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,28 @@ pub const Element = struct {
return Node.replaceChildren(parser.elementToNode(self), nodes);
}

// A DOMRect object providing information about the size of an element and its position relative to the viewport.
// Returns a 0 DOMRect object if the element is eventually detached from the main window
pub fn _getBoundingClientRect(self: *parser.Element, state: *SessionState) !DOMRect {
// Since we are lazy rendering we need to do this check. We could store the renderer in a viewport such that it could cache these, but it would require tracking changes.
const root = try parser.nodeGetRootNode(parser.elementToNode(self));
if (root != parser.documentToNode(parser.documentHTMLToDocument(state.window.document.?))) {
return DOMRect{ .x = 0, .y = 0, .width = 0, .height = 0 };
}
return state.renderer.getRect(self);
}

// returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
// We do not render so just always return the element's rect.
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![1]DOMRect {
return [_]DOMRect{try state.renderer.getRect(self)};
// Returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
// We do not render so it only always return the element's bounding rect.
// Returns an empty array if the element is eventually detached from the main window
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![]DOMRect {
const root = try parser.nodeGetRootNode(parser.elementToNode(self));
if (root != parser.documentToNode(parser.documentHTMLToDocument(state.window.document.?))) {
return &.{};
}
const heap_ptr = try state.call_arena.create(DOMRect);
heap_ptr.* = try state.renderer.getRect(self);
return heap_ptr[0..1];
}

pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 {
Expand Down Expand Up @@ -568,6 +582,26 @@ test "Browser.DOM.Element" {

.{ "document.getElementById('para').clientWidth", "2" },
.{ "document.getElementById('para').clientHeight", "1" },

.{ "let r4 = document.createElement('div').getBoundingClientRect()", null },
.{ "r4.x", "0" },
.{ "r4.y", "0" },
.{ "r4.width", "0" },
.{ "r4.height", "0" },

// Test setup causes WrongDocument or HierarchyRequest error unlike in chrome/firefox
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that we should not be throwing these errors in this use case, so a pre-exiting bug that is not fixed in the PR

// .{ // An element of another document, even if created from the main document, is not rendered.
// \\ let div5 = document.createElement('div');
// \\ const newDoc = document.implementation.createHTMLDocument("New Document");
// \\ newDoc.body.appendChild(div5);
// \\ let r5 = div5.getBoundingClientRect();
// ,
// null,
// },
// .{ "r5.x", "0" },
// .{ "r5.y", "0" },
// .{ "r5.width", "0" },
// .{ "r5.height", "0" },
}, .{});

try runner.testCases(&.{
Expand Down
13 changes: 8 additions & 5 deletions src/browser/dom/intersection_observer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub const IntersectionObserverEntry = struct {

// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return self.state.renderer.getRect(self.target);
return Element._getBoundingClientRect(self.target, self.state);
}

// Returns the ratio of the intersectionRect to the boundingClientRect.
Expand All @@ -131,7 +131,7 @@ pub const IntersectionObserverEntry = struct {

// Returns a DOMRectReadOnly representing the target's visible area.
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return self.state.renderer.getRect(self.target);
return Element._getBoundingClientRect(self.target, self.state);
}

// A Boolean value which is true if the target element intersects with the intersection observer's root. If this is true, then, the IntersectionObserverEntry describes a transition into a state of intersection; if it's false, then you know the transition is from intersecting to not-intersecting.
Expand All @@ -158,7 +158,7 @@ pub const IntersectionObserverEntry = struct {
else => return error.InvalidState,
}

return try self.state.renderer.getRect(element);
return Element._getBoundingClientRect(element, self.state);
}

// The Element whose intersection with the root changed.
Expand Down Expand Up @@ -244,7 +244,9 @@ test "Browser.DOM.IntersectionObserver" {
// Entry
try runner.testCases(&.{
.{ "let entry;", "undefined" },
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(document.createElement('div'));", "undefined" },
.{ "let div1 = document.createElement('div')", null },
.{ "document.body.appendChild(div1);", null },
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1);", null },
.{ "entry.boundingClientRect.x;", "0" },
.{ "entry.intersectionRatio;", "1" },
.{ "entry.intersectionRect.x;", "0" },
Expand All @@ -261,7 +263,8 @@ test "Browser.DOM.IntersectionObserver" {

// Options
try runner.testCases(&.{
.{ "const new_root = document.createElement('span');", "undefined" },
.{ "const new_root = document.createElement('span');", null },
.{ "document.body.appendChild(new_root);", null },
.{ "let new_entry;", "undefined" },
.{
\\ const new_observer = new IntersectionObserver(
Expand Down
18 changes: 11 additions & 7 deletions src/browser/dom/node.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const Walker = @import("walker.zig").WalkerDepthFirst;
const HTML = @import("../html/html.zig");
const HTMLElem = @import("../html/elements.zig");

const log = std.log.scoped(.node);

// Node interfaces
pub const Interfaces = .{
Attr,
Expand Down Expand Up @@ -262,13 +264,15 @@ pub const Node = struct {
return try parser.nodeContains(self, other);
}

pub fn _getRootNode(self: *parser.Node) !?HTMLElem.Union {
// TODO return this’s shadow-including root if options["composed"] is true
const res = try parser.nodeOwnerDocument(self);
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
// Returns itself or ancestor object inheriting from Node.
// - An Element inside a standard web page will return an HTMLDocument object representing the entire page (or <iframe>).
// - An Element inside a shadow DOM will return the associated ShadowRoot.
// - An Element that is not attached to a document or a shadow tree will return the root of the DOM tree it belongs to
pub fn _getRootNode(self: *parser.Node, options: ?struct { composed: bool = false }) !Union {
if (options) |options_| if (options_.composed) {
log.warn("getRootNode composed is not implemented yet", .{});
};
return try Node.toInterface(try parser.nodeGetRootNode(self));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just trying to understand.

If @intFromPtr(self) == @intFromPtr(state.document.?), you could use the existing parser.nodeOwnerDocument ?

But for cases where the document isn't attached, and possibly also the shadow dom, you use the new nodeGetRootNode (which is also safe to use if it is attached, just not as efficient).

Is that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. if self == state.document than we can return self [htmldocument ]
  2. if self == state.document.documentElement than we can return state.document [htmldocument ]
    In those cases that would also be the same as parser.nodeOwnerDocument

Note that nodeOwnerDocument is set even for detached nodes. It references the original document until it is appended as child to another document.
So we cannot use nodeOwnerDocument to determine whether a node is detached.
So yes, if we know that the node is attached parser.nodeOwnerDocument would yield the correct answer.

I actually now use the difference between getRootNode and nodeOwnerDocument to determine whether an element is or is not detached.

}

pub fn _hasChildNodes(self: *parser.Node) !bool {
Expand Down
73 changes: 73 additions & 0 deletions src/browser/html/document.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;

const Window = @import("window.zig").Window;
const Element = @import("../dom/element.zig").Element;
const ElementUnion = @import("../dom/element.zig").Union;
const Document = @import("../dom/document.zig").Document;
const NodeList = @import("../dom/nodelist.zig").NodeList;
const Location = @import("location.zig").Location;
Expand Down Expand Up @@ -226,6 +228,43 @@ pub const HTMLDocument = struct {
return "";
}

// Returns the topmost Element at the specified coordinates (relative to the viewport).
// Since LightPanda requires the client to know what they are clicking on we do not return the underlying element at this moment
// This can currenty only happen if the first pixel is clicked without having rendered any element. This will change when css properties are supported.
// This returns an ElementUnion instead of a *Parser.Element in case the element somehow hasn't passed through the js runtime yet.
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, state: *SessionState) !?ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = state.renderer.getElementAtPosition(ix, iy) orelse return null;
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
return try Element.toInterface(element);
}

// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, state: *SessionState) ![]ElementUnion {
const ix: i32 = @intFromFloat(@floor(x));
const iy: i32 = @intFromFloat(@floor(y));
const element = state.renderer.getElementAtPosition(ix, iy) orelse return &.{};
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)

var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
try list.ensureTotalCapacity(state.call_arena, 3);
list.appendAssumeCapacity(try Element.toInterface(element));

// Since we are using a flat renderer there is no hierarchy of elements. What we do know is that the element is part of the main document.
// Thus we can add the HtmlHtmlElement and it's child HTMLBodyElement to the returned list.
// TBD Should we instead return every parent that is an element? Note that a child does not physically need to be overlapping the parent.
// Should we do a render pass on demand?
const doc_elem = try parser.documentGetDocumentElement(parser.documentHTMLToDocument(state.window.document.?)) orelse {
return list.items;
};
if (try parser.documentHTMLBody(state.window.document.?)) |body| {
list.appendAssumeCapacity(try Element.toInterface(parser.bodyToElement(body)));
}
list.appendAssumeCapacity(try Element.toInterface(doc_elem));
return list.items;
}

pub fn documentIsLoaded(html_doc: *parser.DocumentHTML, state: *SessionState) !void {
const self = try state.getNodeWrapper(HTMLDocument, @ptrCast(html_doc));
self.ready_state = .interactive;
Expand Down Expand Up @@ -295,6 +334,40 @@ test "Browser.HTML.Document" {
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
}, .{});

try runner.testCases(&.{
.{ "document.elementFromPoint(0.5, 0.5)", "null" }, // Return null since we only return element s when they have previously been localized
.{ "document.elementsFromPoint(0.5, 0.5)", "" },
.{
\\ let div1 = document.createElement('div');
\\ document.body.appendChild(div1);
\\ div1.getClientRects();
,
null,
},
.{ "document.elementFromPoint(0.5, 0.5)", "[object HTMLDivElement]" },
.{ "let elems = document.elementsFromPoint(0.5, 0.5)", null },
.{ "elems.length", "3" },
.{ "elems[0]", "[object HTMLDivElement]" },
.{ "elems[1]", "[object HTMLBodyElement]" },
.{ "elems[2]", "[object HTMLHtmlElement]" },
}, .{});

try runner.testCases(&.{
.{
\\ let a = document.createElement('a');
\\ a.href = "https://lightpanda.io";
\\ document.body.appendChild(a);
\\ a.getClientRects();
, // Note this will be placed after the div of previous test
null,
},
.{ "let a_again = document.elementFromPoint(1.5, 0.5)", null },
.{ "a_again", "[object HTMLAnchorElement]" },
.{ "a_again.href", "https://lightpanda.io" },
.{ "let a_agains = document.elementsFromPoint(1.5, 0.5)", null },
.{ "a_agains[0].href", "https://lightpanda.io" },
}, .{});

try runner.testCases(&.{
.{ "!document.all", "true" },
.{ "!!document.all", "false" },
Expand Down
16 changes: 14 additions & 2 deletions src/browser/html/window.zig
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,20 @@ test "Browser.HTML.Window" {
try runner.testCases(&.{
.{ "innerHeight", "1" },
.{ "innerWidth", "1" }, // Width is 1 even if there are no elements
.{ "document.createElement('div').getClientRects()", null },
.{ "document.createElement('div').getClientRects()", null },
.{
\\ let div1 = document.createElement('div');
\\ document.body.appendChild(div1);
\\ div1.getClientRects();
,
null,
},
.{
\\ let div2 = document.createElement('div');
\\ document.body.appendChild(div2);
\\ div2.getClientRects();
,
null,
},
.{ "innerHeight", "1" },
.{ "innerWidth", "2" },
}, .{});
Expand Down
15 changes: 15 additions & 0 deletions src/browser/netsurf.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,17 @@ pub fn nodeGetChildNodes(node: *Node) !*NodeList {
return nlist.?;
}

pub fn nodeGetRootNode(node: *Node) !*Node {
var root = node;
while (true) {
const parent = try nodeParentNode(root);
if (parent) |parent_| {
root = parent_;
} else break;
}
return root;
}

pub fn nodeAppendChild(node: *Node, child: *Node) !*Node {
var res: ?*Node = undefined;
const err = nodeVtable(node).dom_node_append_child.?(node, child, &res);
Expand Down Expand Up @@ -2257,6 +2268,10 @@ pub inline fn documentHTMLBody(doc_html: *DocumentHTML) !?*Body {
return @as(*Body, @ptrCast(body.?));
}

pub inline fn bodyToElement(body: *Body) *Element {
return @as(*Element, @ptrCast(body));
}

pub inline fn documentHTMLSetBody(doc_html: *DocumentHTML, elt: ?*ElementHTML) !void {
const err = documentHTMLVtable(doc_html).set_body.?(doc_html, elt);
try DOMErr(err);
Expand Down
2 changes: 2 additions & 0 deletions src/browser/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const FlatRenderer = struct {
};
}

// The DOMRect is always relative to the viewport, not the document the element belongs to.
// Element that are not part of the main document, either detached or in a shadow DOM should not call this function.
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
var elements = &self.elements;
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
Expand Down
8 changes: 4 additions & 4 deletions src/cdp/domains/dom.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const Node = @import("../Node.zig");
const css = @import("../../browser/dom/css.zig");
const parser = @import("../../browser/netsurf.zig");
const dom_node = @import("../../browser/dom/node.zig");
const DOMRect = @import("../../browser/dom/element.zig").Element.DOMRect;
const Element = @import("../../browser/dom/element.zig").Element;

pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
Expand Down Expand Up @@ -253,7 +253,7 @@ fn describeNode(cmd: anytype) !void {
// We are assuming the start/endpoint is not repeated.
const Quad = [8]f64;

fn rectToQuad(rect: DOMRect) Quad {
fn rectToQuad(rect: Element.DOMRect) Quad {
return Quad{
rect.x,
rect.y,
Expand All @@ -271,7 +271,7 @@ fn scrollIntoViewIfNeeded(cmd: anytype) !void {
nodeId: ?Node.Id = null,
backendNodeId: ?u32 = null,
objectId: ?[]const u8 = null,
rect: ?DOMRect = null,
rect: ?Element.DOMRect = null,
})) orelse return error.InvalidParams;
// Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null

Expand Down Expand Up @@ -327,7 +327,7 @@ fn getContentQuads(cmd: anytype) !void {
// Elements like SVGElement may have multiple quads.

const element = parser.nodeToElement(node._node);
const rect = try bc.session.page.?.state.renderer.getRect(element);
const rect = try Element._getBoundingClientRect(element, &bc.session.page.?.state);
const quad = rectToQuad(rect);

return cmd.sendResult(.{ .quads = &.{quad} }, .{});
Expand Down