дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(ui): deliver the click of a tap WebKit reorders on macOS (#1072)
On macOS, while an editable element has focus and a third-party input method (reproduced with WeChat's WeType) is active, WebKit can deliver a tap as pointerup, mouseup, pointerdown, mousedown (WebKit bug 219670). The tap's click is lost, and because the engine still considers the button down, the next tap starts with a bare mousedown and no pointerdown. Taps on tree rows, tabs and tool window buttons needed a second tap. A press stamped no later than the pointerup just before it is a reordered tap: the shim finishes it with a synthetic pointerup, mouseup and click (and dblclick for a double) after the page's handlers ran, and dispatches the skipped pointerdown when a primary mouse press arrives with no pointer down. The next tap's own click is kept. Installed on macOS only; untrusted events and other pointer types are ignored. Verified in a WKWebView with WeType, alternating taps on two targets: main 19/20 taps clicked their target, with this change 20/20, every press ran the pointerdown handlers and setPointerCapture worked; with the ABC layout the shim dispatches nothing. Refs #760, #724 Co-authored-by: ntbang0901 <[email protected]>
Цей коміт міститься в:
1 джерело
29c54c243a
коміт
38ac62f1a4
3 files changed
+253
No files matched your search
@@ -15,6 +15,8 @@ import {
|
||||
import { auxiliaryWindow } from "./platform/auxWindow";
|
||||
import { installRepeatedDoubleClick } from "./platform/doubleClick";
|
||||
import { installErrorLogging } from "./platform/errorLog";
|
||||
import { guessOs } from "./platform/platformInfo";
|
||||
import { installReorderedTapClick } from "./platform/reordered-tap-click";
|
||||
import { installKeyboardDispatcher } from "./platform/keymap";
|
||||
import { installPointerFocus } from "./platform/pointerFocus";
|
||||
import { isTauri } from "./platform/ipc";
|
||||
@@ -30,6 +32,7 @@ if (import.meta.env.DEV && isTauri()) {
|
||||
}
|
||||
|
||||
installRepeatedDoubleClick();
|
||||
if (guessOs() === "macos") installReorderedTapClick();
|
||||
installWindowDrag();
|
||||
installPointerFocus();
|
||||
installErrorLogging();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installReorderedTapClick } from "./reordered-tap-click";
|
||||
|
||||
/** An event as the engine would deliver it: native `timeStamp`, mouse pointer. */
|
||||
function ev(type: string, timeStamp: number, extra: { detail?: number; pointerType?: string } = {}) {
|
||||
const e = new MouseEvent(type, { bubbles: true, cancelable: true, detail: extra.detail ?? 0 });
|
||||
Object.defineProperty(e, "timeStamp", { value: timeStamp });
|
||||
if (type.startsWith("pointer")) Object.assign(e, { pointerType: extra.pointerType ?? "mouse", isPrimary: true });
|
||||
return e;
|
||||
}
|
||||
|
||||
describe("installReorderedTapClick", () => {
|
||||
let uninstall: () => void;
|
||||
let el: HTMLElement;
|
||||
let log: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
uninstall = installReorderedTapClick({ isTrusted: () => true });
|
||||
el = document.body.appendChild(document.createElement("div"));
|
||||
log = [];
|
||||
for (const t of ["pointerdown", "pointerup", "mouseup", "click", "dblclick"])
|
||||
el.addEventListener(t, (e) => log.push(`${t}:${(e as MouseEvent).detail}`));
|
||||
});
|
||||
afterEach(() => {
|
||||
uninstall();
|
||||
el.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** WebKit's order for a tap while an input method is active: up, then the stale down. */
|
||||
const reversedTap = (up: number, down: number, detail = 1) => {
|
||||
el.dispatchEvent(ev("pointerup", up));
|
||||
el.dispatchEvent(ev("mouseup", up));
|
||||
el.dispatchEvent(ev("pointerdown", down));
|
||||
el.dispatchEvent(ev("mousedown", down, { detail }));
|
||||
vi.runAllTimers();
|
||||
};
|
||||
const inOrderTap = (down: number, up: number) => {
|
||||
el.dispatchEvent(ev("pointerdown", down));
|
||||
el.dispatchEvent(ev("mousedown", down, { detail: 1 }));
|
||||
el.dispatchEvent(ev("pointerup", up));
|
||||
el.dispatchEvent(ev("mouseup", up));
|
||||
el.dispatchEvent(ev("click", up, { detail: 1 }));
|
||||
vi.runAllTimers();
|
||||
};
|
||||
|
||||
it("leaves an in-order tap alone", () => {
|
||||
inOrderTap(10, 20);
|
||||
expect(log).toEqual(["pointerdown:0", "pointerup:0", "mouseup:0", "click:1"]);
|
||||
});
|
||||
|
||||
it("finishes a reordered tap with exactly one click", () => {
|
||||
reversedTap(1393, 1386);
|
||||
// The engine's own pointerup/mouseup, then ours; one click only.
|
||||
expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1"]);
|
||||
expect(log.filter((l) => l.startsWith("pointerup"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("gives the next press the pointerdown the engine skips, and keeps its click", () => {
|
||||
reversedTap(1393, 1386);
|
||||
log = [];
|
||||
// The engine still considers the button down: the next tap starts with a bare mousedown.
|
||||
el.dispatchEvent(ev("mousedown", 2000, { detail: 1 }));
|
||||
el.dispatchEvent(ev("pointerup", 2050));
|
||||
el.dispatchEvent(ev("mouseup", 2050));
|
||||
el.dispatchEvent(ev("click", 2050, { detail: 1 }));
|
||||
vi.runAllTimers();
|
||||
expect(log).toEqual(["pointerdown:0", "pointerup:0", "mouseup:0", "click:1"]);
|
||||
});
|
||||
|
||||
it("adds no pointerdown to an in-order press", () => {
|
||||
inOrderTap(10, 20);
|
||||
inOrderTap(30, 40);
|
||||
expect(log.filter((l) => l.startsWith("pointerdown"))).toEqual(["pointerdown:0", "pointerdown:0"]);
|
||||
});
|
||||
|
||||
it("adds no pointerdown to a press another pointer type started", () => {
|
||||
el.dispatchEvent(ev("pointerdown", 10, { pointerType: "touch" }));
|
||||
el.dispatchEvent(ev("mousedown", 10, { detail: 1 }));
|
||||
expect(log).toEqual(["pointerdown:0"]);
|
||||
});
|
||||
|
||||
it("does not drop the click of an in-order tap that follows a reordered one", () => {
|
||||
reversedTap(1393, 1386);
|
||||
inOrderTap(3000, 3050);
|
||||
expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1", "click:1"]);
|
||||
});
|
||||
|
||||
it("repeats the double-click detail of the stale press", () => {
|
||||
reversedTap(1393, 1386, 2);
|
||||
expect(log).toContain("click:2");
|
||||
expect(log).toContain("dblclick:2");
|
||||
});
|
||||
|
||||
it("ignores touch and pen pointers", () => {
|
||||
el.dispatchEvent(ev("pointerup", 1393, { pointerType: "touch" }));
|
||||
el.dispatchEvent(ev("pointerdown", 1386, { pointerType: "touch" }));
|
||||
vi.runAllTimers();
|
||||
expect(log).toEqual(["pointerup:0", "pointerdown:0"]);
|
||||
});
|
||||
|
||||
it("does nothing when the target left the document before the tick", () => {
|
||||
el.dispatchEvent(ev("pointerup", 1393));
|
||||
el.dispatchEvent(ev("pointerdown", 1386));
|
||||
el.remove();
|
||||
vi.runAllTimers();
|
||||
expect(log).toEqual(["pointerup:0", "pointerdown:0"]);
|
||||
});
|
||||
|
||||
it("stops intercepting after uninstall", () => {
|
||||
uninstall();
|
||||
reversedTap(1393, 1386);
|
||||
expect(log.filter((l) => l.startsWith("click"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* macOS WebKit routes mouse events through the input method while an editable element has
|
||||
* focus, and with a third-party input method it can hand the page a tap as `pointerup` ->
|
||||
* `pointerdown` (WebKit bug 219670). The press then has no release: the tap's click is lost,
|
||||
* and the engine, which still considers the button down, starts the next tap with a bare
|
||||
* `mousedown` (no `pointerdown`). `pointerdown.timeStamp` stays native, so a press stamped no
|
||||
* later than the `pointerup` just before it is a reordered tap: finish it with a synthetic
|
||||
* `pointerup`/`mouseup`/`click`, and give the next press the `pointerdown` the engine skips.
|
||||
* The next tap's own click lands on its own target, so it is left alone.
|
||||
*
|
||||
* TODO: remove once the minimum supported macOS ships a WebKit with bug 219670 fixed (WebKit#76581).
|
||||
*/
|
||||
interface Options {
|
||||
win?: Window;
|
||||
/** Tests dispatch untrusted events. */
|
||||
isTrusted?: (e: Event) => boolean;
|
||||
}
|
||||
|
||||
const primaryMouse = (e: PointerEvent) => e.pointerType === "mouse" && e.isPrimary && e.button === 0;
|
||||
|
||||
/** The engine's id of the mouse pointer (the only one a reordered tap concerns). */
|
||||
const MOUSE_POINTER_ID = 1;
|
||||
|
||||
export function installReorderedTapClick({
|
||||
win = window,
|
||||
isTrusted: trusted = (e) => e.isTrusted,
|
||||
}: Options = {}): () => void {
|
||||
// Events this shim dispatches must not be mistaken for the engine's.
|
||||
const own = new WeakSet<Event>();
|
||||
const isTrusted = (e: Event) => trusted(e) && !own.has(e);
|
||||
const send = (target: Element, e: Event) => {
|
||||
own.add(e);
|
||||
target.dispatchEvent(e);
|
||||
};
|
||||
let lastUp: number | null = null;
|
||||
// A pointer is down as far as the page has been told; a mouse press without one is a skipped pointerdown.
|
||||
let down = false;
|
||||
let pending: { target: Element; detail: number; x: number; y: number; mods: MouseEventInit } | null = null;
|
||||
|
||||
const mods = (e: MouseEvent): MouseEventInit => ({
|
||||
ctrlKey: e.ctrlKey,
|
||||
shiftKey: e.shiftKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
});
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!isTrusted(e)) return;
|
||||
down = false;
|
||||
if (primaryMouse(e)) lastUp = e.timeStamp;
|
||||
};
|
||||
|
||||
const onPointerCancel = (e: PointerEvent) => {
|
||||
if (isTrusted(e)) down = false;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!isTrusted(e)) return;
|
||||
down = true;
|
||||
const before = lastUp;
|
||||
lastUp = null;
|
||||
pending = null;
|
||||
if (!primaryMouse(e) || before === null || e.timeStamp > before) return;
|
||||
if (!(e.target instanceof Element)) return;
|
||||
pending = { target: e.target, detail: 1, x: e.clientX, y: e.clientY, mods: mods(e) };
|
||||
// After this tick the page's own pointerdown/mousedown handlers have run, as for an in-order tap.
|
||||
setTimeout(finish);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!isTrusted(e) || e.button !== 0) return;
|
||||
if (pending) {
|
||||
pending.detail = e.detail || 1;
|
||||
return;
|
||||
}
|
||||
if (down || !(e.target instanceof Element)) return;
|
||||
down = true;
|
||||
send(
|
||||
e.target,
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
pointerId: MOUSE_POINTER_ID,
|
||||
pointerType: "mouse",
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
screenX: e.screenX,
|
||||
screenY: e.screenY,
|
||||
...mods(e),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
const tap = pending;
|
||||
pending = null;
|
||||
if (!tap) return;
|
||||
down = false;
|
||||
if (!tap.target.isConnected) return;
|
||||
const init = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
clientX: tap.x,
|
||||
clientY: tap.y,
|
||||
...tap.mods,
|
||||
};
|
||||
send(
|
||||
tap.target,
|
||||
new PointerEvent("pointerup", { ...init, pointerId: MOUSE_POINTER_ID, pointerType: "mouse", isPrimary: true }),
|
||||
);
|
||||
send(tap.target, new MouseEvent("mouseup", { ...init, detail: tap.detail }));
|
||||
send(tap.target, new MouseEvent("click", { ...init, detail: tap.detail }));
|
||||
if (tap.detail === 2) send(tap.target, new MouseEvent("dblclick", { ...init, detail: 2 }));
|
||||
};
|
||||
|
||||
const capture = { capture: true };
|
||||
win.addEventListener("pointerup", onPointerUp as EventListener, capture);
|
||||
win.addEventListener("pointercancel", onPointerCancel as EventListener, capture);
|
||||
win.addEventListener("pointerdown", onPointerDown as EventListener, capture);
|
||||
win.addEventListener("mousedown", onMouseDown as EventListener, capture);
|
||||
return () => {
|
||||
win.removeEventListener("pointerup", onPointerUp as EventListener, capture);
|
||||
win.removeEventListener("pointercancel", onPointerCancel as EventListener, capture);
|
||||
win.removeEventListener("pointerdown", onPointerDown as EventListener, capture);
|
||||
win.removeEventListener("mousedown", onMouseDown as EventListener, capture);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача