From c5c5accaa82fa9d3eefe4b3cdc132f8e5f749102 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Mon, 5 May 2025 12:30:41 +0200 Subject: [PATCH 1/3] Native IntersectionObserver --- src/browser/browser.zig | 2 +- src/browser/dom/dom.zig | 2 + src/browser/dom/element.zig | 8 ++ src/browser/dom/intersection_observer.zig | 145 ++++++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/browser/dom/intersection_observer.zig diff --git a/src/browser/browser.zig b/src/browser/browser.zig index b9549e5b..5bee4ac2 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -829,7 +829,7 @@ const FlatRenderer = struct { } 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..bd6876df 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -346,6 +346,14 @@ 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) ![]DOMRect { + var heap = try state.arena.create(DOMRect); + heap.* = try state.renderer.getRect(self); + return heap[0..1]; + } + pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 { return state.renderer.width(); } diff --git a/src/browser/dom/intersection_observer.zig b/src/browser/dom/intersection_observer.zig new file mode 100644 index 00000000..2fa6bd63 --- /dev/null +++ b/src/browser/dom/intersection_observer.zig @@ -0,0 +1,145 @@ +// 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 { + if (options_ != null) return error.IntersectionObserverOptionsNotYetSupported; + const options = IntersectionObserverOptions{ + .root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)), + .rootMargin = "0px 0px 0px 0px", + .threshold = &[_]f32{0.0}, + }; + + 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, targetElement: *parser.Element) !void { + try self.observed_entries.append(self.state.arena, .{ + .state = self.state, + .target = targetElement, + .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); // Does this ever change? + } + + // 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 isIntersecting(_: *const IntersectionObserverEntry) bool { + return true; + } + + // Returns a DOMRectReadOnly for the intersection observer's root. + pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect { + // TODO self.options.root can be an Element or a Document when Options are supported + const element = (try parser.documentGetDocumentElement(parser.documentHTMLToDocument(self.state.document.?))).?; + 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; + } +}; From db28336e5dd13d03a48addd53b064eaa6cbf60e3 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Mon, 5 May 2025 16:45:21 +0200 Subject: [PATCH 2/3] Support options in observer and tests --- src/browser/dom/element.zig | 4 +- src/browser/dom/intersection_observer.zig | 124 ++++++++++++++++++++-- src/browser/netsurf.zig | 5 + 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig index bd6876df..cd808a19 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -506,7 +506,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" }, @@ -527,7 +527,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 index 2fa6bd63..3eed51ef 100644 --- a/src/browser/dom/intersection_observer.zig +++ b/src/browser/dom/intersection_observer.zig @@ -51,12 +51,16 @@ pub const IntersectionObserver = struct { // new IntersectionObserver(callback) // new IntersectionObserver(callback, options) [not supported yet] pub fn constructor(callback: Env.Callback, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver { - if (options_ != null) return error.IntersectionObserverOptionsNotYetSupported; - const options = IntersectionObserverOptions{ + var options = IntersectionObserverOptions{ .root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)), .rootMargin = "0px 0px 0px 0px", - .threshold = &[_]f32{0.0}, + .threshold = &default_threshold, }; + if (options_) |*o| { + if (o.root) |root| { + options.root = root; + } // Other properties are not used due to the way we render + } return .{ .callback = callback, @@ -103,6 +107,7 @@ const IntersectionObserverOptions = struct { rootMargin: ?[]const u8, threshold: ?[]const f32, }; +const default_threshold = [_]f32{0.0}; // https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry // https://w3c.github.io/IntersectionObserver/#intersection-observer-entry @@ -127,14 +132,25 @@ pub const IntersectionObserverEntry = struct { } // 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 isIntersecting(_: *const IntersectionObserverEntry) bool { + 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 { - // TODO self.options.root can be an Element or a Document when Options are supported - const element = (try parser.documentGetDocumentElement(parser.documentHTMLToDocument(self.state.document.?))).?; + const root = self.options.root.?; + 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); } @@ -142,4 +158,100 @@ pub const IntersectionObserverEntry = struct { 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. 1 + 2 = 3 + 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" }, + }, .{}); + + // 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;", "2" }, // This is not the prefered behaviour, the Window rect should wrap all elements so x -> 0 + .{ "entry.rootBounds.width;", "1" }, // width -> clientWidth + .{ "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;", "3" }, + }, .{}); +} 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; From e32d35b156b28f7ddad0603ca47dd44bc75981ab Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Tue, 6 May 2025 11:27:35 +0200 Subject: [PATCH 3/3] no reobserve rootbounds for Window --- src/browser/browser.zig | 9 ++++++ src/browser/dom/element.zig | 6 ++-- src/browser/dom/intersection_observer.zig | 39 +++++++++++++++++------ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/browser/browser.zig b/src/browser/browser.zig index 5bee4ac2..75592ba7 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -828,6 +828,15 @@ 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 + 1); // +1 since x starts at 1 (use len after append) } diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig index cd808a19..b349990e 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -348,10 +348,8 @@ pub const Element = struct { // 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) ![]DOMRect { - var heap = try state.arena.create(DOMRect); - heap.* = try state.renderer.getRect(self); - return heap[0..1]; + 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 { diff --git a/src/browser/dom/intersection_observer.zig b/src/browser/dom/intersection_observer.zig index 3eed51ef..fb2937a7 100644 --- a/src/browser/dom/intersection_observer.zig +++ b/src/browser/dom/intersection_observer.zig @@ -54,7 +54,7 @@ pub const IntersectionObserver = struct { var options = IntersectionObserverOptions{ .root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)), .rootMargin = "0px 0px 0px 0px", - .threshold = &default_threshold, + .threshold = &.{0.0}, }; if (options_) |*o| { if (o.root) |root| { @@ -74,10 +74,16 @@ pub const IntersectionObserver = struct { self.observed_entries = .{}; // We don't free as it is on an arena } - pub fn _observe(self: *IntersectionObserver, targetElement: *parser.Element) !void { + 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 = targetElement, + .target = target_element, .options = &self.options, }); @@ -107,7 +113,6 @@ const IntersectionObserverOptions = struct { rootMargin: ?[]const u8, threshold: ?[]const f32, }; -const default_threshold = [_]f32{0.0}; // https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry // https://w3c.github.io/IntersectionObserver/#intersection-observer-entry @@ -118,7 +123,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); // Does this ever change? + return self.state.renderer.getRect(self.target); } // Returns the ratio of the intersectionRect to the boundingClientRect. @@ -139,6 +144,10 @@ pub const IntersectionObserverEntry = struct { // 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; @@ -179,7 +188,7 @@ test "Browser.DOM.IntersectionObserver" { }, .{}); // This test is documenting current behavior, not correct behavior. - // Currently every time observe is called, the callback is called with all entries. 1 + 2 = 3 + // 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" }, @@ -191,6 +200,17 @@ test "Browser.DOM.IntersectionObserver" { .{ "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" }, @@ -234,8 +254,9 @@ test "Browser.DOM.IntersectionObserver" { .{ "entry.intersectionRect.width;", "1" }, .{ "entry.intersectionRect.height;", "1" }, .{ "entry.isIntersecting;", "true" }, - .{ "entry.rootBounds.x;", "2" }, // This is not the prefered behaviour, the Window rect should wrap all elements so x -> 0 - .{ "entry.rootBounds.width;", "1" }, // width -> clientWidth + .{ "entry.rootBounds.x;", "0" }, + .{ "entry.rootBounds.y;", "0" }, + .{ "entry.rootBounds.width;", "2" }, .{ "entry.rootBounds.height;", "1" }, .{ "entry.target;", "[object HTMLDivElement]" }, }, .{}); @@ -252,6 +273,6 @@ test "Browser.DOM.IntersectionObserver" { "undefined", }, .{ "new_observer.observe(document.createElement('div'));", "undefined" }, - .{ "new_entry.rootBounds.x;", "3" }, + .{ "new_entry.rootBounds.x;", "2" }, }, .{}); }