-
Notifications
You must be signed in to change notification settings - Fork 286
Native IntersectionObserver #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| // Copyright (C) 2023-2025 Lightpanda (Selecy SAS) | ||
| // | ||
| // Francis Bouvier <francis@lightpanda.io> | ||
| // Pierre Tachoire <pierre@lightpanda.io> | ||
| // | ||
| // 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 <https://www.gnu.org/licenses/>. | ||
|
|
||
| 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, .{ | ||
sjorsdonkers marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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" }, | ||
| }, .{}); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.