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, passive: bool = false,
signal: ?*@import("webapi/AbortSignal.zig") = null, 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) { if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once }); 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)); const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target));
if (gop.found_existing) { if (gop.found_existing) {
// check for duplicate functions already registered // check for duplicate callbacks already registered
var node = gop.value_ptr.first; var node = gop.value_ptr.first;
while (node) |n| { while (node) |n| {
const listener: *Listener = @alignCast(@fieldParentPtr("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; return;
} }
node = n.next; node = n.next;
@@ -84,13 +94,18 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, func
gop.value_ptr.* = .{}; gop.value_ptr.* = .{};
} }
const func = switch (callback) {
.function => |f| Function{ .value = f },
.object => |o| Function{ .object = o },
};
const listener = try self.listener_pool.create(); const listener = try self.listener_pool.create();
listener.* = .{ listener.* = .{
.node = .{}, .node = .{},
.once = opts.once, .once = opts.once,
.capture = opts.capture, .capture = opts.capture,
.passive = opts.passive, .passive = opts.passive,
.function = .{ .value = function }, .function = func,
.signal = opts.signal, .signal = opts.signal,
.typ = try String.init(self.arena, typ, .{}), .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); 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; 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); self.removeListener(list, listener);
} }
} }
@@ -119,7 +134,17 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
switch (target._type) { switch (target._type) {
.node => |node| try self.dispatchNode(node, event, &was_handled), .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; const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
try self.dispatchAll(list, target, event, &was_handled); 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()); const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null); 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) // 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; var node = list.first;
while (node) |n| { while (node) |n| {
node = n.next; node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n)); 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; continue;
} }
if (listener.capture != capture) { if (listener.capture != capture) {
@@ -383,11 +417,19 @@ const Listener = struct {
const Function = union(enum) { const Function = union(enum) {
value: js.Function, value: js.Function,
string: String, string: String,
object: js.Object,
fn eql(self: Function, func: js.Function) bool { fn eqlFunction(self: Function, func: js.Function) bool {
return switch (self) { return switch (self) {
.string => false,
.value => |v| return v.id == func.id, .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, js_obj: v8.Object,
context: *js.Context, context: *js.Context,
pub fn getId(self: Object) u32 {
return self.js_obj.getIdentityHash();
}
pub const SetOpts = packed struct(u32) { pub const SetOpts = packed struct(u32) {
READ_ONLY: bool = false, READ_ONLY: bool = false,
DONT_ENUM: bool = false, DONT_ENUM: bool = false,

View File

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