mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Add rubber-banding to sidebar resize (#13188)
* Add rubber-banding to sidebar resize * refactor
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -120,6 +120,7 @@ declare module "styled-components" {
|
||||
sidebarCollapsedWidth: number;
|
||||
sidebarMinWidth: number;
|
||||
sidebarMaxWidth: number;
|
||||
sidebarResizeMinWidth: number;
|
||||
}
|
||||
|
||||
export interface DefaultTheme
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
Reference in New Issue
Block a user