diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts index 66c45b6e8f..5ccfacc10f 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts @@ -8,6 +8,7 @@ // @vitest-environment happy-dom import { vi, describe, it, expect, afterEach } from "vitest"; +import { type PointerEvent } from "react"; import { waitFor } from "test-utils-rtl"; import { type PanelImperativeHandle } from "@element-hq/web-shared-components"; @@ -17,6 +18,9 @@ import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; import { CallStore } from "../../stores/CallStore"; +/** The pointer handlers only read where the pointer is, so that is all a test has to give them. */ +const pointerAt = (x: number, y: number) => ({ clientX: x, clientY: y }) as PointerEvent; + describe("LeftPanelResizerViewModel", () => { afterEach(() => { localStorage.clear(); @@ -67,7 +71,7 @@ describe("LeftPanelResizerViewModel", () => { const vm = new ResizerViewModel(CallStore.instance); expect(() => { // Click - vm.onPointerDown(); + vm.onPointerDown(pointerAt(100, 100)); vm.onPointerUp(); }).not.toThrow(); }); @@ -86,13 +90,49 @@ describe("LeftPanelResizerViewModel", () => { vm.setPanelHandle(mockHandle); // Simulate drag - vm.onPointerDown(); - vm.onPointerMove(); + vm.onPointerDown(pointerAt(100, 100)); + vm.onPointerMove(pointerAt(160, 100)); vm.onPointerUp(); expect(mockHandle.resize).not.toHaveBeenCalledWith("34%"); }); + it("should expand panel when a click wanders a little", () => { + const vm = new ResizerViewModel(CallStore.instance); + SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34); + const mockHandle = { + resize: vi.fn(), + isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), + } as unknown as PanelImperativeHandle; + vm.setPanelHandle(mockHandle); + + // A trackpad rarely holds the pointer still between press and release. + vm.onPointerDown(pointerAt(100, 100)); + vm.onPointerMove(pointerAt(101, 102)); + vm.onPointerUp(); + + expect(mockHandle.resize).toHaveBeenCalledWith("34%"); + }); + + it("should expand panel on a click that follows moving across the separator", () => { + const vm = new ResizerViewModel(CallStore.instance); + SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34); + const mockHandle = { + resize: vi.fn(), + isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), + } as unknown as PanelImperativeHandle; + vm.setPanelHandle(mockHandle); + + // Pointer moves fire on hover too, with no button held. + vm.onPointerMove(pointerAt(300, 300)); + vm.onPointerDown(pointerAt(100, 100)); + vm.onPointerUp(); + + expect(mockHandle.resize).toHaveBeenCalledWith("34%"); + }); + describe("should expand panel on double click when panel is collapsed", () => { it("to last non-zero width that the user set", () => { const vm = new ResizerViewModel(CallStore.instance); @@ -104,7 +144,7 @@ describe("LeftPanelResizerViewModel", () => { } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click - vm.onPointerDown(); + vm.onPointerDown(pointerAt(100, 100)); vm.onPointerUp(); expect(mockHandle.resize).toHaveBeenCalledWith("34%"); }); @@ -118,7 +158,7 @@ describe("LeftPanelResizerViewModel", () => { } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click - vm.onPointerDown(); + vm.onPointerDown(pointerAt(100, 100)); vm.onPointerUp(); expect(mockHandle.resize).toHaveBeenCalledWith("100%"); }); diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.ts index cd85845003..bf41469236 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.ts @@ -15,6 +15,7 @@ import { type ResizerViewSnapshot, } from "@element-hq/web-shared-components"; import { debounce } from "lodash"; +import { type PointerEvent } from "react"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; @@ -133,15 +134,22 @@ export class ResizerViewModel this.mouseClickHandler.onPointerUp(); }; - public onPointerMove = (): void => { - this.mouseClickHandler.onPointerMove(); + public onPointerMove = (event: PointerEvent): void => { + this.mouseClickHandler.onPointerMove(event.clientX, event.clientY); }; - public onPointerDown = (): void => { - this.mouseClickHandler.onPointerDown(); + public onPointerDown = (event: PointerEvent): void => { + this.mouseClickHandler.onPointerDown(event.clientX, event.clientY); }; } +/** + * How far the pointer may travel between going down and coming up and still count as a click rather + * than a drag. A trackpad rarely holds a pointer perfectly still, and a separator that only opens on + * a pixel-perfect click reads as one that ignores clicks. + */ +const CLICK_TOLERANCE_PX = 5; + /** * Dragging the separator will emit a click event. * This class uses pointer event handlers to distinguish between a drag and a click @@ -150,17 +158,27 @@ export class ResizerViewModel class MouseClickHandler { public constructor(private readonly onClick: () => void) {} + /** Where the pointer went down, for as long as it is down. */ + private origin: { x: number; y: number } | null = null; private isResize = false; public onPointerUp = (): void => { + this.origin = null; if (!this.isResize) this.onClick(); }; - public onPointerDown = (): void => { + public onPointerDown = (x: number, y: number): void => { + this.origin = { x, y }; this.isResize = false; }; - public onPointerMove = (): void => { - this.isResize = true; + public onPointerMove = (x: number, y: number): void => { + // Moving across the separator with no button held is not a drag, and must not be allowed to + // spend the next click. + if (!this.origin) return; + + if (Math.abs(x - this.origin.x) > CLICK_TOLERANCE_PX || Math.abs(y - this.origin.y) > CLICK_TOLERANCE_PX) { + this.isResize = true; + } }; } diff --git a/packages/shared-components/src/resize/separator/SeparatorView.tsx b/packages/shared-components/src/resize/separator/SeparatorView.tsx index 6f3169d1eb..ae45c58e90 100644 --- a/packages/shared-components/src/resize/separator/SeparatorView.tsx +++ b/packages/shared-components/src/resize/separator/SeparatorView.tsx @@ -23,14 +23,16 @@ export interface SeparatorViewActions { onPointerUp: () => void; /** - * onPointerMove handler for separator. + * onPointerMove handler for separator. Takes the event so that how far the pointer has travelled + * since it went down can be measured. */ - onPointerMove: () => void; + onPointerMove: (event: React.PointerEvent) => void; /** - * onPointerDown handler for separator. + * onPointerDown handler for separator. Takes the event so that where the pointer went down can be + * measured from. */ - onPointerDown: () => void; + onPointerDown: (event: React.PointerEvent) => void; /** * onDoubleClick handler for the separator.