Add basic support for key events

Support CDP's Input.dispatchKeyEvent and DOM key events. Currently only
keydown is supported and expects every key to be a displayable character.

It turns out that manipulating the DOM via key events isn't great because the
behavior really depends on the cursor. So, to do this more accurately, we'd
have to introduce some concept of a cursor.

Personally, I don't think we'll run into many pages that are purposefully
using keyboard events. But driver (puppeteer/playwright) scripts might be
another issue.
This commit is contained in:
Karl Seguin
2025-06-09 16:31:50 +08:00
parent d9ce89ab31
commit effd07d8c0
5 changed files with 199 additions and 10 deletions

View File

@@ -243,17 +243,23 @@ pub const Document = struct {
return try TreeWalker.init(root, what_to_show, filter);
}
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
if (state.active_element) |ae| {
return try Element.toInterface(ae);
pub fn getActiveElement(self: *parser.Document, page: *Page) !?*parser.Element {
if (page.getNodeState(@alignCast(@ptrCast(self)))) |state| {
if (state.active_element) |ae| {
return ae;
}
}
if (try parser.documentHTMLBody(page.window.document)) |body| {
return try Element.toInterface(@alignCast(@ptrCast(body)));
return @alignCast(@ptrCast(body));
}
return get_documentElement(self);
return try parser.documentGetDocumentElement(self);
}
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
const ae = (try getActiveElement(self, page)) orelse return null;
return try Element.toInterface(ae);
}
// TODO: some elements can't be focused, like if they're disabled

View File

