Event listener can now be an object with a handleEvent function

This commit is contained in:
Karl Seguin
2025-12-13 17:19:53 +08:00
parent 52dcc6765a
commit c9b4067686
4 changed files with 228 additions and 25 deletions

View File

@@ -57,7 +57,13 @@ pub const RegisterOptions = struct {
passive: bool = false,
signal: ?*@import("webapi/AbortSignal.zig") = null,
};
pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, opts: RegisterOptions) !void {
pub const Callback = union(enum) {
function: js.Function,
object: js.Object,
};
pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once });
}
@@ -71,11 +77,15 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func
const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target));
if (gop.found_existing) {
// check for duplicate functions already registered
// check for duplicate callbacks already registered
var node = gop.value_ptr.first;
while (node) |n| {
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.function.eql(function) and listener.capture == opts.capture) {
const is_duplicate = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (is_duplicate and listener.capture == opts.capture) {
return;
}
node = n.next;
@@ -84,13 +94,18 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func
gop.value_ptr.* = .{};
}
const func = switch (callback) {
.function => |f| Function{ .value = f },
.object => |o| Function{ .object = o },
};
const listener = try self.listener_pool.create();
listener.* = .{
.node = .{},
.once = opts.once,
.capture = opts.capture,
.passive = opts.passive,
.function = .{ .value = function },
.function = func,
.signal = opts.signal,
.typ = try String.init(self.arena, typ, .{}),
};
@@ -98,9 +113,9 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func
gop.value_ptr.append(&listener.node);
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, use_capture: bool) void {
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
if (findListener(list, typ, function, use_capture)) |listener| {
if (findListener(list, typ, callback, use_capture)) |listener| {
self.removeListener(list, listener);
}
}
@@ -119,7 +134,17 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
switch (target._type) {
.node => |node| try self.dispatchNode(node, event, &was_handled),
.xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation, .screen, .screen_orientation => {
.xhr,
.window,
.abort_signal,
.media_query_list,
.message_port,
.text_track_cue,
.navigation,
.screen,
.screen_orientation,
.generic,
=> {
const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
try self.dispatchAll(list, target, event, &was_handled);
},
@@ -304,6 +329,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null);
},
.object => |obj| {
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
// Restore original target (only if we changed it)
@@ -350,12 +380,16 @@ fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void
}
}
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, function: js.Function, capture: bool) ?*Listener {
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Callback, capture: bool) ?*Listener {
var node = list.first;
while (node) |n| {
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (!listener.function.eql(function)) {
const matches = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (!matches) {
continue;
}
if (listener.capture != capture) {
@@ -383,11 +417,19 @@ const Listener = struct {
const Function = union(enum) {
value: js.Function,
string: String,
object: js.Object,
fn eql(self: Function, func: js.Function) bool {
fn eqlFunction(self: Function, func: js.Function) bool {
return switch (self) {
.string => false,
.value => |v| return v.id == func.id,
else => false,
};
}
fn eqlObject(self: Function, obj: js.Object) bool {
return switch (self) {
.object => |o| return o.getId() == obj.getId(),
else => false,
};
}
};

View File

@@ -32,6 +32,10 @@ const Object = @This();
js_obj: v8.Object,
context: *js.Context,
pub fn getId(self: Object) u32 {
return self.js_obj.getIdentityHash();
}
pub const SetOpts = packed struct(u32) {
READ_ONLY: bool = false,
DONT_ENUM: bool = false,

View File

@@ -497,3 +497,118 @@
testing.expectEqual('inner1', nested_calls[4]);
testing.expectEqual(5, nested_calls.length);
</script>
<div id="content"><p id=para></p></div>
<script id=event_target>
{
testing.expectEqual('[object EventTarget]', new EventTarget().toString());
let content = $('#content');
let para = $('#para');
var nb = 0;
var evt;
var phase;
var cur;
function reset() {
nb = 0;
evt = undefined;
phase = undefined;
cur = undefined;
}
function cbk(event) {
evt = event;
phase = event.eventPhase;
cur = event.currentTarget;
nb++;
}
content.addEventListener('basic', cbk);
content.dispatchEvent(new Event('basic'));
testing.expectEqual(1, nb);
testing.expectEqual(true, evt instanceof Event);
testing.expectEqual('basic', evt.type);
testing.expectEqual(2, phase);
testing.expectEqual('content', cur.getAttribute('id'));
reset();
para.dispatchEvent(new Event('basic'))
// handler is not called, no capture, not the targeno bubbling
testing.expectEqual(0, nb);
testing.expectEqual(undefined, evt);
reset();
content.addEventListener('basic', cbk);
content.dispatchEvent(new Event('basic'))
testing.expectEqual(1, nb);
reset();
content.addEventListener('basic', cbk, true);
content.dispatchEvent(new Event('basic'));
testing.expectEqual(2, nb);
reset()
content.removeEventListener('basic', cbk);
content.dispatchEvent(new Event('basic'));
testing.expectEqual(1, nb);
reset();
content.removeEventListener('basic', cbk, {capture: true});
content.dispatchEvent(new Event('basic'));
testing.expectEqual(0, nb);
reset();
content.addEventListener('capture', cbk, true);
content.dispatchEvent(new Event('capture'));
testing.expectEqual(1, nb);
testing.expectEqual(true, evt instanceof Event);
testing.expectEqual('capture', evt.type);
testing.expectEqual(2, phase);
testing.expectEqual('content', cur.getAttribute('id'));
reset();
para.dispatchEvent(new Event('capture'));
testing.expectEqual(1, nb);
testing.expectEqual(true, evt instanceof Event);
testing.expectEqual('capture', evt.type);
testing.expectEqual(1, phase);
testing.expectEqual('content', cur.getAttribute('id'));
reset();
content.addEventListener('bubbles', cbk);
content.dispatchEvent(new Event('bubbles', {bubbles: true}));
testing.expectEqual(1, nb);
testing.expectEqual(true, evt instanceof Event);
testing.expectEqual('bubbles', evt.type);
testing.expectEqual(2, phase);
testing.expectEqual('content', cur.getAttribute('id'));
reset();
para.dispatchEvent(new Event('bubbles', {bubbles: true}));
testing.expectEqual(1, nb);
testing.expectEqual(true, evt instanceof Event);
testing.expectEqual('bubbles', evt.type);
testing.expectEqual(3, phase);
testing.expectEqual('content', cur.getAttribute('id'));
const obj1 = {
calls: 0,
handleEvent: function() { this.calls += 1 }
};
content.addEventListener('he', obj1);
content.dispatchEvent(new Event('he'));
testing.expectEqual(1, obj1.calls);
content.removeEventListener('he', obj1);
content.dispatchEvent(new Event('he'));
testing.expectEqual(1, obj1.calls);
// doesn't crash on null receiver
content.addEventListener('he2', null);
content.dispatchEvent(new Event('he2'));
}
</script>

View File

@@ -20,7 +20,8 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const RegisterOptions = @import("../EventManager.zig").RegisterOptions;
const EventManager = @import("../EventManager.zig");
const RegisterOptions = EventManager.RegisterOptions;
const Event = @import("Event.zig");
@@ -30,6 +31,7 @@ const _prototype_root = true;
_type: Type,
pub const Type = union(enum) {
generic: void,
node: *@import("Node.zig"),
window: *@import("Window.zig"),
xhr: *@import("net/XMLHttpRequestEventTarget.zig"),
@@ -42,6 +44,12 @@ pub const Type = union(enum) {
screen_orientation: *@import("Screen.zig").Orientation,
};
pub fn init(page: *Page) !*EventTarget {
return page._factory.create(EventTarget{
._type = .generic,
});
}
pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
try page._event_manager.dispatch(self, event);
return !event._cancelable or !event._prevent_default;
@@ -59,9 +67,15 @@ pub const EventListenerCallback = union(enum) {
pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void {
const callback = callback_ orelse return;
const actual_callback = switch (callback) {
.function => |func| func,
.object => |obj| (try obj.getFunction("handleEvent")) orelse return,
if (callback == .object) {
if (try callback.object.getFunction("handleEvent") == null) {
return;
}
}
const em_callback = switch (callback) {
.function => |func| EventManager.Callback{ .function = func },
.object => |obj| EventManager.Callback{ .object = try obj.persist() },
};
const options = blk: {
@@ -71,7 +85,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi
.capture => |capture| RegisterOptions{ .capture = capture },
};
};
return page._event_manager.register(self, typ, actual_callback, options);
return page._event_manager.register(self, typ, em_callback, options);
}
const RemoveEventListenerOptions = union(enum) {
@@ -79,36 +93,63 @@ const RemoveEventListenerOptions = union(enum) {
options: Options,
const Options = struct {
useCapture: bool = false,
capture: bool = false,
};
};
pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void {
const callback = callback_ orelse return;
const actual_callback = switch (callback) {
.function => |func| func,
.object => |obj| (try obj.getFunction("handleEvent")) orelse return,
// For object callbacks, check if handleEvent exists
if (callback == .object) {
if (try callback.object.getFunction("handleEvent") == null) {
return;
}
}
const em_callback = switch (callback) {
.function => |func| EventManager.Callback{ .function = func },
.object => |obj| EventManager.Callback{ .object = try obj.persist() },
};
const use_capture = blk: {
const o = opts_ orelse break :blk false;
break :blk switch (o) {
.capture => |capture| capture,
.options => |opts| opts.useCapture,
.options => |opts| opts.capture,
};
};
return page._event_manager.remove(self, typ, actual_callback, use_capture);
return page._event_manager.remove(self, typ, em_callback, use_capture);
}
pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
return switch (self._type) {
.node => |n| n.format(writer),
.window => writer.writeAll("<window>"),
.generic => writer.writeAll("<EventTarget>"),
.window => writer.writeAll("<Window>"),
.xhr => writer.writeAll("<XMLHttpRequestEventTarget>"),
.abort_signal => writer.writeAll("<abort_signal>"),
.abort_signal => writer.writeAll("<AbortSignal>"),
.media_query_list => writer.writeAll("<MediaQueryList>"),
.message_port => writer.writeAll("<MessagePort>"),
.text_track_cue => writer.writeAll("<TextTrackCue>"),
.navigation => writer.writeAll("<Navigation>"),
.screen => writer.writeAll("<Screen>"),
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
};
}
pub fn toString(self: *EventTarget) []const u8 {
return switch (self._type) {
.node => |n| return n.className(),
.generic => return "[object EventTarget]",
.window => return "[object Window]",
.xhr => return "[object XMLHttpRequestEventTarget]",
.abort_signal => return "[object AbortSignal]",
.media_query_list => return "[object MediaQueryList]",
.message_port => return "[object MessagePort]",
.text_track_cue => return "[object TextTrackCue]",
.navigation => return "[object Navigation]",
.screen => return "[object Screen]",
.screen_orientation => return "[object ScreenOrientation]",
};
}
@@ -122,15 +163,16 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(EventTarget.init, .{});
pub const dispatchEvent = bridge.function(EventTarget.dispatchEvent, .{});
pub const addEventListener = bridge.function(EventTarget.addEventListener, .{});
pub const removeEventListener = bridge.function(EventTarget.removeEventListener, .{});
pub const toString = bridge.function(EventTarget.toString, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: EventTarget" {
// we create thousands of these per page. Nothing should bloat it.
try testing.expectEqual(16, @sizeOf(EventTarget));
try testing.htmlRunner("events.html", .{});
}