Add rubber-banding to sidebar resize (#13188)

* Add rubber-banding to sidebar resize

* refactor
This commit is contained in:
Tom Moor
2026-07-28 21:08:09 -04:00
committed by GitHub
parent c1ae5aa86e
commit 7bc6a3ca79
8 changed files with 288 additions and 126 deletions
+59 -57
View File
@@ -5,10 +5,10 @@ import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useStores from "~/hooks/useStores";
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
import { useResizeHandle } from "~/hooks/useResizeHandle";
import { sidebarAppearDuration } from "~/styles/animations";
import { useDirection } from "@radix-ui/react-direction";
@@ -23,74 +23,70 @@ function Aside({ children, border, className, skipInitialAnimation }: Props) {
const theme = useTheme();
const { ui } = useStores();
const positionRef = React.useRef<HTMLDivElement>(null);
const [isResizing, setResizing] = React.useState(false);
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const minWidth = theme.sidebarResizeMinWidth;
const windowScrollbarWidth = useWindowScrollbarWidth();
const direction = useDirection();
const handleDrag = React.useCallback(
const measure = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// Measure from the sidebar's own anchored edge rather than the window,
// as in a split view the sidebar is not positioned at the window edge.
const rect = positionRef.current?.getBoundingClientRect();
if (!rect) {
return;
return undefined;
}
const distance =
direction === "rtl"
? event.clientX - rect.left
: rect.right - event.clientX;
const width = Math.max(
Math.min(distance + (windowScrollbarWidth ?? 0), maxWidth),
minWidth
);
ui.set({ sidebarRightWidth: width });
return distance + (windowScrollbarWidth ?? 0);
},
[minWidth, maxWidth, direction, ui, windowScrollbarWidth]
[direction, windowScrollbarWidth]
);
const handleResize = React.useCallback(
(width: number) => ui.set({ sidebarRightWidth: width }),
[ui]
);
const handleResizeEnd = React.useCallback(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
// Settle within the bounds if released while stretched beyond them.
const settled = Math.max(
Math.min(ui.sidebarRightWidth, maxWidth),
minWidth
);
if (settled !== ui.sidebarRightWidth) {
ui.set({ sidebarRightWidth: settled });
}
}, [ui, minWidth, maxWidth]);
const { isResizing, startResize } = useResizeHandle({
measure,
onResize: handleResize,
onResizeEnd: handleResizeEnd,
min: minWidth,
max: maxWidth,
});
const handleReset = React.useCallback(() => {
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
}, [ui, theme.sidebarRightWidth]);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
}, []);
const handleMouseDown = React.useCallback((event) => {
event.preventDefault();
setResizing(true);
}, []);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
const style = React.useMemo(
() => ({
width: windowScrollbarWidth
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
: `${ui.sidebarRightWidth}px`,
}),
[ui.sidebarRightWidth, windowScrollbarWidth]
);
// Resizing tracks the pointer exactly, otherwise width changes spring which also animates the
// snap back to the maximum or minimum when released from a stretched position.
const transition = isResizing
? { duration: 0 }
: {
type: "spring",
bounce: 0.2,
duration: sidebarAppearDuration / 1000,
};
const animationProps = {
initial: skipInitialAnimation
@@ -100,13 +96,7 @@ function Aside({ children, border, className, skipInitialAnimation }: Props) {
opacity: 0.9,
},
animate: {
transition: isResizing
? { duration: 0 }
: {
type: "spring",
bounce: 0.2,
duration: sidebarAppearDuration / 1000,
},
transition,
width: ui.sidebarRightWidth,
opacity: 1,
},
@@ -116,6 +106,16 @@ function Aside({ children, border, className, skipInitialAnimation }: Props) {
},
};
// The inner element is positioned out of flow, so it must follow the same width to stay in step
// with the container it visually fills.
const positionAnimationProps = {
initial: false,
animate: {
transition,
width: ui.sidebarRightWidth - (windowScrollbarWidth ?? 0),
},
};
return (
<Sidebar
{...animationProps}
@@ -124,10 +124,10 @@ function Aside({ children, border, className, skipInitialAnimation }: Props) {
role="complementary"
aria-label="Aside"
>
<Position ref={positionRef} style={style} column>
<Position ref={positionRef} {...positionAnimationProps}>
<ErrorBoundary>{children}</ErrorBoundary>
<ResizeBorder
onMouseDown={handleMouseDown}
onMouseDown={startResize}
onDoubleClick={handleReset}
dir="right"
/>
@@ -136,7 +136,9 @@ function Aside({ children, border, className, skipInitialAnimation }: Props) {
);
}
const Position = styled(Flex)`
const Position = styled(m.div)`
display: flex;
flex-direction: column;
position: fixed;
top: 0;
bottom: 0;
+29 -36
View File
@@ -8,8 +8,10 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import { useResizeHandle } from "~/hooks/useResizeHandle";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { fadeOnDesktopBackgrounded } from "~/styles";
@@ -52,26 +54,26 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const minWidth = theme.sidebarResizeMinWidth;
const direction = useDirection();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const internalRef = React.useRef<HTMLDivElement | null>(null);
const mergedRef = React.useMemo(() => mergeRefs([internalRef, ref]), [ref]);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const rawWidth =
direction === "rtl" ? offset - event.pageX : event.pageX - offset;
const newWidth = Math.min(rawWidth, maxWidth);
const measure = React.useCallback(
(event: MouseEvent) =>
direction === "rtl" ? offset - event.pageX : event.pageX - offset,
[offset, direction]
);
const handleResize = React.useCallback(
(newWidth: number) => {
const isSmallerThanCollapsePoint = newWidth < minWidth / 2;
if (canCollapse) {
@@ -84,12 +86,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
ui.set({ sidebarWidth: Math.max(newWidth, minWidth) });
}
},
[ui, theme, offset, minWidth, maxWidth, direction, canCollapse]
[ui, theme, minWidth, canCollapse]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
const handleResizeEnd = React.useCallback(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -105,17 +105,26 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
ui.set({ sidebarWidth: minWidth });
setAnimating(true);
}
} else {
ui.set({ sidebarWidth: width });
} else if (width > maxWidth) {
ui.set({ sidebarWidth: maxWidth });
setAnimating(true);
}
}, [ui, isSmallerThanMinimum, minWidth, width, canCollapse]);
}, [ui, isSmallerThanMinimum, minWidth, maxWidth, width, canCollapse]);
// The lower bound is the collapse gesture rather than a stop, so only the maximum is stretchable.
const { isResizing, startResize } = useResizeHandle({
measure,
onResize: handleResize,
onResizeEnd: handleResizeEnd,
max: maxWidth,
});
const handleBlur = React.useCallback(() => {
setHovering(false);
}, []);
const handleMouseDown = React.useCallback(
(event) => {
(event: React.MouseEvent) => {
event.preventDefault();
if (!document.hasFocus()) {
return;
@@ -124,10 +133,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
setOffset(
direction === "rtl" ? event.pageX + width : event.pageX - width
);
setResizing(true);
setAnimating(false);
startResize(event);
},
[width, direction]
[width, direction, startResize]
);
const handlePointerActivity = React.useCallback(
@@ -222,23 +231,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
}
}, [ui, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
} else {
document.body.style.cursor = "initial";
}
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("blur", handleBlur);
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
useEventListener("blur", handleBlur);
const handleReset = React.useCallback(() => {
ui.set({ sidebarWidth: theme.sidebarWidth });
+11 -28
View File
@@ -16,6 +16,7 @@ import {
} from "~/components/RightSidebarContext";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import { useResizeHandle } from "~/hooks/useResizeHandle";
import useStores from "~/hooks/useStores";
import history, { patchLocation, toLocationDescriptor } from "~/utils/history";
import type { SplitViewPane } from "~/utils/splitView";
@@ -45,7 +46,6 @@ export const SplitView = observer(function SplitView({ children }: Props) {
const isMobile = useMobile();
const direction = useDirection();
const containerRef = React.useRef<HTMLDivElement>(null);
const [isResizing, setResizing] = React.useState(false);
const splitPath = isMobile ? undefined : getSplitPath(location.search);
const focusedPane = getFocusedSplitPane();
@@ -59,49 +59,32 @@ export const SplitView = observer(function SplitView({ children }: Props) {
}
}, [splitPath, ui]);
const handleDrag = React.useCallback(
const measure = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) {
return;
return undefined;
}
const offset =
direction === "rtl"
? rect.right - event.clientX
: event.clientX - rect.left;
ui.setSplitViewRatio(offset / rect.width);
return offset / rect.width;
},
[direction, ui]
[direction]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
}, []);
const handleResizeStart = React.useCallback((event: React.MouseEvent) => {
event.preventDefault();
setResizing(true);
}, []);
// Panes divide the available space, so the ratio is clamped by the store rather than stretched.
const { startResize } = useResizeHandle({
measure,
onResize: ui.setSplitViewRatio,
});
const handleResizeReset = React.useCallback(() => {
ui.setSplitViewRatio(0.5);
}, [ui]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
if (!splitPath) {
return <>{children}</>;
}
@@ -124,7 +107,7 @@ export const SplitView = observer(function SplitView({ children }: Props) {
dir="right"
$transparent
data-resize-handle
onMouseDown={handleResizeStart}
onMouseDown={startResize}
onDoubleClick={handleResizeReset}
/>
}
+38
View File
@@ -0,0 +1,38 @@
import { rubberBand } from "./useResizeHandle";
describe("rubberBand", () => {
it("returns the value unchanged when within bounds", () => {
expect(rubberBand(300, { min: 200, max: 600 })).toBe(300);
expect(rubberBand(200, { min: 200, max: 600 })).toBe(200);
expect(rubberBand(600, { min: 200, max: 600 })).toBe(600);
});
it("resists movement beyond the maximum", () => {
const slightly = rubberBand(610, { max: 600 });
expect(slightly).toBeGreaterThan(600);
expect(slightly).toBeLessThan(610);
});
it("resists movement below the minimum", () => {
const slightly = rubberBand(190, { min: 200 });
expect(slightly).toBeLessThan(200);
expect(slightly).toBeGreaterThan(190);
});
it("becomes progressively harder to move further", () => {
const first = rubberBand(620, { max: 600 }) - 600;
const second =
rubberBand(640, { max: 600 }) - rubberBand(620, { max: 600 });
expect(second).toBeLessThan(first);
});
it("never travels further than the limit beyond a bound", () => {
expect(rubberBand(100000, { max: 600, limit: 100 })).toBeLessThan(700);
expect(rubberBand(-100000, { min: 200, limit: 100 })).toBeGreaterThan(100);
});
it("ignores bounds that are not given", () => {
expect(rubberBand(1000, { min: 200 })).toBe(1000);
expect(rubberBand(0, { max: 600 })).toBe(0);
});
});
+126
View File
@@ -0,0 +1,126 @@
import * as React from "react";
import useEventListener from "~/hooks/useEventListener";
/** How far, in pixels, a value can be dragged beyond its bounds. */
const RUBBER_BAND_LIMIT = 100;
/** Resistance applied to the first pixel of overshoot, 0-1. Lower is stiffer. */
const RUBBER_BAND_TENSION = 0.55;
interface Bounds {
/** Lower bound, below which resistance is applied. */
min?: number;
/** Upper bound, above which resistance is applied. */
max?: number;
/** How far the value may travel beyond a bound, defaults to 100. */
limit?: number;
}
interface ResizeHandleOptions extends Bounds {
/**
* Maps a mouse event to the raw, unconstrained value being dragged. Return undefined to ignore
* the event, for example when the element being measured is not yet rendered.
*/
measure: (event: MouseEvent) => number | undefined;
/** Called with the constrained value on each frame of the drag. */
onResize: (value: number) => void;
/** Called once the drag has ended, typically to settle the value within its bounds. */
onResizeEnd?: () => void;
}
interface ResizeHandle {
/** Whether a drag is currently in progress. */
isResizing: boolean;
/** Begins a drag, to be called from the mouse down event of the drag handle. */
startResize: (event: React.MouseEvent) => void;
}
/**
* Drives a draggable resize handle. Tracks the mouse for the duration of the drag and applies
* resistance when dragged outside of the given bounds, so that the value can be stretched past
* them but settles back within them once released.
*
* @param options the bounds to resize within, and the callbacks driving the drag.
* @returns the drag state, and the mouse down handler to attach to the handle.
*/
export function useResizeHandle({
measure,
onResize,
onResizeEnd,
min,
max,
limit,
}: ResizeHandleOptions): ResizeHandle {
const [isResizing, setResizing] = React.useState(false);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const value = measure(event);
if (value === undefined) {
return;
}
onResize(rubberBand(value, { min, max, limit }));
},
[measure, onResize, min, max, limit]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
onResizeEnd?.();
}, [onResizeEnd]);
const startResize = React.useCallback((event: React.MouseEvent) => {
event.preventDefault();
setResizing(true);
}, []);
useEventListener("mousemove", handleDrag, isResizing ? document : null);
useEventListener("mouseup", handleStopDrag, isResizing ? document : null);
useEventListener("blur", handleStopDrag, isResizing ? window : null);
React.useEffect(() => {
if (!isResizing) {
return;
}
document.body.style.cursor = "col-resize";
return () => {
document.body.style.cursor = "";
};
}, [isResizing]);
return { isResizing, startResize };
}
/**
* Applies resistance to a value that has been dragged outside of the given bounds, so that moving
* it further becomes progressively harder as in the iOS rubber band scrolling effect. The value
* approaches, but never reaches, `limit` pixels beyond the bound.
*
* @param value the value to constrain.
* @param bounds the bounds to apply resistance outside of.
* @returns the value with resistance applied.
*/
export function rubberBand(
value: number,
{ min, max, limit = RUBBER_BAND_LIMIT }: Bounds
): number {
if (max !== undefined && value > max) {
return max + resistance(value - max, limit);
}
if (min !== undefined && value < min) {
return min - resistance(min - value, limit);
}
return value;
}
function resistance(distance: number, limit: number): number {
return (
(distance * limit * RUBBER_BAND_TENSION) /
(limit + RUBBER_BAND_TENSION * distance)
);
}
+15 -3
View File
@@ -1,3 +1,4 @@
import { clamp } from "es-toolkit";
import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
@@ -155,9 +156,20 @@ class UiStore {
const data: PersistedData = Storage.get(UI_STORE) || {};
this.languagePromptDismissed = data.languagePromptDismissed;
this.sidebarCollapsed = !!data.sidebarCollapsed;
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
this.sidebarRightWidth =
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
// Widths are clamped as a drag may have been interrupted while stretched beyond the bounds,
// or the bounds themselves may have since changed.
const { sidebarResizeMinWidth: minWidth, sidebarMaxWidth: maxWidth } =
defaultTheme;
this.sidebarWidth = clamp(
data.sidebarWidth || defaultTheme.sidebarWidth,
minWidth,
maxWidth
);
this.sidebarRightWidth = clamp(
data.sidebarRightWidth || defaultTheme.sidebarRightWidth,
minWidth,
maxWidth
);
this.tocVisible = data.tocVisible;
this.rightSidebar = data.rightSidebar ?? null;
this.theme = data.theme || Theme.System;
+1
View File
@@ -120,6 +120,7 @@ declare module "styled-components" {
sidebarCollapsedWidth: number;
sidebarMinWidth: number;
sidebarMaxWidth: number;
sidebarResizeMinWidth: number;
}
export interface DefaultTheme
+9 -2
View File
@@ -43,12 +43,19 @@ const defaultColors: Colors = {
},
};
/** The narrowest the content of a sidebar can be, excluding its padding. */
const sidebarMinWidth = 240;
const sidebarPadding = 16;
const spacing = {
sidebarWidth: 260,
sidebarRightWidth: 300,
sidebarCollapsedWidth: 16,
sidebarMinWidth: 200,
sidebarMaxWidth: 600,
sidebarMinWidth,
sidebarMaxWidth: 500,
/** The narrowest a sidebar can be resized to, including its padding. */
sidebarResizeMinWidth: sidebarMinWidth + sidebarPadding,
};
const buildBaseTheme = (input: Partial<Colors>) => {