diff --git a/src/browser/browser.zig b/src/browser/browser.zig index 1d549bccb..743aaf6cf 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,53 @@ pub const Page = struct { } } + pub const ClickResult = union(enum) { + navigate: std.Uri, + }; + + pub const MouseEvent = struct { + x: i32, + y: i32, + type: Type, + + const Type = enum { + pressed, + released, + }; + }; + + pub fn mouseEvent(self: *Page, allocator: Allocator, me: MouseEvent) !?ClickResult { + if (me.type != .pressed) { + return null; + } + + const element = self.renderer.getElementAtPosition(me.x, me.y) orelse return null; + + const event = try parser.mouseEventCreate(); + defer parser.mouseEventDestroy(event); + try parser.mouseEventInit(event, "click", .{ + .bubbles = true, + .cancelable = true, + .x = me.x, + .y = me.y, + }); + _ = try parser.elementDispatchEvent(element, @ptrCast(event)); + + if ((try parser.mouseEventDefaultPrevented(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 +513,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 +805,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: i32, y: i32) ?*parser.Element { + if (y != 1 or x < 0) { + return null; + } + + const elements = self.elements.items; + return if (x < elements.len) @ptrFromInt(elements[@intCast(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 253442bb1..45d4c63ce 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].*))) { @@ -281,8 +284,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { // we should reject it. session_id: ?[]const u8, - // State - url: []const u8, loader_id: []const u8, security_origin: []const u8, page_life_cycle_events: bool, @@ -303,7 +304,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { .cdp = cdp, .target_id = null, .session_id = null, - .url = URL_BASE, .security_origin = URL_BASE, .secure_context_type = "Secure", // TODO = enum .loader_id = LOADER_ID, @@ -333,6 +333,11 @@ pub fn BrowserContext(comptime CDP_T: type) type { }; } + pub fn getURL(self: *const Self) ?[]const u8 { + const page = self.session.currentPage() orelse return null; + return page.rawuri; + } + pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { // msg should be {"id":,... diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig new file mode 100644 index 000000000..196ed1920 --- /dev/null +++ b/src/cdp/domains/input.zig @@ -0,0 +1,100 @@ +// 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"); +const Page = @import("../../browser/browser.zig").Page; + +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 { + x: i32, + y: i32, + type: Type, + + const Type = enum { + mousePressed, + mouseReleased, + mouseMoved, + mouseWheel, + }; + })) orelse return error.InvalidParams; + + try cmd.sendResult(null, .{}); + + // quickly ignore types we know we don't handle + switch (params.type) { + .mouseMoved, .mouseWheel => return, + else => {}, + } + + const bc = cmd.browser_context orelse return; + const page = bc.session.currentPage() orelse return; + + const mouse_event = Page.MouseEvent{ + .x = params.x, + .y = params.y, + .type = switch (params.type) { + .mousePressed => .pressed, + .mouseReleased => .released, + else => unreachable, + }, + }; + const click_result = (try page.mouseEvent(cmd.arena, mouse_event)) 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 d5e179297..02134cd27 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -61,10 +61,10 @@ fn getFrameTree(cmd: anytype) !void { return cmd.sendResult(.{ .frameTree = .{ .frame = Frame{ - .url = bc.url, .id = target_id, .loaderId = bc.loader_id, .securityOrigin = bc.security_origin, + .url = bc.getURL() orelse "about:blank", .secureContextType = bc.secure_context_type, }, }, @@ -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,9 @@ 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.loader_id = cmd.cdp.loader_id_gen.next(); const LifecycleEvent = struct { frameId: []const u8, @@ -180,10 +181,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 +202,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 +221,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, @@ -281,7 +284,7 @@ test "cdp.page: getFrameTree" { .frame = .{ .id = "TID-3", .loaderId = bc.loader_id, - .url = bc.url, + .url = "about:blank", .domainAndRegistry = "", .securityOrigin = bc.security_origin, .mimeType = "text/html", diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index e94284c99..6c4fd4421 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -132,7 +132,6 @@ fn createTarget(cmd: anytype) !void { _ = try bc.session.createPage(aux_data); // change CDP state - bc.url = "about:blank"; bc.security_origin = "://"; bc.secure_context_type = "InsecureScheme"; bc.loader_id = LOADER_ID; @@ -142,11 +141,11 @@ fn createTarget(cmd: anytype) !void { // has been enabled? try cmd.sendEvent("Target.targetCreated", .{ .targetInfo = TargetInfo{ - .url = bc.url, + .attached = false, .targetId = target_id, .title = "about:blank", .browserContextId = bc.id, - .attached = false, + .url = "about:blank", }, }, .{}); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index a8b0e7d94..c9ce2ebb3 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -85,6 +85,7 @@ const Session = struct { return error.MockBrowserPageAlreadyExists; } self.page = .{ + .rawuri = "", .session = self, .aux_data = try self.arena.dupe(u8, aux_data orelse ""), }; @@ -103,14 +104,20 @@ const Session = struct { const Page = struct { session: *Session, + rawuri: []const u8, 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 MouseEvent = @import("../browser/browser.zig").Page.MouseEvent; + const ClickResult = @import("../browser/browser.zig").Page.ClickResult; + pub fn mouseEvent(_: *Page, _: Allocator, _: MouseEvent) !?ClickResult { + return null; + } }; const Client = struct { diff --git a/src/dom/element.zig b/src/dom/element.zig index 030630daf..0453a283a 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 65bb4e3fd..85b584c29 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 d57b46193..92c0bbea3 100644 --- a/src/netsurf/netsurf.zig +++ b/src/netsurf/netsurf.zig @@ -24,6 +24,7 @@ const c = @cImport({ @cInclude("dom/bindings/hubbub/parser.h"); @cInclude("events/event_target.h"); @cInclude("events/event.h"); + @cInclude("events/mouse_event.h"); }); const mimalloc = @import("mimalloc"); @@ -801,6 +802,11 @@ pub fn eventTargetDispatchEvent(et: *EventTarget, event: *Event) !bool { return res; } +pub fn elementDispatchEvent(element: *Element, event: *Event) !bool { + const et: *EventTarget = toEventTarget(Element, element); + return eventTargetDispatchEvent(et, @ptrCast(event)); +} + pub fn eventTargetTBaseFieldName(comptime T: type) ?[]const u8 { std.debug.assert(@inComptime()); switch (@typeInfo(T)) { @@ -860,6 +866,61 @@ pub const EventTargetTBase = extern struct { } }; +// MouseEvent + +pub const MouseEvent = c.dom_mouse_event; + +pub fn mouseEventCreate() !*MouseEvent { + var evt: ?*MouseEvent = undefined; + const err = c._dom_mouse_event_create(&evt); + try DOMErr(err); + return evt.?; +} + +pub fn mouseEventDestroy(evt: *MouseEvent) void { + c._dom_mouse_event_destroy(evt); +} + +const MouseEventOpts = struct { + x: i32, + y: i32, + bubbles: bool = false, + cancelable: bool = false, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + meta: bool = false, + button: u16 = 0, + click_count: u16 = 1, +}; + +pub fn mouseEventInit(evt: *MouseEvent, typ: []const u8, opts: MouseEventOpts) !void { + const s = try strFromData(typ); + const err = c._dom_mouse_event_init( + evt, + s, + opts.bubbles, + opts.cancelable, + null, // dom_abstract_view* ? + opts.click_count, // details + opts.x, // screen_x + opts.y, // screen_y + opts.x, // client_x + opts.y, // client_y + opts.ctrl, + opts.alt, + opts.shift, + opts.meta, + opts.button, + null, // related target + ); + try DOMErr(err); +} + +pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool { + return eventDefaultPrevented(@ptrCast(evt)); +} + // NodeType pub const NodeType = enum(u4) { diff --git a/src/user_context.zig b/src/user_context.zig index e71b29f88..d97089329 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 64b9b2b0c..6b48960a0 160000 --- a/vendor/zig-js-runtime +++ b/vendor/zig-js-runtime @@ -1 +1 @@ -Subproject commit 64b9b2b0c9e7a46a71bd5c1baff513a6916e7363 +Subproject commit 6b48960a0664b74016c777da5e3f9129e5f041f7