diff --git a/src/browser/browser.zig b/src/browser/browser.zig index 1d549bcc..7bdeff66 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -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 {} diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 253442bb..15a81c76 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -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].*))) { diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig new file mode 100644 index 00000000..44c39eb5 --- /dev/null +++ b/src/cdp/domains/input.zig @@ -0,0 +1,80 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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); +} diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index d5e17929..b74b8878 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -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, diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index a8b0e7d9..296e33eb 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -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 { diff --git a/src/dom/element.zig b/src/dom/element.zig index 030630da..0453a283 100644 --- a/src/dom/element.zig +++ b/src/dom/element.zig @@ -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 = "

And

" }, }; + + 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); } diff --git a/src/main_tests.zig b/src/main_tests.zig index 65bb4e3f..85b584c2 100644 --- a/src/main_tests.zig +++ b/src/main_tests.zig @@ -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, }); diff --git a/src/netsurf/netsurf.zig b/src/netsurf/netsurf.zig index d57b4619..2471b2b2 100644 --- a/src/netsurf/netsurf.zig +++ b/src/netsurf/netsurf.zig @@ -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)) { diff --git a/src/user_context.zig b/src/user_context.zig index e71b29f8..d9708932 100644 --- a/src/user_context.zig +++ b/src/user_context.zig @@ -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, }; diff --git a/vendor/zig-js-runtime b/vendor/zig-js-runtime index 64b9b2b0..304a12e3 160000 --- a/vendor/zig-js-runtime +++ b/vendor/zig-js-runtime @@ -1 +1 @@ -Subproject commit 64b9b2b0c9e7a46a71bd5c1baff513a6916e7363 +Subproject commit 304a12e3174f73fc92ff20b90baca4786f233942