Skip to content

Commit 3095f21

Browse files
authored
Merge pull request #599 from lightpanda-io/NativeIntersectionObserver
Native IntersectionObserver
2 parents 7f2506d + e32d35b commit 3095f21

File tree

5 files changed

+303
-3
lines changed

5 files changed

+303
-3
lines changed

src/browser/browser.zig

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,8 +828,17 @@ const FlatRenderer = struct {
828828
};
829829
}
830830

831+
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
832+
return .{
833+
.x = 0.0,
834+
.y = 0.0,
835+
.width = @floatFromInt(self.width()),
836+
.height = @floatFromInt(self.height()),
837+
};
838+
}
839+
831840
pub fn width(self: *const FlatRenderer) u32 {
832-
return @intCast(self.elements.items.len);
841+
return @intCast(self.elements.items.len + 1); // +1 since x starts at 1 (use len after append)
833842
}
834843

835844
pub fn height(_: *const FlatRenderer) u32 {

src/browser/dom/dom.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const DOMTokenList = @import("token_list.zig");
2424
const NodeList = @import("nodelist.zig");
2525
const Node = @import("node.zig");
2626
const MutationObserver = @import("mutation_observer.zig");
27+
const IntersectionObserver = @import("intersection_observer.zig");
2728

2829
pub const Interfaces = .{
2930
DOMException,
@@ -35,4 +36,5 @@ pub const Interfaces = .{
3536
Node.Node,
3637
Node.Interfaces,
3738
MutationObserver.Interfaces,
39+
IntersectionObserver.Interfaces,
3840
};

src/browser/dom/element.zig

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ pub const Element = struct {
346346
return state.renderer.getRect(self);
347347
}
348348

349+
// returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
350+
// We do not render so just always return the element's rect.
351+
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![1]DOMRect {
352+
return [_]DOMRect{try state.renderer.getRect(self)};
353+
}
354+
349355
pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 {
350356
return state.renderer.width();
351357
}
@@ -498,7 +504,7 @@ test "Browser.DOM.Element" {
498504
}, .{});
499505

500506
try runner.testCases(&.{
501-
.{ "document.getElementById('para').clientWidth", "0" },
507+
.{ "document.getElementById('para').clientWidth", "1" },
502508
.{ "document.getElementById('para').clientHeight", "1" },
503509

504510
.{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
@@ -519,7 +525,7 @@ test "Browser.DOM.Element" {
519525
.{ "r3.width", "1" },
520526
.{ "r3.height", "1" },
521527

522-
.{ "document.getElementById('para').clientWidth", "2" },
528+
.{ "document.getElementById('para').clientWidth", "3" },
523529
.{ "document.getElementById('para').clientHeight", "1" },
524530
}, .{});
525531

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
2+
//
3+
// Francis Bouvier <[email protected]>
4+
// Pierre Tachoire <[email protected]>
5+
//
6+
// This program is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// This program is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Affero General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Affero General Public License
17+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
19+
const std = @import("std");
20+
const Allocator = std.mem.Allocator;
21+
22+
const parser = @import("../netsurf.zig");
23+
const SessionState = @import("../env.zig").SessionState;
24+
25+
const Env = @import("../env.zig").Env;
26+
const Element = @import("element.zig").Element;
27+
const Document = @import("document.zig").Document;
28+
29+
pub const Interfaces = .{
30+
IntersectionObserver,
31+
IntersectionObserverEntry,
32+
};
33+
34+
const log = std.log.scoped(.events);
35+
36+
// This is supposed to listen to change between the root and observation targets.
37+
// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes.
38+
// As such, there are no changes to intersections between the root and any target.
39+
// Instead we keep a list of all entries that are being observed.
40+
// The callback is called with all entries everytime a new entry is added(observed).
41+
// Potentially we should also call the callback at a regular interval.
42+
// The returned Entries are phony, they always indicate full intersection.
43+
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
44+
pub const IntersectionObserver = struct {
45+
callback: Env.Callback,
46+
options: IntersectionObserverOptions,
47+
state: *SessionState,
48+
49+
observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry),
50+
51+
// new IntersectionObserver(callback)
52+
// new IntersectionObserver(callback, options) [not supported yet]
53+
pub fn constructor(callback: Env.Callback, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver {
54+
var options = IntersectionObserverOptions{
55+
.root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)),
56+
.rootMargin = "0px 0px 0px 0px",
57+
.threshold = &.{0.0},
58+
};
59+
if (options_) |*o| {
60+
if (o.root) |root| {
61+
options.root = root;
62+
} // Other properties are not used due to the way we render
63+
}
64+
65+
return .{
66+
.callback = callback,
67+
.options = options,
68+
.state = state,
69+
.observed_entries = .{},
70+
};
71+
}
72+
73+
pub fn _disconnect(self: *IntersectionObserver) !void {
74+
self.observed_entries = .{}; // We don't free as it is on an arena
75+
}
76+
77+
pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void {
78+
for (self.observed_entries.items) |*observer| {
79+
if (observer.target == target_element) {
80+
return; // Already observed
81+
}
82+
}
83+
84+
try self.observed_entries.append(self.state.arena, .{
85+
.state = self.state,
86+
.target = target_element,
87+
.options = &self.options,
88+
});
89+
90+
var result: Env.Callback.Result = undefined;
91+
self.callback.tryCall(.{self.observed_entries.items}, &result) catch {
92+
log.err("intersection observer callback error: {s}", .{result.exception});
93+
log.debug("stack:\n{s}", .{result.stack orelse "???"});
94+
};
95+
}
96+
97+
pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void {
98+
for (self.observed_entries.items, 0..) |*observer, index| {
99+
if (observer.target == target) {
100+
_ = self.observed_entries.swapRemove(index);
101+
break;
102+
}
103+
}
104+
}
105+
106+
pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry {
107+
return self.observed_entries.items;
108+
}
109+
};
110+
111+
const IntersectionObserverOptions = struct {
112+
root: ?*parser.Node, // Element or Document
113+
rootMargin: ?[]const u8,
114+
threshold: ?[]const f32,
115+
};
116+
117+
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry
118+
// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry
119+
pub const IntersectionObserverEntry = struct {
120+
state: *SessionState,
121+
target: *parser.Element,
122+
options: *IntersectionObserverOptions,
123+
124+
// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
125+
pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
126+
return self.state.renderer.getRect(self.target);
127+
}
128+
129+
// Returns the ratio of the intersectionRect to the boundingClientRect.
130+
pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 {
131+
return 1.0;
132+
}
133+
134+
// Returns a DOMRectReadOnly representing the target's visible area.
135+
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
136+
return self.state.renderer.getRect(self.target);
137+
}
138+
139+
// 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.
140+
pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool {
141+
return true;
142+
}
143+
144+
// Returns a DOMRectReadOnly for the intersection observer's root.
145+
pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect {
146+
const root = self.options.root.?;
147+
if (@intFromPtr(root) == @intFromPtr(self.state.document.?)) {
148+
return self.state.renderer.boundingRect();
149+
}
150+
151+
const root_type = try parser.nodeType(root);
152+
153+
var element: *parser.Element = undefined;
154+
switch (root_type) {
155+
.element => element = parser.nodeToElement(root),
156+
.document => {
157+
const doc = parser.nodeToDocument(root);
158+
element = (try parser.documentGetDocumentElement(doc)).?;
159+
},
160+
else => return error.InvalidState,
161+
}
162+
163+
return try self.state.renderer.getRect(element);
164+
}
165+
166+
// The Element whose intersection with the root changed.
167+
pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element {
168+
return self.target;
169+
}
170+
171+
// TODO: pub fn get_time(self: *const IntersectionObserverEntry)
172+
};
173+
174+
const testing = @import("../../testing.zig");
175+
test "Browser.DOM.IntersectionObserver" {
176+
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
177+
defer runner.deinit();
178+
179+
try runner.testCases(&.{
180+
.{ "new IntersectionObserver(() => {}).observe(document.documentElement);", "undefined" },
181+
}, .{});
182+
183+
try runner.testCases(&.{
184+
.{ "let count_a = 0;", "undefined" },
185+
.{ "const a1 = document.createElement('div');", "undefined" },
186+
.{ "new IntersectionObserver(entries => {count_a += 1;}).observe(a1);", "undefined" },
187+
.{ "count_a;", "1" },
188+
}, .{});
189+
190+
// This test is documenting current behavior, not correct behavior.
191+
// Currently every time observe is called, the callback is called with all entries.
192+
try runner.testCases(&.{
193+
.{ "let count_b = 0;", "undefined" },
194+
.{ "let observer_b = new IntersectionObserver(entries => {count_b = entries.length;});", "undefined" },
195+
.{ "const b1 = document.createElement('div');", "undefined" },
196+
.{ "observer_b.observe(b1);", "undefined" },
197+
.{ "count_b;", "1" },
198+
.{ "const b2 = document.createElement('div');", "undefined" },
199+
.{ "observer_b.observe(b2);", "undefined" },
200+
.{ "count_b;", "2" },
201+
}, .{});
202+
203+
// Re-observing is a no-op
204+
try runner.testCases(&.{
205+
.{ "let count_bb = 0;", "undefined" },
206+
.{ "let observer_bb = new IntersectionObserver(entries => {count_bb = entries.length;});", "undefined" },
207+
.{ "const bb1 = document.createElement('div');", "undefined" },
208+
.{ "observer_bb.observe(bb1);", "undefined" },
209+
.{ "count_bb;", "1" },
210+
.{ "observer_bb.observe(bb1);", "undefined" },
211+
.{ "count_bb;", "1" }, // Still 1, not 2
212+
}, .{});
213+
214+
// Unobserve
215+
try runner.testCases(&.{
216+
.{ "let count_c = 0;", "undefined" },
217+
.{ "let observer_c = new IntersectionObserver(entries => { count_c = entries.length;});", "undefined" },
218+
.{ "const c1 = document.createElement('div');", "undefined" },
219+
.{ "observer_c.observe(c1);", "undefined" },
220+
.{ "count_c;", "1" },
221+
.{ "observer_c.unobserve(c1);", "undefined" },
222+
.{ "const c2 = document.createElement('div');", "undefined" },
223+
.{ "observer_c.observe(c2);", "undefined" },
224+
.{ "count_c;", "1" },
225+
}, .{});
226+
227+
// Disconnect
228+
try runner.testCases(&.{
229+
.{ "let observer_d = new IntersectionObserver(entries => {});", "undefined" },
230+
.{ "let d1 = document.createElement('div');", "undefined" },
231+
.{ "observer_d.observe(d1);", "undefined" },
232+
.{ "observer_d.disconnect();", "undefined" },
233+
.{ "observer_d.takeRecords().length;", "0" },
234+
}, .{});
235+
236+
// takeRecords
237+
try runner.testCases(&.{
238+
.{ "let observer_e = new IntersectionObserver(entries => {});", "undefined" },
239+
.{ "let e1 = document.createElement('div');", "undefined" },
240+
.{ "observer_e.observe(e1);", "undefined" },
241+
.{ "const e2 = document.createElement('div');", "undefined" },
242+
.{ "observer_e.observe(e2);", "undefined" },
243+
.{ "observer_e.takeRecords().length;", "2" },
244+
}, .{});
245+
246+
// Entry
247+
try runner.testCases(&.{
248+
.{ "let entry;", "undefined" },
249+
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(document.createElement('div'));", "undefined" },
250+
.{ "entry.boundingClientRect.x;", "1" },
251+
.{ "entry.intersectionRatio;", "1" },
252+
.{ "entry.intersectionRect.x;", "1" },
253+
.{ "entry.intersectionRect.y;", "0" },
254+
.{ "entry.intersectionRect.width;", "1" },
255+
.{ "entry.intersectionRect.height;", "1" },
256+
.{ "entry.isIntersecting;", "true" },
257+
.{ "entry.rootBounds.x;", "0" },
258+
.{ "entry.rootBounds.y;", "0" },
259+
.{ "entry.rootBounds.width;", "2" },
260+
.{ "entry.rootBounds.height;", "1" },
261+
.{ "entry.target;", "[object HTMLDivElement]" },
262+
}, .{});
263+
264+
// Options
265+
try runner.testCases(&.{
266+
.{ "const new_root = document.createElement('span');", "undefined" },
267+
.{ "let new_entry;", "undefined" },
268+
.{
269+
\\ const new_observer = new IntersectionObserver(
270+
\\ entries => { new_entry = entries[0]; },
271+
\\ {root: new_root, rootMargin: '0px 0px 0px 0px', threshold: [0]});
272+
,
273+
"undefined",
274+
},
275+
.{ "new_observer.observe(document.createElement('div'));", "undefined" },
276+
.{ "new_entry.rootBounds.x;", "2" },
277+
}, .{});
278+
}

src/browser/netsurf.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,11 @@ pub inline fn nodeToElement(node: *Node) *Element {
12781278
return @as(*Element, @ptrCast(node));
12791279
}
12801280

1281+
// nodeToDocument is an helper to convert a node to an document.
1282+
pub inline fn nodeToDocument(node: *Node) *Document {
1283+
return @as(*Document, @ptrCast(node));
1284+
}
1285+
12811286
// CharacterData
12821287
pub const CharacterData = c.dom_characterdata;
12831288

0 commit comments

Comments
 (0)