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]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-04 01:31:10 +08:00
1 джерело a35e434376
коміт 555226a9bf
15 files changed
+180 -9

No files matched your search

+29
Переглянути файл
@@ -242,6 +242,35 @@ describe("ProjectView", () => {
}
});
it("starts a file drag only while the primary button is held", async () => {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
document.elementFromPoint = () => null; // jsdom lacks it; the drop-target lookup needs it
// The drag label repeats the file name outside the tree row.
const ghost = () => (screen.queryAllByText("README.md").length > 1 ? true : null);
const move = (buttons: number, x: number) =>
act(() => {
window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: 0 }));
});
const release = () => act(() => void window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })));
// jsdom has no PointerEvent, so React would see `button` undefined on a plain pointerDown.
const press = () =>
fireEvent(
row("README.md").querySelector("[data-project-path]")!,
new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
);
// A macOS tap WebKit reorders: the press arrives late and no button is held.
press();
move(0, 40);
expect(ghost()).toBeNull();
release();
press();
move(1, 40);
expect(ghost()).not.toBeNull();
release();
expect(ghost()).toBeNull();
});
it("cancels a new file when its name field loses focus", async () => {
const { rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
+7
Переглянути файл
@@ -581,6 +581,11 @@ export function ProjectView() {
// Files (not folders) can also be dropped on an editor to open them there.
let files: DragSession | null = null;
const move = (ev: PointerEvent) => {
// A press with no button held is a reordered macOS tap, not a drag (see pointerDrag.ts).
if (!(ev.buttons & 1)) {
if (!active) cleanup();
return;
}
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
active = true;
@@ -626,6 +631,7 @@ export function ProjectView() {
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cleanup);
window.removeEventListener("keydown", key, true);
files?.cancel();
setDropTarget(null);
@@ -633,6 +639,7 @@ export function ProjectView() {
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cleanup);
window.addEventListener("keydown", key, true);
}, []);