Add a dumb renderer to get coordinates

FlatRenderer positions items on a single row, giving each a height and width of
1.

Added getBoundingClientRect to the DOMelement which, when requested for the
first time, will place the item in with the renderer.

The goal here is to give elements a fixed position and to make it easy to map
x,y coordinates onto an element. This should work, at least with puppeteer,
since it first requests the boundingClientRect before issuing a click.
This commit is contained in:
Karl Seguin
2025-04-01 17:51:33 +08:00
parent 647575261e
commit 0253de80de
10 changed files with 285 additions and 23 deletions

View File

@@ -298,10 +298,14 @@ pub const Page = struct {
// current_script could by fetch module to resolve module's url to fetch.
current_script: ?*const Script = null,
renderer: FlatRenderer,
fn init(session: *Session) Page {
const arena = session.browser.page_arena.allocator();
return .{
.arena = arena,
.session = session,
.arena = session.browser.page_arena.allocator(),
.renderer = FlatRenderer.init(arena),
};
}
@@ -423,6 +427,32 @@ pub const Page = struct {
}
}
pub const ClickResult = union(enum) {
navigate: std.Uri,
};
pub fn click(self: *Page, allocator: Allocator, x: u32, y: u32) !?ClickResult {
const element = self.renderer.getElementAtPosition(x, y) orelse return null;
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, "click", .{ .bubbles = true, .cancelable = true });
if ((try parser.eventDefaultPrevented(event)) == true) {
return null;
}
const node = parser.elementToNode(element);
const tag = try parser.nodeName(node);
if (std.ascii.eqlIgnoreCase(tag, "a")) {
const href = (try parser.elementGetAttribute(element, "href")) orelse return null;
var buf = try allocator.alloc(u8, 1024);
return .{ .navigate = try std.Uri.resolve_inplace(self.uri, href, &buf) };
}
return null;
}
// https://html.spec.whatwg.org/#read-html
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8, aux_data: ?[]const u8) !void {
const arena = self.arena;
@@ -462,6 +492,7 @@ pub const Page = struct {
try session.env.setUserContext(.{
.uri = self.uri,
.document = html_doc,
.renderer = @ptrCast(&self.renderer),
.cookie_jar = @ptrCast(&self.session.cookie_jar),
.http_client = @ptrCast(self.session.http_client),
});
@@ -753,6 +784,70 @@ pub const Page = struct {
};
};
// provide very poor abstration to the rest of the code. In theory, we can change
// the FlatRendere to a different implementation, and it'll all just work.
pub const Renderer = FlatRenderer;
// This "renderer" positions elements in a single row in an unspecified order.
// The important thing is that elements have a consistent position/index within
// that row, which can be turned into a rectangle.
const FlatRenderer = struct {
allocator: Allocator,
// key is a @ptrFromInt of the element
// value is the index position
positions: std.AutoHashMapUnmanaged(u64, u32),
// given an index, get the element
elements: std.ArrayListUnmanaged(u64),
const Element = @import("../dom/element.zig").Element;
// we expect allocator to be an arena
pub fn init(allocator: Allocator) FlatRenderer {
return .{
.elements = .{},
.positions = .{},
.allocator = allocator,
};
}
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
var elements = &self.elements;
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
var x: u32 = gop.value_ptr.*;
if (gop.found_existing == false) {
try elements.append(self.allocator, @intFromPtr(e));
x = @intCast(elements.items.len);
gop.value_ptr.* = x;
}
return .{
.x = @floatFromInt(x),
.y = 0.0,
.width = 1.0,
.height = 1.0,
};
}
pub fn width(self: *const FlatRenderer) u32 {
return @intCast(self.elements.items.len);
}
pub fn height(_: *const FlatRenderer) u32 {
return 1;
}
pub fn getElementAtPosition(self: *const FlatRenderer, x: u32, y: u32) ?*parser.Element {
if (y > 1) {
return null;
}
const elements = self.elements.items;
return if (x < elements.len) @ptrFromInt(elements[x]) else null;
}
};
const NoopInspector = struct {
pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}

View File

