|
| 1 | +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) |
| 2 | +// |
| 3 | +// Francis Bouvier <francis@lightpanda.io> |
| 4 | +// Pierre Tachoire <pierre@lightpanda.io> |
| 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 | + |
| 21 | +const js = @import("../js/js.zig"); |
| 22 | +const log = @import("../../log.zig"); |
| 23 | +const parser = @import("../netsurf.zig"); |
| 24 | +const Page = @import("../page.zig").Page; |
| 25 | +const Node = @import("node.zig").Node; |
| 26 | +const Element = @import("element.zig").Element; |
| 27 | + |
| 28 | +pub const Interfaces = .{ |
| 29 | + IntersectionObserver, |
| 30 | + Entry, |
| 31 | +}; |
| 32 | + |
| 33 | +// This implementation attempts to be as less wrong as possible. Since we don't |
| 34 | +// render, or know how things are positioned, our best guess isn't very good. |
| 35 | +const IntersectionObserver = @This(); |
| 36 | +page: *Page, |
| 37 | +root: *parser.Node, |
| 38 | +callback: js.Function, |
| 39 | +event_node: parser.EventNode, |
| 40 | +observed_entries: std.ArrayList(Entry), |
| 41 | +pending_elements: std.ArrayList(*parser.Element), |
| 42 | +ready_elements: std.ArrayList(*parser.Element), |
| 43 | + |
| 44 | +pub fn constructor(callback: js.Function, opts_: ?IntersectionObserverOptions, page: *Page) !*IntersectionObserver { |
| 45 | + const opts = opts_ orelse IntersectionObserverOptions{}; |
| 46 | + |
| 47 | + const self = try page.arena.create(IntersectionObserver); |
| 48 | + self.* = .{ |
| 49 | + .page = page, |
| 50 | + .callback = callback, |
| 51 | + .ready_elements = .{}, |
| 52 | + .observed_entries = .{}, |
| 53 | + .pending_elements = .{}, |
| 54 | + .event_node = .{ .func = mutationCallback }, |
| 55 | + .root = opts.root orelse parser.documentToNode(parser.documentHTMLToDocument(page.window.document)), |
| 56 | + }; |
| 57 | + |
| 58 | + _ = try parser.eventTargetAddEventListener( |
| 59 | + parser.toEventTarget(parser.Node, self.root), |
| 60 | + "DOMNodeInserted", |
| 61 | + &self.event_node, |
| 62 | + false, |
| 63 | + ); |
| 64 | + |
| 65 | + _ = try parser.eventTargetAddEventListener( |
| 66 | + parser.toEventTarget(parser.Node, self.root), |
| 67 | + "DOMNodeRemoved", |
| 68 | + &self.event_node, |
| 69 | + false, |
| 70 | + ); |
| 71 | + |
| 72 | + return self; |
| 73 | +} |
| 74 | + |
| 75 | +pub fn _disconnect(self: *IntersectionObserver) !void { |
| 76 | + // We don't free as it is on an arena |
| 77 | + self.ready_elements = .{}; |
| 78 | + self.observed_entries = .{}; |
| 79 | + self.pending_elements = .{}; |
| 80 | +} |
| 81 | + |
| 82 | +pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element, page: *Page) !void { |
| 83 | + for (self.observed_entries.items) |*observer| { |
| 84 | + if (observer.target == target_element) { |
| 85 | + return; // Already observed |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + if (self.isPending(target_element)) { |
| 90 | + return; // Already pending |
| 91 | + } |
| 92 | + |
| 93 | + for (self.ready_elements.items) |element| { |
| 94 | + if (element == target_element) { |
| 95 | + return; // Already primed |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + // We can never fire callbacks synchronously. Code like React expects any |
| 100 | + // callback to fire in the future (e.g. via microtasks). |
| 101 | + try self.ready_elements.append(self.page.arena, target_element); |
| 102 | + if (self.ready_elements.items.len == 1) { |
| 103 | + // this is our first ready entry, schedule a callback |
| 104 | + try page.scheduler.add(self, processReady, 0, .{ |
| 105 | + .name = "intersection ready", |
| 106 | + }); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void { |
| 111 | + if (self.removeObserved(target)) { |
| 112 | + return; |
| 113 | + } |
| 114 | + |
| 115 | + for (self.ready_elements.items, 0..) |el, index| { |
| 116 | + if (el == target) { |
| 117 | + _ = self.ready_elements.swapRemove(index); |
| 118 | + return; |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + for (self.pending_elements.items, 0..) |el, index| { |
| 123 | + if (el == target) { |
| 124 | + _ = self.pending_elements.swapRemove(index); |
| 125 | + return; |
| 126 | + } |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +pub fn _takeRecords(self: *IntersectionObserver) []Entry { |
| 131 | + return self.observed_entries.items; |
| 132 | +} |
| 133 | + |
| 134 | +fn processReady(ctx: *anyopaque) ?u32 { |
| 135 | + const self: *IntersectionObserver = @ptrCast(@alignCast(ctx)); |
| 136 | + self._processReady() catch |err| { |
| 137 | + log.err(.web_api, "intersection ready", .{ .err = err }); |
| 138 | + }; |
| 139 | + return null; |
| 140 | +} |
| 141 | + |
| 142 | +fn _processReady(self: *IntersectionObserver) !void { |
| 143 | + defer self.ready_elements.clearRetainingCapacity(); |
| 144 | + for (self.ready_elements.items) |element| { |
| 145 | + // IntersectionObserver probably doesn't work like what your intuition |
| 146 | + // thinks. As long as a node has a parent, even if that parent isn't |
| 147 | + // connected and even if the two nodes don't intersect, it'll fire the |
| 148 | + // callback once. |
| 149 | + if (try Node.get_parentNode(@ptrCast(element)) == null) { |
| 150 | + if (!self.isPending(element)) { |
| 151 | + try self.pending_elements.append(self.page.arena, element); |
| 152 | + } |
| 153 | + continue; |
| 154 | + } |
| 155 | + try self.forceObserve(element); |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +fn isPending(self: *IntersectionObserver, element: *parser.Element) bool { |
| 160 | + for (self.pending_elements.items) |el| { |
| 161 | + if (el == element) { |
| 162 | + return true; |
| 163 | + } |
| 164 | + } |
| 165 | + return false; |
| 166 | +} |
| 167 | + |
| 168 | +fn mutationCallback(en: *parser.EventNode, event: *parser.Event) void { |
| 169 | + const mutation_event = parser.eventToMutationEvent(event); |
| 170 | + const self: *IntersectionObserver = @fieldParentPtr("event_node", en); |
| 171 | + self._mutationCallback(mutation_event) catch |err| { |
| 172 | + log.err(.web_api, "mutation callback", .{ .err = err, .source = "intersection observer" }); |
| 173 | + }; |
| 174 | +} |
| 175 | + |
| 176 | +fn _mutationCallback(self: *IntersectionObserver, event: *parser.MutationEvent) !void { |
| 177 | + const event_type = parser.eventType(@ptrCast(event)); |
| 178 | + |
| 179 | + if (std.mem.eql(u8, event_type, "DOMNodeInserted")) { |
| 180 | + const node = parser.mutationEventRelatedNode(event) catch return orelse return; |
| 181 | + if (parser.nodeType(node) != .element) { |
| 182 | + return; |
| 183 | + } |
| 184 | + const el: *parser.Element = @ptrCast(node); |
| 185 | + if (self.removePending(el)) { |
| 186 | + // It was pending (because it wasn't in the root), but now it is |
| 187 | + // we should observe it. |
| 188 | + try self.forceObserve(el); |
| 189 | + } |
| 190 | + return; |
| 191 | + } |
| 192 | + |
| 193 | + if (std.mem.eql(u8, event_type, "DOMNodeRemoved")) { |
| 194 | + const node = parser.mutationEventRelatedNode(event) catch return orelse return; |
| 195 | + if (parser.nodeType(node) != .element) { |
| 196 | + return; |
| 197 | + } |
| 198 | + |
| 199 | + const el: *parser.Element = @ptrCast(node); |
| 200 | + if (self.removeObserved(el)) { |
| 201 | + // It _was_ observed, it no longer is in our root, but if it was |
| 202 | + // to get re-added, it should be observed again (I think), so |
| 203 | + // we add it to our pending list |
| 204 | + try self.pending_elements.append(self.page.arena, el); |
| 205 | + } |
| 206 | + |
| 207 | + return; |
| 208 | + } |
| 209 | + |
| 210 | + // impossible event type |
| 211 | + unreachable; |
| 212 | +} |
| 213 | + |
| 214 | +// Exists to skip the checks made _observe when called from a DOMNodeInserted |
| 215 | +// event. In such events, the event handler has alread done the necessary |
| 216 | +// checks. |
| 217 | +fn forceObserve(self: *IntersectionObserver, target: *parser.Element) !void { |
| 218 | + try self.observed_entries.append(self.page.arena, .{ |
| 219 | + .page = self.page, |
| 220 | + .root = self.root, |
| 221 | + .target = target, |
| 222 | + }); |
| 223 | + |
| 224 | + var result: js.Function.Result = undefined; |
| 225 | + self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch { |
| 226 | + log.debug(.user_script, "callback error", .{ |
| 227 | + .err = result.exception, |
| 228 | + .stack = result.stack, |
| 229 | + .source = "intersection observer", |
| 230 | + }); |
| 231 | + }; |
| 232 | +} |
| 233 | + |
| 234 | +fn removeObserved(self: *IntersectionObserver, target: *parser.Element) bool { |
| 235 | + for (self.observed_entries.items, 0..) |*observer, index| { |
| 236 | + if (observer.target == target) { |
| 237 | + _ = self.observed_entries.swapRemove(index); |
| 238 | + return true; |
| 239 | + } |
| 240 | + } |
| 241 | + return false; |
| 242 | +} |
| 243 | + |
| 244 | +fn removePending(self: *IntersectionObserver, target: *parser.Element) bool { |
| 245 | + for (self.pending_elements.items, 0..) |el, index| { |
| 246 | + if (el == target) { |
| 247 | + _ = self.pending_elements.swapRemove(index); |
| 248 | + return true; |
| 249 | + } |
| 250 | + } |
| 251 | + return false; |
| 252 | +} |
| 253 | + |
| 254 | +const IntersectionObserverOptions = struct { |
| 255 | + root: ?*parser.Node = null, // Element or Document |
| 256 | + rootMargin: ?[]const u8 = "0px 0px 0px 0px", |
| 257 | + threshold: ?Threshold = .{ .single = 0.0 }, |
| 258 | + |
| 259 | + const Threshold = union(enum) { |
| 260 | + single: f32, |
| 261 | + list: []const f32, |
| 262 | + }; |
| 263 | +}; |
| 264 | + |
| 265 | +// https://developer.mozilla.org/en-US/docs/Web/API/Entry |
| 266 | +// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry |
| 267 | +pub const Entry = struct { |
| 268 | + page: *Page, |
| 269 | + root: *parser.Node, |
| 270 | + target: *parser.Element, |
| 271 | + |
| 272 | + // Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect(). |
| 273 | + pub fn get_boundingClientRect(self: *const Entry) !Element.DOMRect { |
| 274 | + return Element._getBoundingClientRect(self.target, self.page); |
| 275 | + } |
| 276 | + |
| 277 | + // Returns the ratio of the intersectionRect to the boundingClientRect. |
| 278 | + pub fn get_intersectionRatio(_: *const Entry) f32 { |
| 279 | + return 1.0; |
| 280 | + } |
| 281 | + |
| 282 | + // Returns a DOMRectReadOnly representing the target's visible area. |
| 283 | + pub fn get_intersectionRect(self: *const Entry) !Element.DOMRect { |
| 284 | + return Element._getBoundingClientRect(self.target, self.page); |
| 285 | + } |
| 286 | + |
| 287 | + // A Boolean value which is true if the target element intersects with the |
| 288 | + // intersection observer's root. If this is true, then, the |
| 289 | + // Entry describes a transition into a state of |
| 290 | + // intersection; if it's false, then you know the transition is from |
| 291 | + // intersecting to not-intersecting. |
| 292 | + pub fn get_isIntersecting(_: *const Entry) bool { |
| 293 | + return true; |
| 294 | + } |
| 295 | + |
| 296 | + // Returns a DOMRectReadOnly for the intersection observer's root. |
| 297 | + pub fn get_rootBounds(self: *const Entry) !Element.DOMRect { |
| 298 | + const root = self.root; |
| 299 | + if (@intFromPtr(root) == @intFromPtr(self.page.window.document)) { |
| 300 | + return self.page.renderer.boundingRect(); |
| 301 | + } |
| 302 | + |
| 303 | + const root_type = parser.nodeType(root); |
| 304 | + |
| 305 | + var element: *parser.Element = undefined; |
| 306 | + switch (root_type) { |
| 307 | + .element => element = parser.nodeToElement(root), |
| 308 | + .document => { |
| 309 | + const doc = parser.nodeToDocument(root); |
| 310 | + element = (try parser.documentGetDocumentElement(doc)).?; |
| 311 | + }, |
| 312 | + else => return error.InvalidState, |
| 313 | + } |
| 314 | + |
| 315 | + return Element._getBoundingClientRect(element, self.page); |
| 316 | + } |
| 317 | + |
| 318 | + // The Element whose intersection with the root changed. |
| 319 | + pub fn get_target(self: *const Entry) *parser.Element { |
| 320 | + return self.target; |
| 321 | + } |
| 322 | + |
| 323 | + // TODO: pub fn get_time(self: *const Entry) |
| 324 | +}; |
| 325 | + |
| 326 | +const testing = @import("../../testing.zig"); |
| 327 | +test "Browser: DOM.IntersectionObserver" { |
| 328 | + try testing.htmlRunner("dom/intersection_observer.html"); |
| 329 | +} |
0 commit comments