mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-02 01:15:00 -04:00
Merge pull request #3507 from R4m1r0qu41/refactor/shared-click-dispatch-v2
Share pointer/mouse click dispatch across click paths
This commit is contained in:
8 files changed
+619
-100
No files matched your search
@@ -186,6 +186,10 @@ input_modifiers: if (lp.build_config.wpt_extensions) @import("frame/user_input.z
|
||||
// The element the synthetic pointer is currently over
|
||||
input_hover_target: ?*Element = null,
|
||||
|
||||
// Per-gesture button state for the synthetic mouse pointer; see
|
||||
// user_input.PointerButtons.
|
||||
input_pointer: @import("frame/user_input.zig").PointerButtons = .{},
|
||||
|
||||
// Popup Frames opened by window.open. They are top-level browsing contexts
|
||||
// (parent == null, no iframe element) but share this Page's factory, arena,
|
||||
// and identity map.
|
||||
|
||||
+4
-55
@@ -22,7 +22,6 @@ const DOMNode = @import("webapi/Node.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
const PointerEvent = @import("webapi/event/PointerEvent.zig");
|
||||
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
|
||||
const Frame = @import("Frame.zig");
|
||||
const Session = @import("Session.zig");
|
||||
@@ -39,41 +38,6 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
|
||||
};
|
||||
}
|
||||
|
||||
fn dispatch(el: *Element, event: *Event, comptime typ: []const u8, frame: *Frame) !bool {
|
||||
return frame._event_manager.dispatchCancelable(el.asEventTarget(), event) catch |err| {
|
||||
lp.log.err(.app, "click " ++ typ ++ " failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
fn dispatchPointer(el: *Element, comptime typ: []const u8, buttons: u16, detail: u32, frame: *Frame) !bool {
|
||||
const event: *PointerEvent = try .initTrusted(typ, .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.buttons = buttons,
|
||||
.detail = detail,
|
||||
.pointerId = 1,
|
||||
.pointerType = "mouse",
|
||||
.isPrimary = true,
|
||||
.pressure = if (buttons != 0) 0.5 else 0.0,
|
||||
}, frame);
|
||||
return dispatch(el, event.asEvent(), typ, frame);
|
||||
}
|
||||
|
||||
fn dispatchMouse(el: *Element, comptime typ: []const u8, buttons: u16, frame: *Frame) !bool {
|
||||
const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.buttons = buttons,
|
||||
.detail = 1,
|
||||
}, frame);
|
||||
return dispatch(el, event.asEvent(), typ, frame);
|
||||
}
|
||||
|
||||
/// The trusted primary-button gesture a real user click produces; widgets key
|
||||
/// off pointerdown/mousedown, not click alone.
|
||||
pub fn click(node: *DOMNode, frame: *Frame) !void {
|
||||
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
@@ -83,25 +47,10 @@ pub fn click(node: *DOMNode, frame: *Frame) !void {
|
||||
|
||||
Frame.user_input.updateHoverTarget(frame, el, .{ .with_pointer = true });
|
||||
|
||||
// preventDefault() on pointerdown suppresses both compatibility mouse
|
||||
// events (mousedown and mouseup) for the rest of this gesture; click
|
||||
// still fires.
|
||||
const suppress_mouse = try dispatchPointer(el, "pointerdown", 1, 0, frame);
|
||||
if (!suppress_mouse) {
|
||||
const suppress_focus = try dispatchMouse(el, "mousedown", 1, frame);
|
||||
if (!suppress_focus) {
|
||||
Frame.user_input.focusForMouseDown(frame, el) catch |err| {
|
||||
lp.log.warn(.app, "click mousedown focus", .{ .err = err });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_ = try dispatchPointer(el, "pointerup", 0, 0, frame);
|
||||
if (!suppress_mouse) {
|
||||
_ = try dispatchMouse(el, "mouseup", 0, frame);
|
||||
}
|
||||
|
||||
_ = try dispatchPointer(el, "click", 0, 1, frame);
|
||||
Frame.user_input.triggerClick(frame, el, .{}) catch |err| {
|
||||
lp.log.err(.app, "click failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn hover(node: *DOMNode, frame: *Frame) !void {
|
||||
|
||||
@@ -180,25 +180,161 @@ fn dispatchBoundaryEvent(frame: *Frame, target: *Element, comptime mouse_typ: []
|
||||
};
|
||||
}
|
||||
|
||||
/// Dispatch a single trusted mouse event of the given type on `target`, carrying
|
||||
/// the pressed button and pointer position. `detail` is the click count (used for
|
||||
/// click/dblclick); 0 for events where it does not apply. Reports whether the
|
||||
/// event was cancelled via preventDefault().
|
||||
fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, x: f64, y: f64, button: i32, detail: u32) !bool {
|
||||
/// Event fields for the trusted mouse/pointer dispatchers, bundled so `button`
|
||||
/// (which button changed) and `buttons` (the held mask) can't be transposed.
|
||||
const PointerInput = struct {
|
||||
x: f64,
|
||||
y: f64,
|
||||
button: i32 = mouse_button.main,
|
||||
buttons: u16 = 0,
|
||||
detail: u32 = 0,
|
||||
modifiers: Modifiers = .{},
|
||||
};
|
||||
|
||||
/// Dispatch a trusted mouse event; returns whether preventDefault() cancelled it.
|
||||
fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, in: PointerInput) !bool {
|
||||
const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.clientX = x,
|
||||
.clientY = y,
|
||||
.button = button,
|
||||
.detail = detail,
|
||||
.clientX = in.x,
|
||||
.clientY = in.y,
|
||||
.button = in.button,
|
||||
.buttons = in.buttons,
|
||||
.detail = in.detail,
|
||||
.ctrlKey = in.modifiers.ctrl,
|
||||
.shiftKey = in.modifiers.shift,
|
||||
.altKey = in.modifiers.alt,
|
||||
.metaKey = in.modifiers.meta,
|
||||
}, frame);
|
||||
return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent());
|
||||
}
|
||||
|
||||
pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
|
||||
const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return;
|
||||
/// Dispatch a trusted pointer event (always mouse-sourced: pointerType,
|
||||
/// pointerId, isPrimary fixed); returns whether preventDefault() cancelled it.
|
||||
fn dispatchPointerEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, in: PointerInput) !bool {
|
||||
const event: *PointerEvent = try .initTrusted(typ, .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.clientX = in.x,
|
||||
.clientY = in.y,
|
||||
.button = in.button,
|
||||
.buttons = in.buttons,
|
||||
.detail = in.detail,
|
||||
.pointerId = 1,
|
||||
.pointerType = "mouse",
|
||||
.isPrimary = true,
|
||||
.pressure = if (in.buttons != 0) 0.5 else 0.0,
|
||||
.ctrlKey = in.modifiers.ctrl,
|
||||
.shiftKey = in.modifiers.shift,
|
||||
.altKey = in.modifiers.alt,
|
||||
.metaKey = in.modifiers.meta,
|
||||
}, frame);
|
||||
return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent());
|
||||
}
|
||||
|
||||
/// MouseEvent/PointerEvent.buttons bitmask for a MouseEvent.button value.
|
||||
/// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
||||
pub fn buttonsBitmask(button: i32) u16 {
|
||||
return switch (button) {
|
||||
mouse_button.main => 1,
|
||||
mouse_button.secondary => 2,
|
||||
mouse_button.auxiliary => 4,
|
||||
mouse_button.fourth => 8,
|
||||
mouse_button.fifth => 16,
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Held-button mask and suppression flag for the single synthetic mouse
|
||||
/// pointer, tracked across the split press/release messages of one gesture so
|
||||
/// a chord is told apart from a new gesture.
|
||||
pub const PointerButtons = struct {
|
||||
/// Mask of buttons currently held.
|
||||
held: u16 = 0,
|
||||
/// Whether the gesture's opening pointerdown suppressed the compat mouse
|
||||
/// events; held for the whole gesture so each split message reads it here.
|
||||
mousedown_suppressed: bool = false,
|
||||
|
||||
/// `starts_gesture` is false for a chorded press (another button held).
|
||||
pub fn press(self: *PointerButtons, button: i32) struct { starts_gesture: bool, held: u16 } {
|
||||
const bit = buttonsBitmask(button);
|
||||
const starts_gesture = self.held & ~bit == 0;
|
||||
self.held |= bit;
|
||||
return .{ .starts_gesture = starts_gesture, .held = self.held };
|
||||
}
|
||||
|
||||
/// `ends_gesture` is the last held button releasing, which clears the
|
||||
/// suppression flag; `was_suppressed` is that flag for this gesture.
|
||||
pub fn release(self: *PointerButtons, button: i32) struct { ends_gesture: bool, held: u16, was_suppressed: bool } {
|
||||
const was_suppressed = self.mousedown_suppressed;
|
||||
self.held &= ~buttonsBitmask(button);
|
||||
const ends_gesture = self.held == 0;
|
||||
if (ends_gesture) self.mousedown_suppressed = false;
|
||||
return .{ .ends_gesture = ends_gesture, .held = self.held, .was_suppressed = was_suppressed };
|
||||
}
|
||||
|
||||
/// Discards an in-progress gesture (a press/release that hit no element).
|
||||
pub fn reset(self: *PointerButtons) void {
|
||||
self.* = .{};
|
||||
}
|
||||
};
|
||||
|
||||
const PressResult = struct {
|
||||
/// pointerdown's preventDefault() suppressed the compat mousedown here and
|
||||
/// the paired mouseup on release.
|
||||
suppress_mouse: bool,
|
||||
/// mousedown's focus default action is suppressed (always when
|
||||
/// suppress_mouse is).
|
||||
suppress_focus: bool,
|
||||
};
|
||||
|
||||
/// pointerdown, then mousedown unless pointerdown was cancelled. `in.detail`
|
||||
/// applies to mousedown only.
|
||||
fn dispatchPointerPress(frame: *Frame, target: *Element, in: PointerInput) !PressResult {
|
||||
var pointer = in;
|
||||
pointer.detail = 0;
|
||||
const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", pointer);
|
||||
if (suppress_mouse) {
|
||||
return .{ .suppress_mouse = true, .suppress_focus = true };
|
||||
}
|
||||
const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", in);
|
||||
return .{ .suppress_mouse = false, .suppress_focus = suppress_focus };
|
||||
}
|
||||
|
||||
/// pointerup, then mouseup unless the gesture's pointerdown was cancelled.
|
||||
/// `in.detail` applies to mouseup only.
|
||||
fn dispatchPointerRelease(frame: *Frame, target: *Element, in: PointerInput, suppress_mouse: bool) !void {
|
||||
var pointer = in;
|
||||
pointer.detail = 0;
|
||||
_ = try dispatchPointerEventOn(frame, target, "pointerup", pointer);
|
||||
if (suppress_mouse == false) {
|
||||
_ = try dispatchMouseEventOn(frame, target, "mouseup", in);
|
||||
}
|
||||
}
|
||||
|
||||
/// The trusted primary-button gesture a real user click produces; widgets key
|
||||
/// off pointerdown/mousedown, not click alone. A focus failure is logged, not
|
||||
/// returned.
|
||||
pub fn triggerClick(frame: *Frame, target: *Element, modifiers: Modifiers) !void {
|
||||
const press = try dispatchPointerPress(frame, target, .{ .x = 0, .y = 0, .buttons = 1, .detail = 1, .modifiers = modifiers });
|
||||
if (press.suppress_focus == false) {
|
||||
focusForMouseDown(frame, target) catch |err| log.warn(.app, "click mousedown focus", .{ .err = err });
|
||||
}
|
||||
|
||||
const up: PointerInput = .{ .x = 0, .y = 0, .detail = 1, .modifiers = modifiers };
|
||||
try dispatchPointerRelease(frame, target, up, press.suppress_mouse);
|
||||
// click is a PointerEvent, matching HTMLElement.click().
|
||||
_ = try dispatchPointerEventOn(frame, target, "click", up);
|
||||
}
|
||||
|
||||
pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count: i32) !void {
|
||||
const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse {
|
||||
// Don't leave a prior gesture's state for the next message to misread.
|
||||
frame.page.input_pointer.reset();
|
||||
return;
|
||||
};
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
log.debug(.frame, "frame mouse press", .{
|
||||
.url = frame.url,
|
||||
@@ -209,9 +345,31 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
|
||||
.type = frame._type,
|
||||
});
|
||||
}
|
||||
const suppressed = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0);
|
||||
if (!suppressed) {
|
||||
try focusForMouseDown(frame, target);
|
||||
|
||||
const gesture = frame.page.input_pointer.press(button);
|
||||
// clickCount 0 (omitted) stays 0, not forced to 1: Chrome and Firefox
|
||||
// both fire mousedown with detail 0 in that case.
|
||||
const detail: u32 = if (click_count > 0) @intCast(click_count) else 0;
|
||||
const in: PointerInput = .{ .x = x, .y = y, .button = button, .buttons = gesture.held, .detail = detail };
|
||||
|
||||
if (gesture.starts_gesture) {
|
||||
// Stash the pointerdown outcome before the fallible focus call: the
|
||||
// release half is a separate message and can't observe it otherwise.
|
||||
const press = try dispatchPointerPress(frame, target, in);
|
||||
frame.page.input_pointer.mousedown_suppressed = press.suppress_mouse;
|
||||
if (press.suppress_focus == false) {
|
||||
try focusForMouseDown(frame, target);
|
||||
}
|
||||
} else {
|
||||
// A chorded press is a buttons-mask change (pointermove), not a second
|
||||
// pointerdown: https://www.w3.org/TR/pointerevents3/#chorded-button-interactions
|
||||
_ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = gesture.held });
|
||||
if (frame.page.input_pointer.mousedown_suppressed == false) {
|
||||
const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", in);
|
||||
if (suppress_focus == false) {
|
||||
try focusForMouseDown(frame, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +398,13 @@ pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void {
|
||||
}
|
||||
|
||||
pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_count: i32) !void {
|
||||
// Consume the state before any early return, so a release that misses
|
||||
// every element can't leave it for the next message to misread.
|
||||
const gesture = frame.page.input_pointer.release(button);
|
||||
const remaining = gesture.held;
|
||||
const ends_gesture = gesture.ends_gesture;
|
||||
const was_suppressed = gesture.was_suppressed;
|
||||
|
||||
const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return;
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
log.debug(.frame, "frame mouse release", .{
|
||||
@@ -254,19 +419,28 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou
|
||||
|
||||
const detail: u32 = if (click_count > 0) @intCast(click_count) else 1;
|
||||
|
||||
_ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, detail);
|
||||
if (ends_gesture) {
|
||||
try dispatchPointerRelease(frame, target, .{ .x = x, .y = y, .button = button, .detail = detail }, was_suppressed);
|
||||
} else {
|
||||
// A chorded release (another button still held) is a buttons-mask
|
||||
// change, not pointerup.
|
||||
_ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = remaining });
|
||||
if (!was_suppressed) {
|
||||
_ = try dispatchMouseEventOn(frame, target, "mouseup", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail });
|
||||
}
|
||||
}
|
||||
|
||||
// After mouseup, the activation event depends on the button.
|
||||
switch (button) {
|
||||
mouse_button.main => {
|
||||
_ = try dispatchMouseEventOn(frame, target, "click", x, y, button, detail);
|
||||
_ = try dispatchPointerEventOn(frame, target, "click", .{ .x = x, .y = y, .buttons = remaining, .detail = detail });
|
||||
// A second click in quick succession also fires dblclick.
|
||||
if (click_count == 2) {
|
||||
_ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, detail);
|
||||
_ = try dispatchMouseEventOn(frame, target, "dblclick", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail });
|
||||
}
|
||||
},
|
||||
mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", x, y, button, detail),
|
||||
mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, detail),
|
||||
mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail }),
|
||||
mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail }),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<input id="chk" type="checkbox">
|
||||
<input id="rad" type="radio" name="group1">
|
||||
<button id="btnPreventDefault">Prevent Default</button>
|
||||
<button id="btnChord">Chord</button>
|
||||
<button id="btnDisabled" disabled>Disabled</button>
|
||||
<div id="focusTarget" tabindex="0" onclick="window.focusTargetFocused = document.activeElement === this;">Focus me</div>
|
||||
<div id="plain" onclick="window.plainBlurred = document.activeElement === document.body;">Not focusable</div>
|
||||
@@ -34,7 +35,7 @@
|
||||
window.seq = [];
|
||||
const btn = document.getElementById('btn');
|
||||
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
btn.addEventListener(t, function(e) { window.seq.push([e.type, e.button, e.buttons, e.pointerType, e.isTrusted].join(':')); });
|
||||
btn.addEventListener(t, function(e) { window.seq.push([e.type, e.button, e.buttons, e.detail, e.pointerType, e.isTrusted].join(':')); });
|
||||
}
|
||||
|
||||
// Regression coverage for pointerdown's preventDefault() suppressing
|
||||
@@ -46,6 +47,18 @@
|
||||
btnPreventDefault.addEventListener(t, function(e) { window.seqPrevented.push(e.type); });
|
||||
}
|
||||
|
||||
// Regression coverage for a multi-button chord: the left button's
|
||||
// pointerdown is cancelled, then right is pressed while left is
|
||||
// still held. Only the left press/release should be full
|
||||
// pointerdown/pointerup; the chorded right press/release in between
|
||||
// must report as pointermove, not a second pointerdown/pointerup.
|
||||
window.seqChord = [];
|
||||
const btnChord = document.getElementById('btnChord');
|
||||
btnChord.addEventListener('pointerdown', function(e) { if (e.button === 0) e.preventDefault(); });
|
||||
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'pointermove', 'click', 'auxclick', 'contextmenu']) {
|
||||
btnChord.addEventListener(t, function(e) { window.seqChord.push([e.type, e.buttons].join(':')); });
|
||||
}
|
||||
|
||||
window.disabledEvents = [];
|
||||
const btnDisabled = document.getElementById('btnDisabled');
|
||||
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
|
||||
@@ -67,11 +67,10 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void {
|
||||
}
|
||||
}
|
||||
|
||||
dispatchPointer(element, "pointerdown", 0, 1, frame);
|
||||
_ = dispatchMouse(element, "mousedown", 0, 1, 1, frame);
|
||||
dispatchPointer(element, "pointerup", 0, 0, frame);
|
||||
_ = dispatchMouse(element, "mouseup", 0, 0, 1, frame);
|
||||
_ = dispatchMouse(element, "click", 0, 0, 1, frame);
|
||||
// A dispatch error must never reject the testdriver command.
|
||||
Frame.user_input.triggerClick(frame, element, frame._page.input_modifiers) catch |err| {
|
||||
log.warn(.app, "webdriver click", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
const WebDriverCookie = struct {
|
||||
@@ -281,18 +280,18 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
|
||||
const el = target orelse continue;
|
||||
const button = readI32(action, "button", 0);
|
||||
pressed = true;
|
||||
pressed_mask = buttonsMask(button);
|
||||
pressed_mask = Frame.user_input.buttonsBitmask(button);
|
||||
down_target = el;
|
||||
if (last_click_target == el and last_click_button == button) {
|
||||
click_count += 1;
|
||||
} else {
|
||||
click_count = 1;
|
||||
}
|
||||
dispatchPointer(el, "pointerdown", button, buttonsMask(button), frame);
|
||||
dispatchPointer(el, "pointerdown", button, Frame.user_input.buttonsBitmask(button), frame);
|
||||
if (is_touch) {
|
||||
dispatchTouch(el, "touchstart", frame);
|
||||
} else {
|
||||
const suppressed = dispatchMouse(el, "mousedown", button, buttonsMask(button), click_count, frame);
|
||||
const suppressed = dispatchMouse(el, "mousedown", button, Frame.user_input.buttonsBitmask(button), click_count, frame);
|
||||
if (!suppressed) {
|
||||
Frame.user_input.focusForMouseDown(frame, el) catch |err| {
|
||||
log.warn(.app, "webdriver mousedown focus", .{ .err = err });
|
||||
@@ -331,19 +330,6 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// The `buttons` bitmask bit for a WebDriver button number: the flag order does
|
||||
// not follow the button numbering (left=1, right=2, middle=4).
|
||||
fn buttonsMask(button: i32) u16 {
|
||||
return switch (button) {
|
||||
0 => 1,
|
||||
1 => 4,
|
||||
2 => 2,
|
||||
3 => 8,
|
||||
4 => 16,
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
|
||||
// A click whose mousedown and mouseup landed on different elements fires at
|
||||
// their nearest common inclusive ancestor element.
|
||||
fn commonClickTarget(down: *Element, up: *Element) *Element {
|
||||
|
||||
+2
-2
@@ -1292,8 +1292,8 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
const result = try ls.local.exec(
|
||||
\\ JSON.stringify(window.seq) === JSON.stringify([
|
||||
\\ 'pointerdown:0:1:mouse:true', 'mousedown:0:1::true',
|
||||
\\ 'pointerup:0:0:mouse:true', 'mouseup:0:0::true', 'click:0:0:mouse:true'
|
||||
\\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
|
||||
\\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:1::true', 'click:0:0:1:mouse:true'
|
||||
\\ ]) &&
|
||||
\\ JSON.stringify(window.seqPrevented) === JSON.stringify(['pointerdown', 'pointerup', 'click']) &&
|
||||
\\ JSON.stringify(window.disabledEvents) === '[]' &&
|
||||
|
||||
@@ -570,7 +570,7 @@ fn dispatch(bidi: *BiDi, frame: *Frame, source: *Source, action: *const Action)
|
||||
pointer.last_click_x = pointer.x;
|
||||
pointer.last_click_y = pointer.y;
|
||||
|
||||
try user_input.triggerMousePress(frame, pointer.x, pointer.y, button);
|
||||
try user_input.triggerMousePress(frame, pointer.x, pointer.y, button, pointer.click_count);
|
||||
},
|
||||
.pointer_up => |button| {
|
||||
const pointer = &source.pointer;
|
||||
|
||||
@@ -125,7 +125,7 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void {
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
.mousePressed => try Frame.user_input.triggerMousePress(frame, params.x, params.y, button),
|
||||
.mousePressed => try Frame.user_input.triggerMousePress(frame, params.x, params.y, button, params.clickCount),
|
||||
.mouseReleased => try Frame.user_input.triggerMouseRelease(frame, params.x, params.y, button, params.clickCount),
|
||||
.mouseMoved => try Frame.user_input.triggerMouseMove(frame, params.x, params.y),
|
||||
.mouseWheel => try Frame.user_input.triggerMouseWheel(frame, params.x, params.y, params.deltaX, params.deltaY),
|
||||
@@ -596,6 +596,399 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// A CDP press/release pair fires the full pointer/mouse sequence, with
|
||||
// mousedown carrying the message's clickCount as its detail (matches Chrome).
|
||||
test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full pointer/mouse sequence" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seq) === JSON.stringify([
|
||||
\\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
|
||||
\\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:1::true', 'click:0:0:1:mouse:true'
|
||||
\\])
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// clickCount 0 (omitted) is preserved as mousedown's detail, not forced to 1:
|
||||
// Chrome and Firefox both fire detail 0 there, distinct from a detail-1 click.
|
||||
test "cdp.input: dispatchMouseEvent mousePressed's mousedown detail matches the message's clickCount" {
|
||||
const cases = .{
|
||||
.{ .click_count = 0, .expect_detail = 0 },
|
||||
.{ .click_count = 1, .expect_detail = 1 },
|
||||
.{ .click_count = 2, .expect_detail = 2 },
|
||||
};
|
||||
inline for (cases) |case| {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = case.click_count },
|
||||
});
|
||||
|
||||
const expected = std.fmt.comptimePrint("'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:{d}::true'", .{case.expect_detail});
|
||||
const script = std.fmt.comptimePrint(
|
||||
"JSON.stringify(window.seq) === JSON.stringify([{s}])",
|
||||
.{expected},
|
||||
);
|
||||
const result = try ls.local.compileAndRun(script, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
}
|
||||
|
||||
// clickCount reaches the release half too: a clickCount-2 pair carries
|
||||
// detail 2 on mousedown/mouseup/click and fires dblclick.
|
||||
test "cdp.input: clickCount 2 on press and release puts detail 2 on mousedown, mouseup, click and fires dblclick" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
_ = try ls.local.compileAndRun(
|
||||
\\window.sawDbl = false;
|
||||
\\document.getElementById('btn').addEventListener('dblclick', () => { window.sawDbl = true; });
|
||||
, null);
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 2 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 2 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seq) === JSON.stringify([
|
||||
\\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:2::true',
|
||||
\\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:2::true', 'click:0:0:2:mouse:true'
|
||||
\\]) && window.sawDbl === true
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// clickCount must reach the chorded-press mousedown, not only the first press.
|
||||
test "cdp.input: a chorded mousedown carries the press message's clickCount as its detail" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 2 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seq) === JSON.stringify([
|
||||
\\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
|
||||
\\ 'mousedown:2:3:2::true'
|
||||
\\])
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// A pointerdown cancelled on the press message must still suppress mouseup on
|
||||
// the separate release message, with no state carried by the caller.
|
||||
test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across the split mousePressed/mouseReleased calls" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnPreventDefault').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnPreventDefault').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seqPrevented) === JSON.stringify(['pointerdown', 'pointerup', 'click'])
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// Asserts only the pointer events: pointerdown/pointerup fire at the mask's
|
||||
// 0/nonzero transitions and a mid-gesture button change is a pointermove (the
|
||||
// activation order below is a pre-existing, non-spec deviation from Chrome).
|
||||
test "cdp.input: a mouse chord fires pointermove for the mid-gesture button change, not a second pointerdown/pointerup" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seqChord) === JSON.stringify([
|
||||
\\ 'pointerdown:1', 'pointermove:3', 'pointermove:1', 'contextmenu:1', 'pointerup:0', 'click:0'
|
||||
\\])
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
// A primary release mid-chord reports the still-held mask on its click, not 0
|
||||
// (confirmed against Chrome and Firefox).
|
||||
test "cdp.input: a primary click fired mid-chord carries the still-held buttons mask" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
|
||||
});
|
||||
// Left releases first this time, while right is still held.
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\JSON.stringify(window.seqChord) === JSON.stringify([
|
||||
\\ 'pointerdown:1', 'pointermove:3', 'pointermove:2', 'click:2', 'pointerup:0', 'contextmenu:0'
|
||||
\\])
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
test "cdp.input: chorded mousedown focuses its target unless pointerdown or mousedown was cancelled" {
|
||||
inline for (.{ "none", "mousedown", "pointerdown" }) |cancel| {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
_ = try ls.local.compileAndRun("window.cancelAt = '" ++ cancel ++ "';" ++
|
||||
\\document.getElementById('inp').focus();
|
||||
\\document.getElementById('inp').addEventListener('pointerdown', e => {
|
||||
\\ if (window.cancelAt === 'pointerdown') e.preventDefault();
|
||||
\\});
|
||||
\\window.chordMouseDowns = 0;
|
||||
\\document.getElementById('keyTarget').addEventListener('mousedown', e => {
|
||||
\\ window.chordMouseDowns++;
|
||||
\\ if (window.cancelAt === 'mousedown') e.preventDefault();
|
||||
\\});
|
||||
, null);
|
||||
|
||||
const first_x = try (try ls.local.compileAndRun("document.getElementById('inp').getBoundingClientRect().x", null)).toF64();
|
||||
const first_y = try (try ls.local.compileAndRun("document.getElementById('inp').getBoundingClientRect().y", null)).toF64();
|
||||
const second_x = try (try ls.local.compileAndRun("document.getElementById('keyTarget').getBoundingClientRect().x", null)).toF64();
|
||||
const second_y = try (try ls.local.compileAndRun("document.getElementById('keyTarget').getBoundingClientRect().y", null)).toF64();
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = first_x, .y = first_y, .button = "left" },
|
||||
});
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mousePressed", .x = second_x, .y = second_y, .button = "right" },
|
||||
});
|
||||
|
||||
// Check before any release/click activation can change focus.
|
||||
const result = try ls.local.compileAndRun(
|
||||
\\document.activeElement.id === (window.cancelAt === 'none' ? 'keyTarget' : 'inp') &&
|
||||
\\window.chordMouseDowns === (window.cancelAt === 'pointerdown' ? 0 : 1)
|
||||
, null);
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
Reference in new issue
Block a user