@@ -37,6 +37,7 @@ pub const CDP = CDPT(struct {
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID");
const LoaderIdGen = Incrementing(u32, "LID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
@@ -54,6 +55,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
target_auto_attach: bool = false,
target_id_gen: TargetIdGen = .{},
loader_id_gen: LoaderIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
@@ -183,6 +185,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint("Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint("Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {

80
src/cdp/domains/input.zig Normal file
View File

@@ -0,0 +1,80 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchMouseEvent,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
fn dispatchMouseEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {
type: []const u8,
x: u32,
y: u32,
})) orelse return error.InvalidParams;
try cmd.sendResult(null, .{});
if (std.ascii.eqlIgnoreCase(params.type, "mousePressed") == false) {
return;
}
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const click_result = (try page.click(cmd.arena, params.x, params.y)) orelse return;
switch (click_result) {
.navigate => |uri| try clickNavigate(cmd, uri),
}
// result already sent
}
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
const bc = cmd.browser_context.?;
var url_buf: std.ArrayListUnmanaged(u8) = .{};
try uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.port = true,
.path = true,
.query = true,
}, url_buf.writer(cmd.arena));
const url = url_buf.items;
try cmd.sendEvent("Page.frameRequestedNavigation", .{
.url = url,
.frameId = bc.target_id.?,
.reason = "anchorClick",
.disposition = "currentTab",
}, .{ .session_id = bc.session_id.? });
bc.session.removePage();
_ = try bc.session.createPage(null);
try @import("page.zig").navigateToUrl(cmd, url, false);
}

View File

