diff --git a/build.zig.zon b/build.zig.zon index 9d57095f..036d9ba5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,9 +5,9 @@ .fingerprint = 0xda130f3af836cea0, .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/305bb3706716d32d59b2ffa674731556caa1002b.tar.gz", - .hash = "v8-0.0.0-xddH63bVAwBSEobaUok9J0er1FqsvEujCDDVy6ItqKQ5", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/3aa2b39cb1ab588b85970beef5b374effccf1415.tar.gz", + .hash = "v8-0.0.0-xddH66TeAwDDEs3QkHFlukxqqrRXITzzmmIn2NHISHCn", }, - //.v8 = .{ .path = "../zig-v8-fork" } + // .v8 = .{ .path = "../zig-v8-fork" } }, } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 6555a9d6..ff22f2b6 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -37,6 +37,7 @@ const Scheduler = @import("Scheduler.zig"); const History = @import("webapi/History.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); + const polyfill = @import("polyfill/polyfill.zig"); const Parser = @import("parser/Parser.zig"); @@ -50,6 +51,8 @@ const Window = @import("webapi/Window.zig"); const Location = @import("webapi/Location.zig"); const Document = @import("webapi/Document.zig"); const HtmlScript = @import("webapi/Element.zig").Html.Script; +const MutationObserver = @import("webapi/MutationObserver.zig"); +const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const storage = @import("webapi/storage/storage.zig"); const timestamp = @import("../datetime.zig").timestamp; @@ -89,6 +92,14 @@ _element_class_lists: Element.ClassListLookup = .{}, _script_manager: ScriptManager, +// List of active MutationObservers +_mutation_observers: std.ArrayList(*MutationObserver) = .{}, +_mutation_delivery_scheduled: bool = false, + +// List of active IntersectionObservers +_intersection_observers: std.ArrayList(*IntersectionObserver) = .{}, +_intersection_delivery_scheduled: bool = false, + _polyfill_loader: polyfill.Loader = .{}, // for heap allocations and managing WebAPI objects @@ -190,6 +201,11 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._notified_network_idle = .init; self._notified_network_almost_idle = .init; + self._mutation_observers = .{}; + self._mutation_delivery_scheduled = false; + self._intersection_observers = .{}; + self._intersection_delivery_scheduled = false; + try polyfill.preload(self.arena, self.js); try self.registerBackgroundTasks(); } @@ -677,6 +693,94 @@ pub fn domChanged(self: *Page) void { self.version += 1; } +pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { + try self._mutation_observers.append(self.arena, observer); +} + +pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void { + for (self._mutation_observers.items, 0..) |obs, i| { + if (obs == observer) { + _ = self._mutation_observers.swapRemove(i); + return; + } + } +} + +pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void { + try self._intersection_observers.append(self.arena, observer); +} + +pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void { + for (self._intersection_observers.items, 0..) |obs, i| { + if (obs == observer) { + _ = self._intersection_observers.swapRemove(i); + return; + } + } +} + +pub fn checkIntersections(self: *Page) !void { + for (self._intersection_observers.items) |observer| { + try observer.checkIntersections(self); + } +} + +pub fn scheduleMutationDelivery(self: *Page) !void { + // Only queue if not already scheduled + if (self._mutation_delivery_scheduled) { + return; + } + self._mutation_delivery_scheduled = true; + + // Queue mutation delivery as a microtask + try self.js.queueMutationDelivery(); +} + +pub fn scheduleIntersectionDelivery(self: *Page) !void { + // Only queue if not already scheduled + if (self._intersection_delivery_scheduled) { + return; + } + self._intersection_delivery_scheduled = true; + + // Queue intersection delivery as a microtask + try self.js.queueIntersectionDelivery(); +} + +pub fn deliverIntersections(self: *Page) void { + if (!self._intersection_delivery_scheduled) { + return; + } + self._intersection_delivery_scheduled = false; + + // Iterate backwards to handle observers that disconnect during their callback + var i = self._intersection_observers.items.len; + while (i > 0) { + i -= 1; + const observer = self._intersection_observers.items[i]; + observer.deliverEntries(self) catch |err| { + log.err(.page, "page.deliverIntersections", .{ .err = err }); + }; + } +} + +pub fn deliverMutations(self: *Page) void { + if (!self._mutation_delivery_scheduled) { + return; + } + self._mutation_delivery_scheduled = false; + + // Iterate backwards to handle observers that disconnect during their callback + var i = self._mutation_observers.items.len; + while (i > 0) { + i -= 1; + const observer = self._mutation_observers.items[i]; + observer.deliverRecords(self) catch |err| { + log.err(.page, "page.deliverMutations", .{ .err = err }); + }; + } +} + fn notifyNetworkIdle(self: *Page) void { std.debug.assert(self._notified_network_idle == .done); self._session.browser.notification.dispatch(.page_network_idle, &.{ @@ -1106,6 +1210,10 @@ const RemoveNodeOpts = struct { will_be_reconnected: bool, }; pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts) void { + // Capture siblings before removing + const previous_sibling = child.previousSibling(); + const next_sibling = child.nextSibling(); + const children = parent._children.?; switch (children.*) { .one => |n| { @@ -1132,6 +1240,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts child._parent = null; child._child_link = .{}; + if (self.hasMutationObservers()) { + const removed = [_]*Node{child}; + self.childListChange(parent, &.{}, &removed, previous_sibling, next_sibling); + } + if (opts.will_be_reconnected) { // We might be removing the node only to re-insert it. If the node will // remain connected, we can skip the expensive process of fully @@ -1232,10 +1345,30 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } child._parent = parent; - if (comptime from_parser == false) { - // When the parser adds the node, nodeIsReady is only called when the - // nodeComplete() callback is executed. - try self.nodeIsReady(false, child); + // Tri-state behavior for mutations: + // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse) + // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions) + // 3. from_parser=false, parse_mode=document -> mutation (js manipulation) + // split like this because from_parser can be comptime known. + const should_notify = if (comptime from_parser) + self._parse_mode == .fragment + else + true; + + if (should_notify) { + if (comptime from_parser == false) { + // When the parser adds the node, nodeIsReady is only called when the + // nodeComplete() callback is executed. + try self.nodeIsReady(false, child); + } + + // Notify mutation observers about childList change + if (self.hasMutationObservers()) { + const previous_sibling = child.previousSibling(); + const next_sibling = child.nextSibling(); + const added = [_]*Node{child}; + self.childListChange(parent, &added, &.{}, previous_sibling, next_sibling); + } } var document_by_id = &self.document._elements_by_id; @@ -1276,16 +1409,71 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } } -pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value: []const u8) void { +pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value: []const u8, old_value: ?[]const u8) void { _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| { log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err }); }; + + for (self._mutation_observers.items) |observer| { + observer.notifyAttributeChange(element, name, old_value, self) catch |err| { + log.err(.page, "attributeChange.notifyObserver", .{ .err = err }); + }; + } } -pub fn attributeRemove(self: *Page, element: *Element, name: []const u8) void { +pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_value: []const u8) void { _ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| { log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err }); }; + + for (self._mutation_observers.items) |observer| { + observer.notifyAttributeChange(element, name, old_value, self) catch |err| { + log.err(.page, "attributeRemove.notifyObserver", .{ .err = err }); + }; + } +} + +pub fn hasMutationObservers(self: *const Page) bool { + return self._mutation_observers.items.len > 0; +} + +pub fn characterDataChange( + self: *Page, + target: *Node, + old_value: []const u8, +) void { + // Notify mutation observers + for (self._mutation_observers.items) |observer| { + observer.notifyCharacterDataChange(target, old_value, self) catch |err| { + log.err(.page, "cdataChange.notifyObserver", .{ .err = err }); + }; + } +} + +pub fn childListChange( + self: *Page, + target: *Node, + added_nodes: []const *Node, + removed_nodes: []const *Node, + previous_sibling: ?*Node, + next_sibling: ?*Node, +) void { + // Filter out HTML wrapper element during fragment parsing (html5ever quirk) + if (self._parse_mode == .fragment and added_nodes.len == 1) { + if (added_nodes[0].is(Element.Html.Html) != null) { + // This is the temporary HTML wrapper, added by html5ever + // that will be unwrapped, see: + // https://github.com/servo/html5ever/issues/583 + return; + } + } + + // Notify mutation observers + for (self._mutation_observers.items) |observer| { + observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| { + log.err(.page, "childListChange.notifyObserver", .{ .err = err }); + }; + } } // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') @@ -1302,9 +1490,22 @@ pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { const first = children.one; std.debug.assert(first.is(Element.Html.Html) != null); node._children = first._children; - var it = node.childrenIterator(); - while (it.next()) |child| { - child._parent = node; + + if (self.hasMutationObservers()) { + var it = node.childrenIterator(); + while (it.next()) |child| { + child._parent = node; + // Notify mutation observers for each unwrapped child + const previous_sibling = child.previousSibling(); + const next_sibling = child.nextSibling(); + const added = [_]*Node{child}; + self.childListChange(node, &added, &.{}, previous_sibling, next_sibling); + } + } else { + var it = node.childrenIterator(); + while (it.next()) |child| { + child._parent = node; + } } } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 087a4204..4c7ff66b 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1907,6 +1907,26 @@ fn zigJsonToJs(isolate: v8.Isolate, v8_context: v8.Context, value: std.json.Valu } } +// Microtasks +pub fn queueMutationDelivery(self: *Context) !void { + self.isolate.enqueueMicrotask(struct { + fn run(data: ?*anyopaque) callconv(.c) void { + const page: *Page = @ptrCast(@alignCast(data.?)); + page.deliverMutations(); + } + }.run, self.page); +} + +pub fn queueIntersectionDelivery(self: *Context) !void { + self.isolate.enqueueMicrotask(struct { + fn run(data: ?*anyopaque) callconv(.c) void { + const page: *Page = @ptrCast(@alignCast(data.?)); + page.deliverIntersections(); + } + }.run, self.page); +} + + // == Misc == // An interface for types that want to have their jsDeinit function to be // called when the call context ends diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index d8394428..f04409e8 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -46,7 +46,14 @@ pub fn init(allocator: Allocator, isolate: v8.Isolate, ctx: anytype) !Inspector // If necessary, turn a void context into something we can safely ptrCast const safe_context: *anyopaque = if (ContextT == void) @ptrCast(@constCast(&{})) else ctx; - const channel = v8.InspectorChannel.init(safe_context, InspectorContainer.onInspectorResponse, InspectorContainer.onInspectorEvent, isolate); + const channel = v8.InspectorChannel.init( + safe_context, + InspectorContainer.onInspectorResponse, + InspectorContainer.onInspectorEvent, + InspectorContainer.onRunMessageLoopOnPause, + InspectorContainer.onQuitMessageLoopOnPause, + isolate, + ); const client = v8.InspectorClient.init(); @@ -127,6 +134,8 @@ pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []con const NoopInspector = struct { pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {} pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {} + pub fn onRunMessageLoopOnPause(_: *anyopaque, _: u32) void {} + pub fn onQuitMessageLoopOnPause(_: *anyopaque) void {} }; pub fn getTaggedAnyOpaque(value: v8.Value) ?*js.TaggedAnyOpaque { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 714407eb..447ce3ef 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -544,4 +544,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/URL.zig"), @import("../webapi/Window.zig"), @import("../webapi/MutationObserver.zig"), + @import("../webapi/IntersectionObserver.zig"), }); diff --git a/src/browser/tests/intersection_observer/basic.html b/src/browser/tests/intersection_observer/basic.html new file mode 100644 index 00000000..4131d1dd --- /dev/null +++ b/src/browser/tests/intersection_observer/basic.html @@ -0,0 +1,31 @@ + + +
Target Element
+ + diff --git a/src/browser/tests/intersection_observer/disconnect.html b/src/browser/tests/intersection_observer/disconnect.html new file mode 100644 index 00000000..6677f906 --- /dev/null +++ b/src/browser/tests/intersection_observer/disconnect.html @@ -0,0 +1,30 @@ + + +
Target Element
+ + diff --git a/src/browser/tests/intersection_observer/multiple_targets.html b/src/browser/tests/intersection_observer/multiple_targets.html new file mode 100644 index 00000000..358a8355 --- /dev/null +++ b/src/browser/tests/intersection_observer/multiple_targets.html @@ -0,0 +1,29 @@ + + +
Target 1
+
Target 2
+ + diff --git a/src/browser/tests/intersection_observer/unobserve.html b/src/browser/tests/intersection_observer/unobserve.html new file mode 100644 index 00000000..8d37e2c3 --- /dev/null +++ b/src/browser/tests/intersection_observer/unobserve.html @@ -0,0 +1,30 @@ + + +
Target 1
+
Target 2
+ + diff --git a/src/browser/tests/mutation_observer/character_data.html b/src/browser/tests/mutation_observer/character_data.html new file mode 100644 index 00000000..6afa299c --- /dev/null +++ b/src/browser/tests/mutation_observer/character_data.html @@ -0,0 +1,79 @@ + +
Initial text
+
Test
+
Test
+ + + + + + + diff --git a/src/browser/tests/mutation_observer/childlist.html b/src/browser/tests/mutation_observer/childlist.html new file mode 100644 index 00000000..915dbcf4 --- /dev/null +++ b/src/browser/tests/mutation_observer/childlist.html @@ -0,0 +1,327 @@ + +
Child 1
+
+
Only
+
+
First
Middle
Last
+ + + + + + + + + + + + +
First
Last
+ + + +
Old
+ + + +
+ + + +
Old 1
Old 2
Old 3
+ + diff --git a/src/browser/tests/mutation_observer/multiple_observers.html b/src/browser/tests/mutation_observer/multiple_observers.html new file mode 100644 index 00000000..850aee64 --- /dev/null +++ b/src/browser/tests/mutation_observer/multiple_observers.html @@ -0,0 +1,47 @@ + +
Test
+ + + diff --git a/src/browser/tests/mutation_observer/mutation_observer.html b/src/browser/tests/mutation_observer/mutation_observer.html new file mode 100644 index 00000000..8ced1815 --- /dev/null +++ b/src/browser/tests/mutation_observer/mutation_observer.html @@ -0,0 +1,114 @@ + +
Test
+
Test
+
Test
+
Test
+ + + + + + + + + diff --git a/src/browser/tests/mutation_observer/mutations_during_callback.html b/src/browser/tests/mutation_observer/mutations_during_callback.html new file mode 100644 index 00000000..829f3d5a --- /dev/null +++ b/src/browser/tests/mutation_observer/mutations_during_callback.html @@ -0,0 +1,39 @@ + +
Test
+ + + diff --git a/src/browser/tests/mutation_observer/observe_multiple_targets.html b/src/browser/tests/mutation_observer/observe_multiple_targets.html new file mode 100644 index 00000000..c4547526 --- /dev/null +++ b/src/browser/tests/mutation_observer/observe_multiple_targets.html @@ -0,0 +1,31 @@ + +
Test1
+
Test2
+ + + diff --git a/src/browser/tests/mutation_observer/reobserve_same_target.html b/src/browser/tests/mutation_observer/reobserve_same_target.html new file mode 100644 index 00000000..9249fbac --- /dev/null +++ b/src/browser/tests/mutation_observer/reobserve_same_target.html @@ -0,0 +1,27 @@ + +
Test
+ + + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index a0b569e8..cb39a70b 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -61,11 +61,15 @@ pub fn getData(self: *const CData) []const u8 { } pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { + const old_value = self._data; + if (value) |v| { self._data = try page.dupeString(v); } else { self._data = ""; } + + page.characterDataChange(self.asNode(), old_value); } pub fn format(self: *const CData, writer: *std.io.Writer) !void { diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig new file mode 100644 index 00000000..7198dfa7 --- /dev/null +++ b/src/browser/webapi/IntersectionObserver.zig @@ -0,0 +1,342 @@ +// 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 js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Element = @import("Element.zig"); +const DOMRect = @import("DOMRect.zig"); + +pub fn registerTypes() []const type { + return &.{ + IntersectionObserver, + IntersectionObserverEntry, + }; +} + +const IntersectionObserver = @This(); + +_callback: js.Function, +_observing: std.ArrayList(*Element) = .{}, +_root: ?*Element = null, +_root_margin: []const u8 = "0px", +_threshold: []const f64 = &.{0.0}, +_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{}, +_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{}, + +// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements +var zero_rect: DOMRect = .{ + ._x = 0.0, + ._y = 0.0, + ._width = 0.0, + ._height = 0.0, + ._top = 0.0, + ._right = 0.0, + ._bottom = 0.0, + ._left = 0.0, +}; + +pub const ObserverInit = struct { + root: ?*Element = null, + rootMargin: ?[]const u8 = null, + threshold: []const f64 = &.{0.0}, +}; + +pub fn init(callback: js.Function, options: ?ObserverInit, page: *Page) !*IntersectionObserver { + const opts = options orelse ObserverInit{}; + const root_margin = if (opts.rootMargin) |rm| try page.arena.dupe(u8, rm) else "0px"; + + return page._factory.create(IntersectionObserver{ + ._callback = callback, + ._root = opts.root, + ._root_margin = root_margin, + ._threshold = try page.arena.dupe(f64, opts.threshold), + }); +} + +pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { + // Check if already observing this target + for (self._observing.items) |elem| { + if (elem == target) { + return; + } + } + + // Register with page if this is our first observation + if (self._observing.items.len == 0) { + try page.registerIntersectionObserver(self); + } + + try self._observing.append(page.arena, target); + + // Don't initialize previous state yet - let checkIntersection do it + // This ensures we get an entry on first observation + + // Check intersection for this new target and schedule delivery + try self.checkIntersection(target, page); + if (self._pending_entries.items.len > 0) { + try page.scheduleIntersectionDelivery(); + } +} + +pub fn unobserve(self: *IntersectionObserver, target: *Element) void { + for (self._observing.items, 0..) |elem, i| { + if (elem == target) { + _ = self._observing.swapRemove(i); + _ = self._previous_states.remove(target); + + // Remove any pending entries for this target + var j: usize = 0; + while (j < self._pending_entries.items.len) { + if (self._pending_entries.items[j]._target == target) { + _ = self._pending_entries.swapRemove(j); + } else { + j += 1; + } + } + return; + } + } +} + +pub fn disconnect(self: *IntersectionObserver, page: *Page) void { + page.unregisterIntersectionObserver(self); + self._observing.clearRetainingCapacity(); + self._previous_states.clearRetainingCapacity(); + self._pending_entries.clearRetainingCapacity(); +} + +pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { + const entries = try page.call_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items); + self._pending_entries.clearRetainingCapacity(); + return entries; +} + +fn calculateIntersection( + self: *IntersectionObserver, + target: *Element, + page: *Page, +) !IntersectionData { + const target_rect = try target.getBoundingClientRect(page); + + // Use root element's rect or viewport (simplified: assume infinite viewport) + const root_rect = if (self._root) |root| + try root.getBoundingClientRect(page) + else + // Simplified viewport - assume 1920x1080 for now + try page._factory.create(DOMRect{ + ._x = 0.0, + ._y = 0.0, + ._width = 1920.0, + ._height = 1080.0, + ._top = 0.0, + ._right = 1920.0, + ._bottom = 1080.0, + ._left = 0.0, + }); + + // Calculate intersection rectangle + const left = @max(target_rect._left, root_rect._left); + const top = @max(target_rect._top, root_rect._top); + const right = @min(target_rect._right, root_rect._right); + const bottom = @min(target_rect._bottom, root_rect._bottom); + + const is_intersecting = left < right and top < bottom; + + var intersection_rect: ?*DOMRect = null; + var intersection_ratio: f64 = 0.0; + + if (is_intersecting) { + const width = right - left; + const height = bottom - top; + const intersection_area = width * height; + const target_area = target_rect._width * target_rect._height; + + if (target_area > 0) { + intersection_ratio = intersection_area / target_area; + } + + intersection_rect = try page._factory.create(DOMRect{ + ._x = left, + ._y = top, + ._width = width, + ._height = height, + ._top = top, + ._right = right, + ._bottom = bottom, + ._left = left, + }); + } else { + // No intersection - reuse shared zero rect to avoid allocation + intersection_rect = &zero_rect; + } + + return .{ + .is_intersecting = is_intersecting, + .intersection_ratio = intersection_ratio, + .intersection_rect = intersection_rect.?, + .bounding_client_rect = target_rect, + .root_bounds = root_rect, + }; +} + +const IntersectionData = struct { + is_intersecting: bool, + intersection_ratio: f64, + intersection_rect: *DOMRect, + bounding_client_rect: *DOMRect, + root_bounds: *DOMRect, +}; + +fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool { + for (self._threshold) |threshold| { + if (ratio >= threshold) { + return true; + } + } + return false; +} + +fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) !void { + const data = try self.calculateIntersection(target, page); + const was_intersecting_opt = self._previous_states.get(target); + const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio); + + // Create entry if: + // 1. First time observing this target (was_intersecting_opt == null) + // 2. State changed + // 3. Currently intersecting + const should_report = was_intersecting_opt == null or + was_intersecting_opt.? != is_now_intersecting; + + if (should_report) { + const entry = try page.arena.create(IntersectionObserverEntry); + entry.* = .{ + ._target = target, + ._time = 0.0, // TODO: Get actual timestamp + ._bounding_client_rect = data.bounding_client_rect, + ._intersection_rect = data.intersection_rect, + ._root_bounds = data.root_bounds, + ._intersection_ratio = data.intersection_ratio, + ._is_intersecting = is_now_intersecting, + }; + + try self._pending_entries.append(page.arena, entry); + try self._previous_states.put(page.arena, target, is_now_intersecting); + } +} + +pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void { + if (self._observing.items.len == 0) { + return; + } + + for (self._observing.items) |target| { + try self.checkIntersection(target, page); + } + + if (self._pending_entries.items.len > 0) { + try page.scheduleIntersectionDelivery(); + } +} + +pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void { + if (self._pending_entries.items.len == 0) { + return; + } + + const entries = try self.takeRecords(page); + try self._callback.call(void, .{ entries, self }); +} + +pub const IntersectionObserverEntry = struct { + _target: *Element, + _time: f64, + _bounding_client_rect: *DOMRect, + _intersection_rect: *DOMRect, + _root_bounds: *DOMRect, + _intersection_ratio: f64, + _is_intersecting: bool, + + pub fn getTarget(self: *const IntersectionObserverEntry) *Element { + return self._target; + } + + pub fn getTime(self: *const IntersectionObserverEntry) f64 { + return self._time; + } + + pub fn getBoundingClientRect(self: *const IntersectionObserverEntry) *DOMRect { + return self._bounding_client_rect; + } + + pub fn getIntersectionRect(self: *const IntersectionObserverEntry) *DOMRect { + return self._intersection_rect; + } + + pub fn getRootBounds(self: *const IntersectionObserverEntry) ?*DOMRect { + return self._root_bounds; + } + + pub fn getIntersectionRatio(self: *const IntersectionObserverEntry) f64 { + return self._intersection_ratio; + } + + pub fn getIsIntersecting(self: *const IntersectionObserverEntry) bool { + return self._is_intersecting; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(IntersectionObserverEntry); + + pub const Meta = struct { + pub const name = "IntersectionObserverEntry"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{}); + pub const time = bridge.accessor(IntersectionObserverEntry.getTime, null, .{}); + pub const boundingClientRect = bridge.accessor(IntersectionObserverEntry.getBoundingClientRect, null, .{}); + pub const intersectionRect = bridge.accessor(IntersectionObserverEntry.getIntersectionRect, null, .{}); + pub const rootBounds = bridge.accessor(IntersectionObserverEntry.getRootBounds, null, .{}); + pub const intersectionRatio = bridge.accessor(IntersectionObserverEntry.getIntersectionRatio, null, .{}); + pub const isIntersecting = bridge.accessor(IntersectionObserverEntry.getIsIntersecting, null, .{}); + }; +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(IntersectionObserver); + + pub const Meta = struct { + pub const name = "IntersectionObserver"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(init, .{}); + + pub const observe = bridge.function(IntersectionObserver.observe, .{}); + pub const unobserve = bridge.function(IntersectionObserver.unobserve, .{}); + pub const disconnect = bridge.function(IntersectionObserver.disconnect, .{}); + pub const takeRecords = bridge.function(IntersectionObserver.takeRecords, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: IntersectionObserver" { + try testing.htmlRunner("intersection_observer", .{}); +} diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index e33f3223..ed34d80f 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -16,18 +16,271 @@ // 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 js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Node = @import("Node.zig"); +const Element = @import("Element.zig"); + +pub fn registerTypes() []const type { + return &.{ + MutationObserver, + MutationRecord, + }; +} -// @ZIGDOM (haha, bet you wish you hadn't opened this file) -// puppeteer's startup script creates a MutationObserver, even if it doesn't use -// it in simple scripts. This not-even-a-skeleton is required for puppeteer/cdp.js -// to run const MutationObserver = @This(); -pub fn init() MutationObserver { - return .{}; +_callback: js.Function, +_observing: std.ArrayList(Observing) = .{}, +_pending_records: std.ArrayList(*MutationRecord) = .{}, + +const Observing = struct { + target: *Node, + options: ObserveOptions, +}; + +pub const ObserveOptions = struct { + attributes: bool = false, + attributeOldValue: bool = false, + childList: bool = false, + characterData: bool = false, + characterDataOldValue: bool = false, + // Future: subtree, attributeFilter +}; + +pub fn init(callback: js.Function, page: *Page) !*MutationObserver { + return page._factory.create(MutationObserver{ + ._callback = callback, + }); } +pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { + // Check if already observing this target + for (self._observing.items) |*obs| { + if (obs.target == target) { + obs.options = options; + return; + } + } + + // Register with page if this is our first observation + if (self._observing.items.len == 0) { + try page.registerMutationObserver(self); + } + + try self._observing.append(page.arena, .{ + .target = target, + .options = options, + }); +} + +pub fn disconnect(self: *MutationObserver, page: *Page) void { + page.unregisterMutationObserver(self); + self._observing.clearRetainingCapacity(); + self._pending_records.clearRetainingCapacity(); +} + +pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { + const records = try page.call_arena.dupe(*MutationRecord, self._pending_records.items); + self._pending_records.clearRetainingCapacity(); + return records; +} + +// Called when an attribute changes on any element +pub fn notifyAttributeChange( + self: *MutationObserver, + target: *Element, + attribute_name: []const u8, + old_value: ?[]const u8, + page: *Page, +) !void { + const target_node = target.asNode(); + + for (self._observing.items) |obs| { + if (obs.target != target_node) { + continue; + } + if (!obs.options.attributes) { + continue; + } + + const record = try page._factory.create(MutationRecord{ + ._type = .attributes, + ._target = target_node, + ._attribute_name = try page.arena.dupe(u8, attribute_name), + ._old_value = if (obs.options.attributeOldValue and old_value != null) + try page.arena.dupe(u8, old_value.?) + else + null, + ._added_nodes = &.{}, + ._removed_nodes = &.{}, + ._previous_sibling = null, + ._next_sibling = null, + }); + + try self._pending_records.append(page.arena, record); + + try page.scheduleMutationDelivery(); + break; + } +} + +// Called when character data changes on a text node +pub fn notifyCharacterDataChange( + self: *MutationObserver, + target: *Node, + old_value: ?[]const u8, + page: *Page, +) !void { + for (self._observing.items) |obs| { + if (obs.target != target) { + continue; + } + if (!obs.options.characterData) { + continue; + } + + const record = try page._factory.create(MutationRecord{ + ._type = .characterData, + ._target = target, + ._attribute_name = null, + ._old_value = if (obs.options.characterDataOldValue and old_value != null) + try page.arena.dupe(u8, old_value.?) + else + null, + ._added_nodes = &.{}, + ._removed_nodes = &.{}, + ._previous_sibling = null, + ._next_sibling = null, + }); + + try self._pending_records.append(page.arena, record); + + try page.scheduleMutationDelivery(); + break; + } +} + +// Called when children are added or removed from a node +pub fn notifyChildListChange( + self: *MutationObserver, + target: *Node, + added_nodes: []const *Node, + removed_nodes: []const *Node, + previous_sibling: ?*Node, + next_sibling: ?*Node, + page: *Page, +) !void { + for (self._observing.items) |obs| { + if (obs.target != target) { + continue; + } + if (!obs.options.childList) { + continue; + } + + const record = try page._factory.create(MutationRecord{ + ._type = .childList, + ._target = target, + ._attribute_name = null, + ._old_value = null, + ._added_nodes = try page.arena.dupe(*Node, added_nodes), + ._removed_nodes = try page.arena.dupe(*Node, removed_nodes), + ._previous_sibling = previous_sibling, + ._next_sibling = next_sibling, + }); + + try self._pending_records.append(page.arena, record); + + try page.scheduleMutationDelivery(); + break; + } +} + +pub fn deliverRecords(self: *MutationObserver, page: *Page) !void { + if (self._pending_records.items.len == 0) { + return; + } + + // Take a copy of the records and clear the list before calling callback + // This ensures mutations triggered during the callback go into a fresh list + const records = try self.takeRecords(page); + try self._callback.call(void, .{ records, self }); +} + +pub const MutationRecord = struct { + _type: Type, + _target: *Node, + _attribute_name: ?[]const u8, + _old_value: ?[]const u8, + _added_nodes: []const *Node, + _removed_nodes: []const *Node, + _previous_sibling: ?*Node, + _next_sibling: ?*Node, + + pub const Type = enum { + attributes, + childList, + characterData, + }; + + pub fn getType(self: *const MutationRecord) []const u8 { + return switch (self._type) { + .attributes => "attributes", + .childList => "childList", + .characterData => "characterData", + }; + } + + pub fn getTarget(self: *const MutationRecord) *Node { + return self._target; + } + + pub fn getAttributeName(self: *const MutationRecord) ?[]const u8 { + return self._attribute_name; + } + + pub fn getOldValue(self: *const MutationRecord) ?[]const u8 { + return self._old_value; + } + + pub fn getAddedNodes(self: *const MutationRecord) []const *Node { + return self._added_nodes; + } + + pub fn getRemovedNodes(self: *const MutationRecord) []const *Node { + return self._removed_nodes; + } + + pub fn getPreviousSibling(self: *const MutationRecord) ?*Node { + return self._previous_sibling; + } + + pub fn getNextSibling(self: *const MutationRecord) ?*Node { + return self._next_sibling; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(MutationRecord); + + pub const Meta = struct { + pub const name = "MutationRecord"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const @"type" = bridge.accessor(MutationRecord.getType, null, .{}); + pub const target = bridge.accessor(MutationRecord.getTarget, null, .{}); + pub const attributeName = bridge.accessor(MutationRecord.getAttributeName, null, .{}); + pub const oldValue = bridge.accessor(MutationRecord.getOldValue, null, .{}); + pub const addedNodes = bridge.accessor(MutationRecord.getAddedNodes, null, .{}); + pub const removedNodes = bridge.accessor(MutationRecord.getRemovedNodes, null, .{}); + pub const previousSibling = bridge.accessor(MutationRecord.getPreviousSibling, null, .{}); + pub const nextSibling = bridge.accessor(MutationRecord.getNextSibling, null, .{}); + }; +}; + pub const JsApi = struct { pub const bridge = js.Bridge(MutationObserver); @@ -38,4 +291,13 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(MutationObserver.init, .{}); + + pub const observe = bridge.function(MutationObserver.observe, .{}); + pub const disconnect = bridge.function(MutationObserver.disconnect, .{}); + pub const takeRecords = bridge.function(MutationObserver.takeRecords, .{}); }; + +const testing = @import("../../testing.zig"); +test "WebApi: MutationObserver" { + try testing.htmlRunner("mutation_observer", .{}); +} diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 3d37f173..c07c68dd 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -156,7 +156,9 @@ pub const List = struct { const is_id = isIdForConnected(result.normalized, element); var entry: *Entry = undefined; + var old_value: ?[]const u8 = null; if (result.entry) |e| { + old_value = try page.call_arena.dupe(u8, e._value.str()); if (is_id) { _ = page.document._elements_by_id.remove(e._value.str()); } @@ -174,7 +176,7 @@ pub const List = struct { if (is_id) { try page.document._elements_by_id.put(page.arena, entry._value.str(), element); } - page.attributeChange(element, result.normalized, entry._value.str()); + page.attributeChange(element, result.normalized, entry._value.str(), old_value); return entry; } @@ -226,12 +228,13 @@ pub const List = struct { const entry = result.entry orelse return; const is_id = isIdForConnected(result.normalized, element); + const old_value = entry._value.str(); if (is_id) { _ = page.document._elements_by_id.remove(entry._value.str()); } - page.attributeRemove(element, result.normalized); + page.attributeRemove(element, result.normalized, old_value); _ = page._attribute_lookup.remove(@intFromPtr(entry)); self._list.remove(&entry._node); page._factory.destroy(entry); diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 73c5e514..dc897346 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -642,6 +642,19 @@ pub fn BrowserContext(comptime CDP_T: type) type { }; } + // debugger events + + + pub fn onRunMessageLoopOnPause(_: *anyopaque, _: u32) void { + // onRunMessageLoopOnPause is called when a breakpoint is hit. + // Until quit pause, we must continue to run a nested message loop + // to interact with the the debugger ony (ie. Chrome DevTools). + } + + pub fn onQuitMessageLoopOnPause(_: *anyopaque) void { + // Quit breakpoint pause. + } + // This is hacky x 2. First, we create the JSON payload by gluing our // session_id onto it. Second, we're much more client/websocket aware than // we should be.