@@ -25,6 +25,7 @@ const c = @cImport({
@cInclude("events/event_target.h");
@cInclude("events/event.h");
@cInclude("events/mouse_event.h");
@cInclude("events/keyboard_event.h");
@cInclude("utils/validate.h");
@cInclude("html/html_element.h");
@cInclude("html/html_document.h");
@@ -864,6 +865,59 @@ pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool {
return eventDefaultPrevented(@ptrCast(evt));
}
// KeyboardEvent
pub const KeyboardEvent = c.dom_keyboard_event;
pub fn keyboardEventCreate() !*KeyboardEvent {
var evt: ?*KeyboardEvent = undefined;
const err = c._dom_keyboard_event_create(&evt);
try DOMErr(err);
return evt.?;
}
pub fn keyboardEventDestroy(evt: *KeyboardEvent) void {
c._dom_keyboard_event_destroy(evt);
}
const KeyboardEventOpts = struct {
key: []const u8,
code: []const u8,
bubbles: bool = false,
cancelable: bool = false,
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
meta: bool = false,
};
pub fn keyboardEventInit(evt: *KeyboardEvent, typ: []const u8, opts: KeyboardEventOpts) !void {
const s = try strFromData(typ);
const err = c._dom_keyboard_event_init(
evt,
s,
opts.bubbles,
opts.cancelable,
null, // dom_abstract_view* ?
try strFromData(opts.key),
try strFromData(opts.code),
0, // location 0 == standard
opts.ctrl,
opts.shift,
opts.alt,
opts.meta,
false, // repease
false, // is_composiom
);
try DOMErr(err);
}
pub fn keyboardEventGetKey(evt: *KeyboardEvent) ![]const u8 {
var s: ?*String = undefined;
_ = c._dom_keyboard_event_get_key(evt, &s);
return strToData(s.?);
}
// NodeType
pub const NodeType = enum(u4) {
@@ -2393,6 +2447,11 @@ pub fn textareaGetValue(textarea: *TextArea) ![]const u8 {
return strToData(s);
}
pub fn textareaSetValue(textarea: *TextArea, value: []const u8) !void {
const err = c.dom_html_text_area_element_set_value(textarea, try strFromData(value));
try DOMErr(err);
}
// Select
pub fn selectGetOptions(select: *Select) !*OptionCollection {
var collection: ?*OptionCollection = null;

View File

@@ -80,6 +80,7 @@ pub const Page = struct {
microtask_node: Loop.CallbackNode,
keydown_event_node: parser.EventNode,
window_clicked_event_node: parser.EventNode,
// Our JavaScript context for this specific page. This is what we use to
@@ -112,6 +113,7 @@ pub const Page = struct {
.state_pool = &browser.state_pool,
.cookie_jar = &session.cookie_jar,
.microtask_node = .{ .func = microtaskCallback },
.keydown_event_node = .{ .func = keydownCallback },
.window_clicked_event_node = .{ .func = windowClicked },
.request_factory = browser.http_client.requestFactory(.{
.notification = browser.notification,
@@ -307,6 +309,12 @@ pub const Page = struct {
&self.window_clicked_event_node,
false,
);
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Element, document_element),
"keydown",
&self.keydown_event_node,
false,
);
// https://html.spec.whatwg.org/#read-html
@@ -595,6 +603,76 @@ pub const Page = struct {
}
}
pub const KeyboardEvent = struct {
type: Type,
key: []const u8,
code: []const u8,
alt: bool,
ctrl: bool,
meta: bool,
shift: bool,
const Type = enum {
keydown,
};
};
pub fn keyboardEvent(self: *Page, kbe: KeyboardEvent) !void {
if (kbe.type != .keydown) {
return;
}
const Document = @import("dom/document.zig").Document;
const element = (try Document.getActiveElement(@ptrCast(self.window.document), self)) orelse return;
const event = try parser.keyboardEventCreate();
defer parser.keyboardEventDestroy(event);
try parser.keyboardEventInit(event, "keydown", .{
.bubbles = true,
.cancelable = true,
.key = kbe.key,
.code = kbe.code,
.alt = kbe.alt,
.ctrl = kbe.ctrl,
.meta = kbe.meta,
.shift = kbe.shift,
});
_ = try parser.elementDispatchEvent(element, @ptrCast(event));
}
fn keydownCallback(node: *parser.EventNode, event: *parser.Event) void {
const self: *Page = @fieldParentPtr("keydown_event_node", node);
self._keydownCallback(event) catch |err| {
log.err(.browser, "keydown handler error", .{ .err = err });
};
}
fn _keydownCallback(page: *Page, event: *parser.Event) !void {
const kbe: *parser.KeyboardEvent = @ptrCast(event);
const target = (try parser.eventTarget(event)) orelse return;
const node = parser.eventTargetToNode(target);
const tag = (try parser.nodeHTMLGetTagType(node)) orelse return;
switch (tag) {
.input => {
const element: *parser.Element = @ptrCast(node);
const input_type = (try parser.elementGetAttribute(element, "type")) orelse "text";
if (std.mem.eql(u8, input_type, "text")) {
const value = try parser.inputGetValue(@ptrCast(element));
const new_key = try parser.keyboardEventGetKey(kbe);
const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key });
try parser.inputSetValue(@ptrCast(element), new_value);
}
},
.textarea => {
const value = try parser.textareaGetValue(@ptrCast(node));
const new_key = try parser.keyboardEventGetKey(kbe);
const new_value = try std.mem.concat(page.arena, u8, &.{ value, new_key });
try parser.textareaSetValue(@ptrCast(node), new_value);
},
else => {},
}
}
// As such we schedule the function to be called as soon as possible.
// The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime.

View File

@@ -162,7 +162,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page
}
submitter_included = true;
}
const value = (try parser.elementGetAttribute(element, "value")) orelse "";
const value = try parser.inputGetValue(@ptrCast(element));
try entries.appendOwned(arena, name, value);
},
.select => {
@@ -189,11 +189,11 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page
}
if (submitter_included == false) {
if (submitter_) |submitter| {
if (submitter_name_) |submitter_name| {
// this can happen if the submitter is outside the form, but associated
// with the form via a form=ID attribute
const value = (try parser.elementGetAttribute(@ptrCast(submitter), "value")) orelse "";
try entries.appendOwned(arena, submitter_name_.?, value);
const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse "";
try entries.appendOwned(arena, submitter_name, value);
}
}

View File

@@ -21,14 +21,60 @@ const Page = @import("../../browser/page.zig").Page;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchKeyEvent,
dispatchMouseEvent,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchKeyEvent => return dispatchKeyEvent(cmd),
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent
fn dispatchKeyEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {
type: Type,
key: []const u8,
code: []const u8,
modifiers: u4,
// Many optional parameters are not implemented yet, see documentation url.
const Type = enum {
keyDown,
keyUp,
rawKeyDown,
char,
};
})) orelse return error.InvalidParams;
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.keyUp, .rawKeyDown, .char => return,
.keyDown => {},
}
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const keyboard_event = Page.KeyboardEvent{
.key = params.key,
.code = params.code,
.type = switch (params.type) {
.keyDown => .keydown,
else => unreachable,
},
.alt = params.modifiers & 1 == 1,
.ctrl = params.modifiers & 2 == 2,
.meta = params.modifiers & 4 == 4,
.shift = params.modifiers & 8 == 8,
};
try page.keyboardEvent(keyboard_event);
// result already sent
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
fn dispatchMouseEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {