diff --git a/src/browser/browser.zig b/src/browser/browser.zig index b9549e5b..75592ba7 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -828,8 +828,17 @@ const FlatRenderer = struct { }; } + pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect { + return .{ + .x = 0.0, + .y = 0.0, + .width = @floatFromInt(self.width()), + .height = @floatFromInt(self.height()), + }; + } + pub fn width(self: *const FlatRenderer) u32 { - return @intCast(self.elements.items.len); + return @intCast(self.elements.items.len + 1); // +1 since x starts at 1 (use len after append) } pub fn height(_: *const FlatRenderer) u32 { diff --git a/src/browser/dom/dom.zig b/src/browser/dom/dom.zig index c0099a04..f2f3aef7 100644 --- a/src/browser/dom/dom.zig +++ b/src/browser/dom/dom.zig @@ -24,6 +24,7 @@ const DOMTokenList = @import("token_list.zig"); const NodeList = @import("nodelist.zig"); const Node = @import("node.zig"); const MutationObserver = @import("mutation_observer.zig"); +const IntersectionObserver = @import("intersection_observer.zig"); pub const Interfaces = .{ DOMException, @@ -35,4 +36,5 @@ pub const Interfaces = .{ Node.Node, Node.Interfaces, MutationObserver.Interfaces, + IntersectionObserver.Interfaces, }; diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig index aad066b6..b349990e 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -346,6 +346,12 @@ pub const Element = struct { 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)}; + } + pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 { return state.renderer.width(); } @@ -498,7 +504,7 @@ test "Browser.DOM.Element" { }, .{}); try runner.testCases(&.{ - .{ "document.getElementById('para').clientWidth", "0" }, + .{ "document.getElementById('para').clientWidth", "1" }, .{ "document.getElementById('para').clientHeight", "1" }, .{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" }, @@ -519,7 +525,7 @@ test "Browser.DOM.Element" { .{ "r3.width", "1" }, .{ "r3.height", "1" }, - .{ "document.getElementById('para').clientWidth", "2" }, + .{ "document.getElementById('para').clientWidth", "3" }, .{ "document.getElementById('para').clientHeight", "1" }, }, .{}); diff --git a/src/browser/dom/intersection_observer.zig b/src/browser/dom/intersection_observer.zig new file mode 100644 index 00000000..fb2937a7 --- /dev/null +++ b/src/browser/dom/intersection_observer.zig @@ -0,0 +1,278 @@ +// Copyright (C) 2023-2025 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 Allocator = std.mem.Allocator; + +const parser = @import("../netsurf.zig"); +const SessionState = @import("../env.zig").SessionState; + +const Env = @import("../env.zig").Env; +const Element = @import("element.zig").Element; +const Document = @import("document.zig").Document; + +pub const Interfaces = .{ + IntersectionObserver, + IntersectionObserverEntry, +}; + +const log = std.log.scoped(.events); + +// This is supposed to listen to change between the root and observation targets. +// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes. +// As such, there are no changes to intersections between the root and any target. +// Instead we keep a list of all entries that are being observed. +// The callback is called with all entries everytime a new entry is added(observed). +// Potentially we should also call the callback at a regular interval. +// The returned Entries are phony, they always indicate full intersection. +// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver +pub const IntersectionObserver = struct { + callback: Env.Callback, + options: IntersectionObserverOptions, + state: *SessionState, + + observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry), + + // new IntersectionObserver(callback) + // new IntersectionObserver(callback, options) [not supported yet] + pub fn constructor(callback: Env.Callback, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver { + var options = IntersectionObserverOptions{ + .root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)), + .rootMargin = "0px 0px 0px 0px", + .threshold = &.{0.0}, + }; + if (options_) |*o| { + if (o.root) |root| { + options.root = root; + } // Other properties are not used due to the way we render + } + + return .{ + .callback = callback, + .options = options, + .state = state, + .observed_entries = .{}, + }; + } + + pub fn _disconnect(self: *IntersectionObserver) !void { + self.observed_entries = .{}; // We don't free as it is on an arena + } + + pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void { + for (self.observed_entries.items) |*observer| { + if (observer.target == target_element) { + return; // Already observed + } + } + + try self.observed_entries.append(self.state.arena, .{ + .state = self.state, + .target = target_element, + .options = &self.options, + }); + + var result: Env.Callback.Result = undefined; + self.callback.tryCall(.{self.observed_entries.items}, &result) catch { + log.err("intersection observer callback error: {s}", .{result.exception}); + log.debug("stack:\n{s}", .{result.stack orelse "???"}); + }; + } + + pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void { + for (self.observed_entries.items, 0..) |*observer, index| { + if (observer.target == target) { + _ = self.observed_entries.swapRemove(index); + break; + } + } + } + + pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry { + return self.observed_entries.items; + } +}; + +const IntersectionObserverOptions = struct { + root: ?*parser.Node, // Element or Document + rootMargin: ?[]const u8, + threshold: ?[]const f32, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry +// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry +pub const IntersectionObserverEntry = struct { + state: *SessionState, + target: *parser.Element, + options: *IntersectionObserverOptions, + + // 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); + } + + // Returns the ratio of the intersectionRect to the boundingClientRect. + pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 { + return 1.0; + } + + // 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); + } + + // 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. + pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool { + return true; + } + + // Returns a DOMRectReadOnly for the intersection observer's root. + pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect { + const root = self.options.root.?; + if (@intFromPtr(root) == @intFromPtr(self.state.document.?)) { + return self.state.renderer.boundingRect(); + } + + const root_type = try parser.nodeType(root); + + var element: *parser.Element = undefined; + switch (root_type) { + .element => element = parser.nodeToElement(root), + .document => { + const doc = parser.nodeToDocument(root); + element = (try parser.documentGetDocumentElement(doc)).?; + }, + else => return error.InvalidState, + } + + return try self.state.renderer.getRect(element); + } + + // The Element whose intersection with the root changed. + pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element { + return self.target; + } + + // TODO: pub fn get_time(self: *const IntersectionObserverEntry) +}; + +const testing = @import("../../testing.zig"); +test "Browser.DOM.IntersectionObserver" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "new IntersectionObserver(() => {}).observe(document.documentElement);", "undefined" }, + }, .{}); + + try runner.testCases(&.{ + .{ "let count_a = 0;", "undefined" }, + .{ "const a1 = document.createElement('div');", "undefined" }, + .{ "new IntersectionObserver(entries => {count_a += 1;}).observe(a1);", "undefined" }, + .{ "count_a;", "1" }, + }, .{}); + + // This test is documenting current behavior, not correct behavior. + // Currently every time observe is called, the callback is called with all entries. + try runner.testCases(&.{ + .{ "let count_b = 0;", "undefined" }, + .{ "let observer_b = new IntersectionObserver(entries => {count_b = entries.length;});", "undefined" }, + .{ "const b1 = document.createElement('div');", "undefined" }, + .{ "observer_b.observe(b1);", "undefined" }, + .{ "count_b;", "1" }, + .{ "const b2 = document.createElement('div');", "undefined" }, + .{ "observer_b.observe(b2);", "undefined" }, + .{ "count_b;", "2" }, + }, .{}); + + // Re-observing is a no-op + try runner.testCases(&.{ + .{ "let count_bb = 0;", "undefined" }, + .{ "let observer_bb = new IntersectionObserver(entries => {count_bb = entries.length;});", "undefined" }, + .{ "const bb1 = document.createElement('div');", "undefined" }, + .{ "observer_bb.observe(bb1);", "undefined" }, + .{ "count_bb;", "1" }, + .{ "observer_bb.observe(bb1);", "undefined" }, + .{ "count_bb;", "1" }, // Still 1, not 2 + }, .{}); + + // Unobserve + try runner.testCases(&.{ + .{ "let count_c = 0;", "undefined" }, + .{ "let observer_c = new IntersectionObserver(entries => { count_c = entries.length;});", "undefined" }, + .{ "const c1 = document.createElement('div');", "undefined" }, + .{ "observer_c.observe(c1);", "undefined" }, + .{ "count_c;", "1" }, + .{ "observer_c.unobserve(c1);", "undefined" }, + .{ "const c2 = document.createElement('div');", "undefined" }, + .{ "observer_c.observe(c2);", "undefined" }, + .{ "count_c;", "1" }, + }, .{}); + + // Disconnect + try runner.testCases(&.{ + .{ "let observer_d = new IntersectionObserver(entries => {});", "undefined" }, + .{ "let d1 = document.createElement('div');", "undefined" }, + .{ "observer_d.observe(d1);", "undefined" }, + .{ "observer_d.disconnect();", "undefined" }, + .{ "observer_d.takeRecords().length;", "0" }, + }, .{}); + + // takeRecords + try runner.testCases(&.{ + .{ "let observer_e = new IntersectionObserver(entries => {});", "undefined" }, + .{ "let e1 = document.createElement('div');", "undefined" }, + .{ "observer_e.observe(e1);", "undefined" }, + .{ "const e2 = document.createElement('div');", "undefined" }, + .{ "observer_e.observe(e2);", "undefined" }, + .{ "observer_e.takeRecords().length;", "2" }, + }, .{}); + + // Entry + try runner.testCases(&.{ + .{ "let entry;", "undefined" }, + .{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(document.createElement('div'));", "undefined" }, + .{ "entry.boundingClientRect.x;", "1" }, + .{ "entry.intersectionRatio;", "1" }, + .{ "entry.intersectionRect.x;", "1" }, + .{ "entry.intersectionRect.y;", "0" }, + .{ "entry.intersectionRect.width;", "1" }, + .{ "entry.intersectionRect.height;", "1" }, + .{ "entry.isIntersecting;", "true" }, + .{ "entry.rootBounds.x;", "0" }, + .{ "entry.rootBounds.y;", "0" }, + .{ "entry.rootBounds.width;", "2" }, + .{ "entry.rootBounds.height;", "1" }, + .{ "entry.target;", "[object HTMLDivElement]" }, + }, .{}); + + // Options + try runner.testCases(&.{ + .{ "const new_root = document.createElement('span');", "undefined" }, + .{ "let new_entry;", "undefined" }, + .{ + \\ const new_observer = new IntersectionObserver( + \\ entries => { new_entry = entries[0]; }, + \\ {root: new_root, rootMargin: '0px 0px 0px 0px', threshold: [0]}); + , + "undefined", + }, + .{ "new_observer.observe(document.createElement('div'));", "undefined" }, + .{ "new_entry.rootBounds.x;", "2" }, + }, .{}); +} diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index b5204f25..5a48b6f5 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -1278,6 +1278,11 @@ pub inline fn nodeToElement(node: *Node) *Element { return @as(*Element, @ptrCast(node)); } +// nodeToDocument is an helper to convert a node to an document. +pub inline fn nodeToDocument(node: *Node) *Document { + return @as(*Document, @ptrCast(node)); +} + // CharacterData pub const CharacterData = c.dom_characterdata;