дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(ui): start pointer drags only while the primary button is held (#1071)
On macOS, with an editable element focused and an input method active, WebKit can deliver a tap as pointerup then pointerdown, so drag trackers saw every later pointermove as a held button: one tap plus a pointer move started a phantom drag (file drag label, tab detach, tool window move) and the next tap dropped it. A drag now starts only from a move with the primary button down, a move without it cancels the press, and late button-less moves after a drag started are ignored so the drop is kept. Resizers, dialog drag/resize, pan and the review gutter end instead; the Project view also listens for pointercancel. Refs #760 Co-authored-by: ntbang0901 <[email protected]>
Цей коміт міститься в:
1 джерело
a35e434376
коміт
555226a9bf
15 files changed
+180
-9
No files matched your search
@@ -120,7 +120,7 @@ describe("Dialog chrome", () => {
|
||||
down(dialog.querySelector("div:last-child")!, 10);
|
||||
expect(dialog.style.translate).toBe("");
|
||||
down(handle, 10);
|
||||
fireEvent.pointerMove(window, { clientX: 200, clientY: 40 });
|
||||
fireEvent.pointerMove(window, { buttons: 1, clientX: 200, clientY: 40 });
|
||||
fireEvent.pointerUp(window);
|
||||
expect(dialog.style.translate).toBe("190px 30px");
|
||||
});
|
||||
@@ -134,12 +134,12 @@ describe("Dialog chrome", () => {
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const bar = document.querySelector("[data-dialog-titlebar]") as HTMLElement;
|
||||
fireEvent.pointerDown(bar, { button: 0, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(window, { clientX: 60, clientY: 40 });
|
||||
fireEvent.pointerMove(window, { buttons: 1, clientX: 60, clientY: 40 });
|
||||
fireEvent.pointerUp(window);
|
||||
expect(dialog.style.translate).toBe("80px 30px");
|
||||
// The close button never starts a drag.
|
||||
fireEvent.pointerDown(within(bar).getByRole("button", { name: "Close" }), { button: 0, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(window, { clientX: 200, clientY: 40 });
|
||||
fireEvent.pointerMove(window, { buttons: 1, clientX: 200, clientY: 40 });
|
||||
fireEvent.pointerUp(window);
|
||||
expect(dialog.style.translate).toBe("80px 30px");
|
||||
});
|
||||
|
||||
@@ -299,6 +299,7 @@ function DialogInner({
|
||||
const rect = el.getBoundingClientRect();
|
||||
const base = { left: rect.left - offset.x, top: rect.top - offset.y };
|
||||
const move = (ev: globalThis.PointerEvent) => {
|
||||
if (!(ev.buttons & 1)) return up();
|
||||
// Keep the title bar reachable.
|
||||
const x = Math.min(
|
||||
Math.max(ev.clientX - start.x, -base.left - rect.width + 80),
|
||||
@@ -331,6 +332,7 @@ function DialogInner({
|
||||
const minH = chrome + 120;
|
||||
const origin = offset;
|
||||
const move = (ev: globalThis.PointerEvent) => {
|
||||
if (!(ev.buttons & 1)) return up();
|
||||
const w = Math.min(Math.max(rect.width + ev.clientX - start.x, minW), window.innerWidth - 24);
|
||||
const h = Math.min(Math.max(rect.height + ev.clientY - start.y, minH), window.innerHeight - 24);
|
||||
setSize({ w, h });
|
||||
|
||||
@@ -226,7 +226,16 @@ describe("Tabs drag & drop", () => {
|
||||
.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
|
||||
.map((el) => el.textContent);
|
||||
const pointer = (el: Element | Window, type: string, x: number) =>
|
||||
fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: 12 }));
|
||||
fireEvent(
|
||||
el,
|
||||
new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: type === "pointerup" ? 0 : 1,
|
||||
clientX: x,
|
||||
clientY: 12,
|
||||
}),
|
||||
);
|
||||
|
||||
it("moves the other tabs out of the way while a tab is dragged, like IDEA (#1052)", () => {
|
||||
render(<Strip label="Terminal sessions" initial={["Local", "Local (2)", "Local (3)"]} />);
|
||||
|
||||
@@ -67,7 +67,17 @@ const labels = () =>
|
||||
.getAllByRole("treeitem")
|
||||
.map((el) => el.textContent);
|
||||
const pointer = (el: Element | Window, type: string, y: number, altKey = false) =>
|
||||
fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: 30, clientY: y, altKey }));
|
||||
fireEvent(
|
||||
el,
|
||||
new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: type === "pointerup" ? 0 : 1,
|
||||
clientX: 30,
|
||||
clientY: y,
|
||||
altKey,
|
||||
}),
|
||||
);
|
||||
function drag(source: HTMLElement, target: HTMLElement, after = false, alt = false) {
|
||||
pointer(source, "pointerdown", source.getBoundingClientRect().top + 12, alt);
|
||||
hit = target;
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropZoneAt, isOutsideWindow } from "./pointerDrag";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dropZoneAt, isOutsideWindow, startPointerDrag } from "./pointerDrag";
|
||||
|
||||
const fire = (type: string, buttons: number, x = 0, y = 0) =>
|
||||
window.dispatchEvent(new MouseEvent(type, { bubbles: true, buttons, clientX: x, clientY: y }));
|
||||
|
||||
describe("startPointerDrag with a reordered macOS tap", () => {
|
||||
beforeEach(() => {
|
||||
document.elementFromPoint = () => null; // jsdom lacks it; drop hit-testing needs it
|
||||
});
|
||||
afterEach(() => (document.body.innerHTML = ""));
|
||||
const start = () => {
|
||||
const source = document.body.appendChild(document.createElement("div"));
|
||||
const onStart = vi.fn();
|
||||
const onEnd = vi.fn();
|
||||
startPointerDrag(
|
||||
{ button: 0, clientX: 0, clientY: 0, altKey: false },
|
||||
source,
|
||||
{ type: "t", payload: "" },
|
||||
{ onStart, onEnd },
|
||||
);
|
||||
return { onStart, onEnd };
|
||||
};
|
||||
|
||||
it("never starts a drag from moves with no button held", () => {
|
||||
const { onStart, onEnd } = start();
|
||||
fire("pointermove", 0, 40, 0);
|
||||
fire("pointerup", 0, 40, 0);
|
||||
expect(onStart).not.toHaveBeenCalled();
|
||||
expect(onEnd).toHaveBeenCalledOnce();
|
||||
expect(onEnd).toHaveBeenCalledWith(false);
|
||||
expect(document.querySelector("[aria-hidden]")).toBeNull();
|
||||
});
|
||||
|
||||
it("drags and drops while the button is held", () => {
|
||||
const { onStart, onEnd } = start();
|
||||
fire("pointermove", 1, 40, 0);
|
||||
fire("pointerup", 0, 40, 0);
|
||||
expect(onStart).toHaveBeenCalledOnce();
|
||||
expect(onEnd).toHaveBeenCalledWith(true, expect.objectContaining({ x: 40 }));
|
||||
});
|
||||
|
||||
it("keeps the drop when late moves arrive after the button was released", () => {
|
||||
const { onEnd } = start();
|
||||
fire("pointermove", 1, 40, 0);
|
||||
fire("pointermove", 0, 60, 0);
|
||||
fire("pointerup", 0, 60, 0);
|
||||
expect(onEnd).toHaveBeenCalledWith(true, expect.objectContaining({ x: 60 }));
|
||||
});
|
||||
});
|
||||
|
||||
const viewport = { left: 0, top: 0, right: 1280, bottom: 800 };
|
||||
|
||||
|
||||
@@ -146,6 +146,9 @@ export function startPointerDrag(
|
||||
const session = dragSession(data);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!ghost) {
|
||||
// macOS WebKit can deliver a tap's pointerup before its pointerdown, so a
|
||||
// press with no button held would otherwise start a phantom drag.
|
||||
if (!(ev.buttons & 1)) return onCancel();
|
||||
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
||||
const r = source.getBoundingClientRect();
|
||||
ghost = source.cloneNode(true) as HTMLElement;
|
||||
@@ -165,6 +168,8 @@ export function startPointerDrag(
|
||||
document.documentElement.classList.add(styles.dragging!);
|
||||
opts.onStart?.();
|
||||
}
|
||||
// Late moves after release may still precede the pointerup; ignore, don't cancel the drop.
|
||||
if (!(ev.buttons & 1)) return;
|
||||
ghost.style.transform = `translate(${ev.clientX - start.x}px, ${ev.clientY - start.y}px)`;
|
||||
ghost.classList.toggle(styles.noTarget!, !session.move(ev.clientX, ev.clientY));
|
||||
};
|
||||
|
||||
Reference in new issue
Заблокувати користувача