@@ -129,6 +129,18 @@ fn createIsolatedWorld(cmd: anytype) !void {
}
fn navigate(cmd: anytype) !void {
const params = (try cmd.params(struct {
url: []const u8,
// referrer: ?[]const u8 = null,
// transitionType: ?[]const u8 = null, // TODO: enum
// frameId: ?[]const u8 = null,
// referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
return navigateToUrl(cmd, params.url, true);
}
pub fn navigateToUrl(cmd: anytype, url: []const u8, send_result: bool) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// didn't create?
@@ -140,20 +152,10 @@ fn navigate(cmd: anytype) !void {
// if we have a target_id we have to have a page;
std.debug.assert(bc.session.page != null);
const params = (try cmd.params(struct {
url: []const u8,
referrer: ?[]const u8 = null,
transitionType: ?[]const u8 = null, // TODO: enum
frameId: ?[]const u8 = null,
referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
// change state
bc.reset();
bc.url = params.url;
// TODO: hard coded ID
bc.loader_id = "AF8667A203C5392DBE9AC290044AA4C2";
bc.url = url;
bc.loader_id = cmd.cdp.loader_id_gen.next();
const LifecycleEvent = struct {
frameId: []const u8,
@@ -180,10 +182,12 @@ fn navigate(cmd: anytype) !void {
}
// output
try cmd.sendResult(.{
.frameId = target_id,
.loaderId = bc.loader_id,
}, .{});
if (send_result) {
try cmd.sendResult(.{
.frameId = target_id,
.loaderId = bc.loader_id,
}, .{});
}
// TODO: at this point do we need async the following actions to be async?
@@ -199,7 +203,7 @@ fn navigate(cmd: anytype) !void {
);
var page = bc.session.currentPage().?;
try page.navigate(params.url, aux_data);
try page.navigate(url, aux_data);
// Events
@@ -218,7 +222,7 @@ fn navigate(cmd: anytype) !void {
.type = "Navigation",
.frame = Frame{
.id = target_id,
.url = bc.url,
.url = url,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
.loaderId = bc.loader_id,

View File

@@ -106,11 +106,17 @@ const Page = struct {
aux_data: []const u8 = "",
doc: ?*parser.Document = null,
pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void {
_ = self;
pub fn navigate(_: *Page, url: []const u8, aux_data: []const u8) !void {
_ = url;
_ = aux_data;
}
const ClickResult = @import("../browser/browser.zig").Page.ClickResult;
pub fn click(_: *Page, _: Allocator, x: u32, y: u32) !?ClickResult {
_ = x;
_ = y;
return null;
}
};
const Client = struct {

View File

@@ -33,6 +33,7 @@ const Node = @import("node.zig").Node;
const Walker = @import("walker.zig").WalkerDepthFirst;
const NodeList = @import("nodelist.zig").NodeList;
const HTMLElem = @import("../html/elements.zig");
const UserContext = @import("../user_context.zig").UserContext;
pub const Union = @import("../html/elements.zig").Union;
const DOMException = @import("exceptions.zig").DOMException;
@@ -43,6 +44,13 @@ pub const Element = struct {
pub const prototype = *Node;
pub const mem_guarantied = true;
pub const DOMRect = struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
pub fn toInterface(e: *parser.Element) !Union {
return try HTMLElem.toInterface(Union, e);
}
@@ -339,6 +347,18 @@ pub const Element = struct {
return Node.replaceChildren(parser.elementToNode(self), nodes);
}
pub fn _getBoundingClientRect(self: *parser.Element, user_context: UserContext) !DOMRect {
return user_context.renderer.getRect(self);
}
pub fn get_clientWidth(_: *parser.Element, user_context: UserContext) u32 {
return user_context.renderer.width();
}
pub fn get_clientHeight(_: *parser.Element, user_context: UserContext) u32 {
return user_context.renderer.height();
}
pub fn deinit(_: *parser.Element, _: std.mem.Allocator) void {}
};
@@ -484,5 +504,33 @@ pub fn testExecFn(
var outerHTML = [_]Case{
.{ .src = "document.getElementById('para').outerHTML", .ex = "<p id=\"para\"> And</p>" },
};
var getBoundingClientRect = [_]Case{
.{ .src = "document.getElementById('para').clientWidth", .ex = "0" },
.{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
.{ .src = "let r1 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r1.x", .ex = "1" },
.{ .src = "r1.y", .ex = "0" },
.{ .src = "r1.width", .ex = "1" },
.{ .src = "r1.height", .ex = "1" },
.{ .src = "let r2 = document.getElementById('content').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r2.x", .ex = "2" },
.{ .src = "r2.y", .ex = "0" },
.{ .src = "r2.width", .ex = "1" },
.{ .src = "r2.height", .ex = "1" },
.{ .src = "let r3 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
.{ .src = "r3.x", .ex = "1" },
.{ .src = "r3.y", .ex = "0" },
.{ .src = "r3.width", .ex = "1" },
.{ .src = "r3.height", .ex = "1" },
.{ .src = "document.getElementById('para').clientWidth", .ex = "2" },
.{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
};
try checkCases(js_env, &getBoundingClientRect);
try checkCases(js_env, &outerHTML);
}

View File

@@ -25,6 +25,7 @@ const pretty = @import("pretty");
const parser = @import("netsurf");
const apiweb = @import("apiweb.zig");
const browser = @import("browser/browser.zig");
const Window = @import("html/window.zig").Window;
const xhr = @import("xhr/xhr.zig");
const storage = @import("storage/storage.zig");
@@ -100,9 +101,14 @@ fn testExecFn(
var cookie_jar = storage.CookieJar.init(alloc);
defer cookie_jar.deinit();
var renderer = browser.Renderer.init(alloc);
defer renderer.elements.deinit(alloc);
defer renderer.positions.deinit(alloc);
try js_env.setUserContext(.{
.uri = try std.Uri.parse(url),
.document = doc,
.renderer = &renderer,
.cookie_jar = &cookie_jar,
.http_client = &http_client,
});

View File

@@ -801,6 +801,24 @@ pub fn eventTargetDispatchEvent(et: *EventTarget, event: *Event) !bool {
return res;
}
const DispatchOpts = struct {
type: []const u8,
bubbles: bool = true,
cancelable: bool = true,
};
pub fn elementDispatchEvent(element: *Element, opts: DispatchOpts) !bool {
const event = try eventCreate();
defer eventDestroy(event);
try eventInit(event, opts.type, .{ .bubbles = opts.bubbles, .cancelable = opts.cancelable });
var res: bool = undefined;
const et: *EventTarget = @ptrCast(element);
const err = eventTargetVtable(et).dispatch_event.?(et, event, &res);
try DOMErr(err);
return res;
}
pub fn eventTargetTBaseFieldName(comptime T: type) ?[]const u8 {
std.debug.assert(@inComptime());
switch (@typeInfo(T)) {

View File

@@ -2,10 +2,12 @@ const std = @import("std");
const parser = @import("netsurf");
const storage = @import("storage/storage.zig");
const Client = @import("http/client.zig").Client;
const Renderer = @import("browser/browser.zig").Renderer;
pub const UserContext = struct {
http_client: *Client,
uri: std.Uri,
http_client: *Client,
document: *parser.DocumentHTML,
cookie_jar: *storage.CookieJar,
renderer: *Renderer,
};