diff --git a/src/browser/Viewport.zig b/src/browser/Viewport.zig index 7537d7900..959021ebc 100644 --- a/src/browser/Viewport.zig +++ b/src/browser/Viewport.zig @@ -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, diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 39731b437..ff45d0215 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -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; }; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index e838c2beb..8b78dccf6 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -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 -//
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 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) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index e98b0acf9..32861fd91 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -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() }); }; } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 2ae514394..5580069e0 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -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; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index b9e63f394..a4aa87934 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -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; diff --git a/src/browser/webapi/element/text_entry.zig b/src/browser/webapi/element/text_entry.zig index b5b31a704..ca68facd6 100644 --- a/src/browser/webapi/element/text_entry.zig +++ b/src/browser/webapi/element/text_entry.zig @@ -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); diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 7dbb790fc..06d577cb3 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -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', ''); + \\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', ''); - \\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); } }