mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-21 03:55:22 -04:00
input: one key target resolver and shared activation predicates
Every key path resolves its target through user_input.focusedElement, which is document.activeElement with the <body> fallback. WebDriver and the MCP press action used to fall back to the document node instead, a target no default action or char step handles. The Enter and Space activation rules share isButton, and the text-entry rule (no text goes into a checkbox or radio) lives on the TextEntry mixin as acceptsTextEntry rather than as a type test inside the shared insertion helper. pressKey takes its extra ref only when a keypress will be built from the keydown.
This commit is contained in:
1 parent
7b30a7447e
commit
21c2d71e84
8 files changed
+100
-89
No files matched your search
@@ -20,7 +20,7 @@ const Viewport = @This();
|
||||
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: f32 = 1.0, // for screenshot raster and window.devicePixelRatio
|
||||
scale: f32 = 1.0, // CSS px to device px
|
||||
// window.screen dimensions; null means the same as the viewport.
|
||||
screen_width: ?u32 = null,
|
||||
screen_height: ?u32 = null,
|
||||
|
||||
@@ -129,11 +129,10 @@ pub fn hover(node: *DOMNode, frame: *Frame) !void {
|
||||
}
|
||||
|
||||
pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
|
||||
const target_el: ?*Element = if (node) |n|
|
||||
const target: *Element = if (node) |n|
|
||||
(n.is(Element) orelse return error.InvalidNodeType)
|
||||
else
|
||||
null;
|
||||
const target = if (target_el) |el| el.asEventTarget() else frame.document.asNode().asEventTarget();
|
||||
Frame.user_input.focusedElement(frame) orelse return error.ActionFailed;
|
||||
const canonical = canonicalKey(key);
|
||||
|
||||
const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{
|
||||
@@ -143,10 +142,7 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
|
||||
.key = canonical,
|
||||
}, frame);
|
||||
|
||||
_ = (if (target_el) |el|
|
||||
Frame.user_input.pressKey(frame, el, keydown_event, Frame.user_input.textForKey(keydown_event))
|
||||
else
|
||||
frame._event_manager.dispatchCancelable(target, keydown_event.asEvent())) catch |err| {
|
||||
_ = Frame.user_input.pressKey(frame, target, keydown_event, Frame.user_input.textForKey(keydown_event)) catch |err| {
|
||||
lp.log.err(.app, "press keydown failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
@@ -158,7 +154,7 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
|
||||
.key = canonical,
|
||||
}, frame);
|
||||
|
||||
frame._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| {
|
||||
frame._event_manager.dispatch(target.asEventTarget(), keyup_event.asEvent()) catch |err| {
|
||||
lp.log.err(.app, "press keyup failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
|
||||
@@ -624,33 +624,26 @@ fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8,
|
||||
}, .{ .anchor = target_frame });
|
||||
}
|
||||
|
||||
/// pressKey on the focused element.
|
||||
pub fn triggerKeyDown(frame: *Frame, keydown: *KeyboardEvent, text: ?[]const u8) !bool {
|
||||
const element = focusedElement(frame, keydown) orelse return false;
|
||||
const element = focusedElement(frame) orelse {
|
||||
keydown.asEvent().deinit(frame.page);
|
||||
return false;
|
||||
};
|
||||
return pressKey(frame, element, keydown, text);
|
||||
}
|
||||
|
||||
pub fn triggerKeyUp(frame: *Frame, keyup: *KeyboardEvent) !void {
|
||||
const element = focusedElement(frame, keyup) orelse return;
|
||||
const element = focusedElement(frame) orelse {
|
||||
keyup.asEvent().deinit(frame.page);
|
||||
return;
|
||||
};
|
||||
try frame._event_manager.dispatch(element.asEventTarget(), keyup.asEvent());
|
||||
}
|
||||
|
||||
// `document.activeElement`, so with nothing focused a key still fires on
|
||||
// <body> and Tab's focus navigation can run.
|
||||
fn focusedElement(frame: *Frame, keyboard_event: *KeyboardEvent) ?*Element {
|
||||
const element = frame.window._document.getActiveElement() orelse {
|
||||
keyboard_event.asEvent().deinit(frame.page);
|
||||
return null;
|
||||
};
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
log.debug(.frame, "frame key", .{
|
||||
.url = frame.url,
|
||||
.node = element,
|
||||
.key = keyboard_event._key,
|
||||
.type = frame._type,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
/// Where a key event goes: `document.activeElement`, so with nothing focused
|
||||
/// a key still fires on <body> and Tab's focus navigation can run.
|
||||
pub fn focusedElement(frame: *Frame) ?*Element {
|
||||
return frame.window._document.getActiveElement();
|
||||
}
|
||||
|
||||
/// Dispatches a trusted keydown on `target` then, unless cancelled, types
|
||||
@@ -658,15 +651,23 @@ fn focusedElement(frame: *Frame, keyboard_event: *KeyboardEvent) ?*Element {
|
||||
/// char as its own event, as chromedp does). Returns whether the keydown was
|
||||
/// cancelled.
|
||||
pub fn pressKey(frame: *Frame, target: *Element, keydown: *KeyboardEvent, text: ?[]const u8) !bool {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
log.debug(.frame, "frame keydown", .{
|
||||
.url = frame.url,
|
||||
.node = target,
|
||||
.key = keydown._key,
|
||||
.type = frame._type,
|
||||
});
|
||||
}
|
||||
const event = keydown.asEvent();
|
||||
const t = text orelse return frame._event_manager.dispatchCancelable(target.asEventTarget(), event);
|
||||
|
||||
// dispatch drops the event; keypressFor still needs it.
|
||||
event.acquireRef();
|
||||
defer event.releaseRef(frame.page);
|
||||
|
||||
if (try frame._event_manager.dispatchCancelable(target.asEventTarget(), event)) {
|
||||
return true;
|
||||
}
|
||||
const t = text orelse return false;
|
||||
// logged like a default action's failure, not the key event's
|
||||
typeChar(frame, target, try keypressFor(frame, keydown), t) catch |err| {
|
||||
log.warn(.frame, "frame.keypress", .{ .err = err });
|
||||
@@ -696,7 +697,7 @@ pub fn typeChar(frame: *Frame, target: *Element, keypress: *KeyboardEvent, text:
|
||||
return;
|
||||
}
|
||||
const is_enter = text.len == 1 and (text[0] == '\r' or text[0] == '\n');
|
||||
if (is_enter and enterClicks(target)) {
|
||||
if (is_enter and isButton(target)) {
|
||||
return dispatchKeyboardClick(frame, target);
|
||||
}
|
||||
|
||||
@@ -718,7 +719,6 @@ pub fn typeChar(frame: *Frame, target: *Element, keypress: *KeyboardEvent, text:
|
||||
}
|
||||
}
|
||||
|
||||
/// The keypress mirroring `keydown`'s key and modifiers.
|
||||
fn keypressFor(frame: *Frame, keydown: *const KeyboardEvent) !*KeyboardEvent {
|
||||
return KeyboardEvent.initTrusted(comptime .wrap("keypress"), .{
|
||||
.key = keydown.getKey().asString(),
|
||||
@@ -743,22 +743,17 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void {
|
||||
}
|
||||
|
||||
if (key == .Enter and event.getIsTrusted()) {
|
||||
// A link follows Enter on the keydown; buttons wait for the keypress,
|
||||
// see typeChar.
|
||||
if (target.is(Element.Html.Anchor)) |anchor| {
|
||||
if (anchor.asElement().getAttributeInterned("href") != null) {
|
||||
return dispatchKeyboardClick(frame, anchor.asElement());
|
||||
if (target.is(Element)) |element| {
|
||||
if (enterFollowsLink(element)) {
|
||||
return dispatchKeyboardClick(frame, element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target.is(Element.Html.Input)) |input| {
|
||||
// Don't handle text input for radio/checkbox
|
||||
const input_type = input._input_type;
|
||||
if (input_type == .radio or input_type == .checkbox) {
|
||||
if (!input.acceptsTextEntry()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return editKey(frame, keyboard_event, input, key);
|
||||
}
|
||||
|
||||
@@ -787,11 +782,8 @@ fn editKey(frame: *Frame, keyboard_event: *KeyboardEvent, ctl: anytype, key: Key
|
||||
}
|
||||
|
||||
fn insertInto(frame: *Frame, ctl: anytype, text: []const u8) !void {
|
||||
if (@TypeOf(ctl) == *Element.Html.Input) {
|
||||
const input_type = ctl._input_type;
|
||||
if (input_type == .radio or input_type == .checkbox) {
|
||||
return;
|
||||
}
|
||||
if (!ctl.acceptsTextEntry()) {
|
||||
return;
|
||||
}
|
||||
if (try allowEdit(frame, ctl.asElement(), text, text, "insertText")) {
|
||||
try ctl.innerInsert(text, frame);
|
||||
@@ -881,8 +873,15 @@ fn dispatchKeyboardClick(frame: *Frame, element: *Element) !void {
|
||||
try frame._event_manager.dispatch(element.asEventTarget(), event.asEvent());
|
||||
}
|
||||
|
||||
// elements where Enter's keypress dispatches a click
|
||||
fn enterClicks(element: *Element) bool {
|
||||
// Which elements act on which key, and at which step: a link follows Enter on
|
||||
// the keydown, a button clicks on Enter's keypress (typeChar) and on Space's
|
||||
// keyup, as do checkboxes and radios for Space.
|
||||
fn enterFollowsLink(element: *Element) bool {
|
||||
const html_element = element.is(Element.Html) orelse return false;
|
||||
return html_element._type == .anchor and element.getAttributeInterned("href") != null;
|
||||
}
|
||||
|
||||
fn isButton(element: *Element) bool {
|
||||
const html_element = element.is(Element.Html) orelse return false;
|
||||
if (html_element._type == .button) {
|
||||
return true;
|
||||
@@ -896,19 +895,12 @@ fn enterClicks(element: *Element) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// elements where space on a keyup should dispatch a click-click event
|
||||
fn spaceActivates(element: *Element) bool {
|
||||
const html_element = element.is(Element.Html) orelse return false;
|
||||
if (html_element._type == .button) {
|
||||
if (isButton(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.is(Element.Html.Input)) |input| {
|
||||
return switch (input._input_type) {
|
||||
.button, .submit, .reset, .image, .checkbox, .radio => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
const input = element.is(Element.Html.Input) orelse return false;
|
||||
return input._input_type == .checkbox or input._input_type == .radio;
|
||||
}
|
||||
|
||||
// Sequential focus navigation: move `document.activeElement` to the next (Tab)
|
||||
|
||||
@@ -423,10 +423,7 @@ fn performKeySource(source: js.Object, frame: *Frame) !void {
|
||||
// longer does.
|
||||
setModifier(&frame.page.input_modifiers, key, is_down);
|
||||
|
||||
// Key actions have no explicit target; they go to the focused element,
|
||||
// or the document if nothing is focused. Resolved per action since a
|
||||
// key's default action can move focus.
|
||||
dispatchKey(frame.document._active_element, is_down, key, frame);
|
||||
dispatchKey(is_down, key, frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +508,9 @@ fn setModifier(modifiers: *Modifiers, key: []const u8, pressed: bool) void {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatchKey(focused: ?*Element, is_down: bool, key: []const u8, frame: *Frame) void {
|
||||
// Key actions have no explicit target; they go to the focused element,
|
||||
// resolved per action since a key's default action can move focus.
|
||||
fn dispatchKey(is_down: bool, key: []const u8, frame: *Frame) void {
|
||||
const typ: lp.String = if (is_down) comptime .wrap("keydown") else comptime .wrap("keyup");
|
||||
const modifiers = frame.page.input_modifiers;
|
||||
const event = KeyboardEvent.initTrusted(typ, .{
|
||||
@@ -527,11 +526,10 @@ fn dispatchKey(focused: ?*Element, is_down: bool, key: []const u8, frame: *Frame
|
||||
log.warn(.app, "webdriver key event", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
const el = focused orelse return dispatch(frame.document.asNode().asEventTarget(), event.asEvent(), frame, typ.str());
|
||||
if (!is_down) {
|
||||
return dispatch(el.asEventTarget(), event.asEvent(), frame, typ.str());
|
||||
}
|
||||
_ = Frame.user_input.pressKey(frame, el, event, Frame.user_input.textForKey(event)) catch |err| {
|
||||
(if (is_down)
|
||||
Frame.user_input.triggerKeyDown(frame, event, Frame.user_input.textForKey(event))
|
||||
else
|
||||
Frame.user_input.triggerKeyUp(frame, event)) catch |err| {
|
||||
log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ.str() });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -627,6 +627,7 @@ const entry = text_entry.TextEntry(Input);
|
||||
|
||||
pub const select = entry.select;
|
||||
pub const innerInsert = entry.innerInsert;
|
||||
pub const acceptsTextEntry = entry.acceptsTextEntry;
|
||||
pub const innerDelete = entry.innerDelete;
|
||||
pub const moveCaret = entry.moveCaret;
|
||||
pub const CaretMove = entry.CaretMove;
|
||||
|
||||
@@ -140,6 +140,7 @@ const entry = text_entry.TextEntry(TextArea);
|
||||
|
||||
pub const select = entry.select;
|
||||
pub const innerInsert = entry.innerInsert;
|
||||
pub const acceptsTextEntry = entry.acceptsTextEntry;
|
||||
pub const innerDelete = entry.innerDelete;
|
||||
pub const moveCaret = entry.moveCaret;
|
||||
pub const CaretMove = entry.CaretMove;
|
||||
|
||||
@@ -29,6 +29,18 @@ const InputEvent = @import("../event/InputEvent.zig");
|
||||
|
||||
pub fn TextEntry(comptime T: type) type {
|
||||
return struct {
|
||||
/// Whether typing edits the control's value. Checkbox and radio share
|
||||
/// Input's value machinery but no text goes into them.
|
||||
pub fn acceptsTextEntry(self: *const T) bool {
|
||||
if (!@hasField(T, "_input_type")) {
|
||||
return true;
|
||||
}
|
||||
return switch (self._input_type) {
|
||||
.checkbox, .radio => false,
|
||||
else => true,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn select(self: *T, frame: *Frame) !void {
|
||||
const len: u32 = @intCast(self.getValue().len);
|
||||
try setSelectionRange(self, 0, len, null, frame);
|
||||
|
||||
@@ -56,6 +56,9 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void {
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
|
||||
// rawKeyDown is a Chrome-internal event type not used for JS dispatch
|
||||
if (params.type == .rawKeyDown) return;
|
||||
|
||||
const bc = cmd.browser_context orelse return;
|
||||
const frame = bc.mainFrame() orelse return;
|
||||
|
||||
@@ -73,21 +76,23 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void {
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
// rawKeyDown is a Chrome-internal event type not used for JS dispatch
|
||||
.rawKeyDown => {},
|
||||
.rawKeyDown => unreachable,
|
||||
.keyDown => {
|
||||
const event = try KeyboardEvent.initTrusted(comptime .wrap("keydown"), opts, frame);
|
||||
const prevented = try Frame.user_input.triggerKeyDown(frame, event, text);
|
||||
bc.suppress_next_char = prevented and text == null;
|
||||
},
|
||||
.keyUp => try Frame.user_input.triggerKeyUp(frame, try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame)),
|
||||
.keyUp => {
|
||||
const event = try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame);
|
||||
try Frame.user_input.triggerKeyUp(frame, event);
|
||||
},
|
||||
.char => {
|
||||
const t = text orelse return;
|
||||
if (bc.suppress_next_char) {
|
||||
bc.suppress_next_char = false;
|
||||
return;
|
||||
}
|
||||
const target = frame.window._document.getActiveElement() orelse return;
|
||||
const target = Frame.user_input.focusedElement(frame) orelse return;
|
||||
const event = try KeyboardEvent.initTrusted(comptime .wrap("keypress"), opts, frame);
|
||||
try Frame.user_input.typeChar(frame, target, event, t);
|
||||
},
|
||||
@@ -948,6 +953,27 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
_ = try ls.local.compileAndRun(
|
||||
\\document.body.insertAdjacentHTML('beforeend', '<form id=f>' +
|
||||
\\ '<input id=text><input id=check type=checkbox><input id=submit type=submit>' +
|
||||
\\ '<button id=button>go</button><input id=ibutton type=button><input id=reset type=reset></form>');
|
||||
\\const form = document.getElementById('f');
|
||||
\\form.addEventListener('submit', (e) => {
|
||||
\\ e.preventDefault();
|
||||
\\ window.events.push('submit');
|
||||
\\});
|
||||
\\// Only the focused control's keypress and click are recorded.
|
||||
\\for (const t of ['keypress', 'click']) {
|
||||
\\ form.addEventListener(t, (e) => {
|
||||
\\ if (e.target === document.activeElement) window.events.push(t);
|
||||
\\ }, true);
|
||||
\\}
|
||||
\\window.arm = (id) => {
|
||||
\\ window.events = [];
|
||||
\\ document.getElementById(id).focus();
|
||||
\\};
|
||||
, null);
|
||||
|
||||
const cases = [_]struct { id: []const u8, expect: []const u8 }{
|
||||
.{ .id = "text", .expect = "keypress submit" },
|
||||
.{ .id = "check", .expect = "keypress submit" },
|
||||
@@ -959,21 +985,8 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
|
||||
|
||||
var id: u32 = 1;
|
||||
for (cases) |c| {
|
||||
var buf: [1024]u8 = undefined;
|
||||
_ = try ls.local.compileAndRun(try std.fmt.bufPrint(&buf,
|
||||
\\document.body.insertAdjacentHTML('beforeend', '<form id=f>' +
|
||||
\\ '<input id=text><input id=check type=checkbox><input id=submit type=submit>' +
|
||||
\\ '<button id=button>go</button><input id=ibutton type=button><input id=reset type=reset></form>');
|
||||
\\window.events = [];
|
||||
\\document.getElementById('f').addEventListener('submit', (e) => {{
|
||||
\\ e.preventDefault();
|
||||
\\ window.events.push('submit');
|
||||
\\}});
|
||||
\\var el = document.getElementById('{s}');
|
||||
\\el.addEventListener('keypress', () => window.events.push('keypress'));
|
||||
\\el.addEventListener('click', () => window.events.push('click'));
|
||||
\\el.focus();
|
||||
, .{c.id}), null);
|
||||
var buf: [32]u8 = undefined;
|
||||
_ = try ls.local.compileAndRun(try std.fmt.bufPrint(&buf, "arm('{s}')", .{c.id}), null);
|
||||
|
||||
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "Enter", .code = "Enter" } });
|
||||
try ctx.expectSentResult(null, .{ .id = id });
|
||||
@@ -987,7 +1000,5 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
|
||||
|
||||
const got = try (try ls.local.compileAndRun("window.events.join(' ')", null)).toStringSlice();
|
||||
try testing.expectEqualSlices(u8, c.expect, got);
|
||||
|
||||
_ = try ls.local.compileAndRun("document.getElementById('f').remove()", null);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user