diff --git a/src/components/AppSidebar.svelte b/src/components/AppSidebar.svelte index c59fd1048..dfb7dcd81 100644 --- a/src/components/AppSidebar.svelte +++ b/src/components/AppSidebar.svelte @@ -22,7 +22,6 @@ import { commitSidebarWidth, discardSidebarWidthPreview, - MIN_SIDEBAR_WIDTH, previewSidebarWidth, RAIL_WIDTH_REM, renderedSidebarWidth, @@ -33,6 +32,7 @@ sidebarWidth, toggleSidebar, } from "@app/lib/sidebar.svelte"; + import { doublePressDetector, dragResult } from "@app/lib/sidebarResize"; import { updateChecker } from "@app/lib/updateChecker.svelte"; import useLocalStorage from "@app/lib/useLocalStorage.svelte"; import { isMac } from "@app/lib/utils"; @@ -92,14 +92,6 @@ const mini = $derived(collapsed.value); - // How far below the minimum width the drag has to go before it collapses, - // rather than the edge just sticking at the minimum. - const COLLAPSE_DRAG_SLACK_REM = 2; - - // While collapsed, the rail follows the drag at a fraction of its distance so - // it feels attached to the pointer, instead of sitting still until the - // threshold and then jumping open. - const RAIL_STRETCH_FACTOR = 0.35; let railStretch = $state(0); // What `.slot` is animating towards. @@ -176,29 +168,14 @@ // A second press in quick succession fits the sidebar. Detected here rather // than with `dblclick`, because the pointerdown below is cancelled to stop a // Tauri window drag, which suppresses the mouse events `dblclick` comes from. - const DOUBLE_PRESS_MS = 500; - const DOUBLE_PRESS_SLOP_PX = 6; - let lastPressAt = 0; - let lastPressX = 0; - - function isDoublePress(e: PointerEvent): boolean { - const paired = - // A press that ended in a resize isn't half of a pair. - !lastPressResized && - e.timeStamp - lastPressAt < DOUBLE_PRESS_MS && - Math.abs(e.clientX - lastPressX) <= DOUBLE_PRESS_SLOP_PX; - // Cleared so a third press starts a new pair instead of fitting again. - lastPressAt = paired ? 0 : e.timeStamp; - lastPressX = e.clientX; - return paired; - } + const isDoublePress = doublePressDetector(); function onEdgePointerDown(e: PointerEvent) { // Primary button only; a right-click here would otherwise start a drag. if (e.button !== 0) return; e.preventDefault(); - if (isDoublePress(e)) { + if (isDoublePress(e, lastPressResized)) { void fitSidebarToContent(); return; } @@ -223,26 +200,19 @@ if (Math.abs(ev.clientX - startX) <= DRAG_SLOP_PX) return; dragged = true; } - const width = startWidth + (ev.clientX - startX) / pxPerRem; - // One boundary serves both directions: below it the sidebar is collapsed, - // above it expanded, so a single drag can cross either way. - const collapse = width < MIN_SIDEBAR_WIDTH - COLLAPSE_DRAG_SLACK_REM; - if (collapsed.value !== collapse) { - collapsed.value = collapse; + const drag = dragResult(startWidth, ev.clientX - startX, pxPerRem); + if (collapsed.value !== drag.collapse) { + collapsed.value = drag.collapse; snapWidth(); } - if (collapse) { - railStretch = Math.max( - 0, - (width - RAIL_WIDTH_REM) * RAIL_STRETCH_FACTOR, - ); + railStretch = drag.railStretch; + if (drag.collapse) { // Anything previewed before the threshold was crossed is on the way to // collapsing, not a width to remember. discardSidebarWidthPreview(); } else { - railStretch = 0; // Off storage; persisted once when the drag ends. - previewSidebarWidth(width); + previewSidebarWidth(drag.width); } }; diff --git a/src/lib/sidebarResize.ts b/src/lib/sidebarResize.ts new file mode 100644 index 000000000..ea83ada45 --- /dev/null +++ b/src/lib/sidebarResize.ts @@ -0,0 +1,49 @@ +import { MIN_SIDEBAR_WIDTH, RAIL_WIDTH_REM } from "@app/lib/sidebar.svelte"; + +// How far below the minimum width the drag has to go before it collapses, +// rather than the edge just sticking at the minimum. +const COLLAPSE_DRAG_SLACK_REM = 2; + +// While collapsed, the rail follows the drag at a fraction of its distance so +// it feels attached to the pointer, instead of sitting still until the +// threshold and then jumping open. +const RAIL_STRETCH_FACTOR = 0.35; + +// Where a drag of the sidebar edge leaves it. One boundary serves both +// directions: below it the sidebar is collapsed, above it expanded, so a +// single drag can cross either way. +export function dragResult( + startWidth: number, + deltaPx: number, + pxPerRem: number, +): { collapse: boolean; width: number; railStretch: number } { + const width = startWidth + deltaPx / pxPerRem; + const collapse = width < MIN_SIDEBAR_WIDTH - COLLAPSE_DRAG_SLACK_REM; + const railStretch = collapse + ? Math.max(0, (width - RAIL_WIDTH_REM) * RAIL_STRETCH_FACTOR) + : 0; + return { collapse, width, railStretch }; +} + +const DOUBLE_PRESS_MS = 500; +const DOUBLE_PRESS_SLOP_PX = 6; + +// A second press in quick succession, close to the first. A press that ended +// in a resize isn't half of a pair, and a pair is cleared so a third press +// starts a new one. +export function doublePressDetector() { + let lastPressAt = -Infinity; + let lastPressX = 0; + return ( + press: { timeStamp: number; clientX: number }, + lastPressResized: boolean, + ): boolean => { + const paired = + !lastPressResized && + press.timeStamp - lastPressAt < DOUBLE_PRESS_MS && + Math.abs(press.clientX - lastPressX) <= DOUBLE_PRESS_SLOP_PX; + lastPressAt = paired ? -Infinity : press.timeStamp; + lastPressX = press.clientX; + return paired; + }; +} diff --git a/tests/unit/sidebarResize.test.ts b/tests/unit/sidebarResize.test.ts new file mode 100644 index 000000000..e46dcd15f --- /dev/null +++ b/tests/unit/sidebarResize.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { doublePressDetector, dragResult } from "@app/lib/sidebarResize"; + +describe("dragResult", () => { + test("widens by the dragged distance", () => { + expect(dragResult(16, 32, 16)).toEqual({ + collapse: false, + width: 18, + railStretch: 0, + }); + }); + + test("collapses only past the slack below the minimum width", () => { + expect(dragResult(12, -2 * 16, 16).collapse).toBe(false); + expect(dragResult(12, -2 * 16 - 1, 16).collapse).toBe(true); + }); + + test("stretches the rail with a collapsed drag, never below its width", () => { + expect(dragResult(5, 2 * 16, 16)).toEqual({ + collapse: true, + width: 7, + railStretch: 0.7, + }); + expect(dragResult(5, -16, 16).railStretch).toBe(0); + }); +}); + +describe("doublePressDetector", () => { + const press = (timeStamp: number, clientX = 100) => ({ timeStamp, clientX }); + + test("pairs two quick presses in the same place", () => { + const isDoublePress = doublePressDetector(); + expect(isDoublePress(press(1000), false)).toBe(false); + expect(isDoublePress(press(1499, 106), false)).toBe(true); + }); + + test("a third press starts a new pair", () => { + const isDoublePress = doublePressDetector(); + isDoublePress(press(1000), false); + isDoublePress(press(1100), false); + expect(isDoublePress(press(1200), false)).toBe(false); + expect(isDoublePress(press(1300), false)).toBe(true); + }); + + test.each([ + ["too late", press(1500), false], + ["too far", press(1100, 107), false], + ["after a resize", press(1100), true], + ])("doesn't pair a press %s", (_, second, resized) => { + const isDoublePress = doublePressDetector(); + isDoublePress(press(1000), false); + expect(isDoublePress(second, resized)).toBe(false); + }); + + test("the first press right after launch isn't a pair", () => { + expect(doublePressDetector()(press(10, 0), false)).toBe(false); + }); +});