diff --git a/src/main.tsx b/src/main.tsx index 10a197d6c..9b55abf3c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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(); diff --git a/src/platform/reordered-tap-click.test.ts b/src/platform/reordered-tap-click.test.ts new file mode 100644 index 000000000..e415ac992 --- /dev/null +++ b/src/platform/reordered-tap-click.test.ts @@ -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([]); + }); +}); diff --git a/src/platform/reordered-tap-click.ts b/src/platform/reordered-tap-click.ts new file mode 100644 index 000000000..b2c2d048c --- /dev/null +++ b/src/platform/reordered-tap-click.ts @@ -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(); + 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); + }; +}