mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
refactor(chat): replace useSnapScroll with StickToBottom for smoother scrolling
The useSnapScroll hook has been replaced with the StickToBottom component to improve the scrolling behavior in the chat interface. This change ensures smoother and more consistent scrolling, especially when new messages are added. The StickToBottom component provides better control over the scroll position and handles edge cases more effectively.
This commit is contained in:
parent
b41691f6f2
commit
b009b02057
@ -42,6 +42,7 @@ import { SupabaseConnection } from './SupabaseConnection';
|
||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { StickToBottom } from '~/lib/hooks';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@ -87,8 +88,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
messageRef,
|
||||
scrollRef,
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
isStreaming = false,
|
||||
@ -336,7 +335,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
@ -348,24 +347,26 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
<StickToBottom
|
||||
className={classNames('pt-6 px-2 sm:px-6 relative', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
ref={scrollRef}
|
||||
resize="smooth"
|
||||
initial="smooth"
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
<StickToBottom.Content className="flex flex-col gap-4">
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
</StickToBottom.Content>
|
||||
{deployAlert && (
|
||||
<DeployChatAlert
|
||||
alert={deployAlert}
|
||||
@ -387,7 +388,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames('flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
'sticky bottom-2': chatStarted,
|
||||
})}
|
||||
>
|
||||
@ -639,7 +640,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StickToBottom>
|
||||
<div className="flex flex-col justify-center gap-5">
|
||||
{!chatStarted && (
|
||||
<div className="flex justify-center gap-2">
|
||||
|
@ -8,7 +8,7 @@ import { useChat } from 'ai/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks';
|
||||
import { description, useChatHistory } from '~/lib/persistence';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
@ -483,8 +483,6 @@ export const ChatImpl = memo(
|
||||
[],
|
||||
);
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
@ -522,8 +520,6 @@ export const ChatImpl = memo(
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
providerList={activeProviders}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
onTextareaChange(e);
|
||||
debouncedCachePrompt(e);
|
||||
|
@ -20,8 +20,8 @@ export const ExpoQrModal: React.FC<ExpoQrModalProps> = ({ open, onClose }) => {
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="border !border-bolt-elements-borderColor flex flex-col gap-5 justify-center items-center p-6 bg-bolt-elements-background-depth-2 rounded-md">
|
||||
<div className="i-bolt:expo-brand h-10 w-full"></div>
|
||||
<DialogTitle className="text-white text-lg font-semibold leading-6">
|
||||
<div className="i-bolt:expo-brand h-10 w-full invert dark:invert-none"></div>
|
||||
<DialogTitle className="text-bolt-elements-textTertiary text-lg font-semibold leading-6">
|
||||
Preview on your own mobile device
|
||||
</DialogTitle>
|
||||
<DialogDescription className="bg-bolt-elements-background-depth-3 max-w-sm rounded-md p-1 border border-bolt-elements-borderColor">
|
||||
|
153
app/lib/hooks/StickToBottom.tsx
Normal file
153
app/lib/hooks/StickToBottom.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
/*
|
||||
*!---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) StackBlitz. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
type GetTargetScrollTop,
|
||||
type ScrollToBottom,
|
||||
type StickToBottomOptions,
|
||||
type StickToBottomState,
|
||||
type StopScroll,
|
||||
useStickToBottom,
|
||||
} from './useStickToBottom';
|
||||
|
||||
export interface StickToBottomContext {
|
||||
contentRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
||||
scrollRef: React.MutableRefObject<HTMLElement | null> & React.RefCallback<HTMLElement>;
|
||||
scrollToBottom: ScrollToBottom;
|
||||
stopScroll: StopScroll;
|
||||
isAtBottom: boolean;
|
||||
escapedFromLock: boolean;
|
||||
get targetScrollTop(): GetTargetScrollTop | null;
|
||||
set targetScrollTop(targetScrollTop: GetTargetScrollTop | null);
|
||||
state: StickToBottomState;
|
||||
}
|
||||
|
||||
const StickToBottomContext = createContext<StickToBottomContext | null>(null);
|
||||
|
||||
export interface StickToBottomProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>,
|
||||
StickToBottomOptions {
|
||||
contextRef?: React.Ref<StickToBottomContext>;
|
||||
instance?: ReturnType<typeof useStickToBottom>;
|
||||
children: ((context: StickToBottomContext) => ReactNode) | ReactNode;
|
||||
}
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
export function StickToBottom({
|
||||
instance,
|
||||
children,
|
||||
resize,
|
||||
initial,
|
||||
mass,
|
||||
damping,
|
||||
stiffness,
|
||||
targetScrollTop: currentTargetScrollTop,
|
||||
contextRef,
|
||||
...props
|
||||
}: StickToBottomProps) {
|
||||
const customTargetScrollTop = useRef<GetTargetScrollTop | null>(null);
|
||||
|
||||
const targetScrollTop = React.useCallback<GetTargetScrollTop>(
|
||||
(target, elements) => {
|
||||
const get = context?.targetScrollTop ?? currentTargetScrollTop;
|
||||
return get?.(target, elements) ?? target;
|
||||
},
|
||||
[currentTargetScrollTop],
|
||||
);
|
||||
|
||||
const defaultInstance = useStickToBottom({
|
||||
mass,
|
||||
damping,
|
||||
stiffness,
|
||||
resize,
|
||||
initial,
|
||||
targetScrollTop,
|
||||
});
|
||||
|
||||
const { scrollRef, contentRef, scrollToBottom, stopScroll, isAtBottom, escapedFromLock, state } =
|
||||
instance ?? defaultInstance;
|
||||
|
||||
const context = useMemo<StickToBottomContext>(
|
||||
() => ({
|
||||
scrollToBottom,
|
||||
stopScroll,
|
||||
scrollRef,
|
||||
isAtBottom,
|
||||
escapedFromLock,
|
||||
contentRef,
|
||||
state,
|
||||
get targetScrollTop() {
|
||||
return customTargetScrollTop.current;
|
||||
},
|
||||
set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) {
|
||||
customTargetScrollTop.current = targetScrollTop;
|
||||
},
|
||||
}),
|
||||
[scrollToBottom, isAtBottom, contentRef, scrollRef, stopScroll, escapedFromLock, state],
|
||||
);
|
||||
|
||||
useImperativeHandle(contextRef, () => context, [context]);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(scrollRef.current).overflow === 'visible') {
|
||||
scrollRef.current.style.overflow = 'auto';
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StickToBottomContext.Provider value={context}>
|
||||
<div {...props}>{typeof children === 'function' ? children(context) : children}</div>
|
||||
</StickToBottomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StickToBottomContentProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
children: ((context: StickToBottomContext) => ReactNode) | ReactNode;
|
||||
}
|
||||
|
||||
function Content({ children, ...props }: StickToBottomContentProps) {
|
||||
const context = useStickToBottomContext();
|
||||
|
||||
return (
|
||||
<div ref={context.scrollRef} className="w-full h-auto">
|
||||
<div {...props} ref={context.contentRef}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
StickToBottom.Content = Content;
|
||||
|
||||
/**
|
||||
* Use this hook inside a <StickToBottom> component to gain access to whether the component is at the bottom of the scrollable area.
|
||||
*/
|
||||
export function useStickToBottomContext() {
|
||||
const context = useContext(StickToBottomContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('use-stick-to-bottom component context must be used within a StickToBottom component');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
export * from './useMessageParser';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useShortcuts';
|
||||
export * from './useSnapScroll';
|
||||
export * from './StickToBottom';
|
||||
export * from './useEditChatDescription';
|
||||
export { default } from './useViewport';
|
||||
export { useUpdateCheck } from './useUpdateCheck';
|
||||
|
@ -1,155 +0,0 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface ScrollOptions {
|
||||
duration?: number;
|
||||
easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
|
||||
cubicBezier?: [number, number, number, number];
|
||||
bottomThreshold?: number;
|
||||
}
|
||||
|
||||
export function useSnapScroll(options: ScrollOptions = {}) {
|
||||
const {
|
||||
duration = 800,
|
||||
easing = 'ease-in-out',
|
||||
cubicBezier = [0.42, 0, 0.58, 1],
|
||||
bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom"
|
||||
} = options;
|
||||
|
||||
const autoScrollRef = useRef(true);
|
||||
const scrollNodeRef = useRef<HTMLDivElement>();
|
||||
const onScrollRef = useRef<() => void>();
|
||||
const observerRef = useRef<ResizeObserver>();
|
||||
const animationFrameRef = useRef<number>();
|
||||
const lastScrollTopRef = useRef<number>(0);
|
||||
|
||||
const smoothScroll = useCallback(
|
||||
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
||||
const startPosition = element.scrollTop;
|
||||
const distance = targetPosition - startPosition;
|
||||
const startTime = performance.now();
|
||||
|
||||
const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1];
|
||||
|
||||
const cubicBezierFunction = (t: number): number => {
|
||||
const [, y1, , y2] = bezierPoints;
|
||||
|
||||
/*
|
||||
* const cx = 3 * x1;
|
||||
* const bx = 3 * (x2 - x1) - cx;
|
||||
* const ax = 1 - cx - bx;
|
||||
*/
|
||||
|
||||
const cy = 3 * y1;
|
||||
const by = 3 * (y2 - y1) - cy;
|
||||
const ay = 1 - cy - by;
|
||||
|
||||
// const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
|
||||
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
|
||||
|
||||
return sampleCurveY(t);
|
||||
};
|
||||
|
||||
const animation = (currentTime: number) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
const easedProgress = cubicBezierFunction(progress);
|
||||
const newPosition = startPosition + distance * easedProgress;
|
||||
|
||||
// Only scroll if auto-scroll is still enabled
|
||||
if (autoScrollRef.current) {
|
||||
element.scrollTop = newPosition;
|
||||
}
|
||||
|
||||
if (progress < 1 && autoScrollRef.current) {
|
||||
animationFrameRef.current = requestAnimationFrame(animation);
|
||||
}
|
||||
};
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animation);
|
||||
},
|
||||
[cubicBezier],
|
||||
);
|
||||
|
||||
const isScrolledToBottom = useCallback(
|
||||
(element: HTMLDivElement): boolean => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
return scrollHeight - scrollTop - clientHeight <= bottomThreshold;
|
||||
},
|
||||
[bottomThreshold],
|
||||
);
|
||||
|
||||
const messageRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoScrollRef.current && scrollNodeRef.current) {
|
||||
const { scrollHeight, clientHeight } = scrollNodeRef.current;
|
||||
const scrollTarget = scrollHeight - clientHeight;
|
||||
|
||||
smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
} else {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = undefined;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
[duration, easing, smoothScroll],
|
||||
);
|
||||
|
||||
const scrollRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
onScrollRef.current = () => {
|
||||
const { scrollTop } = node;
|
||||
|
||||
// Detect scroll direction
|
||||
const isScrollingUp = scrollTop < lastScrollTopRef.current;
|
||||
|
||||
// Update auto-scroll based on scroll direction and position
|
||||
if (isScrollingUp) {
|
||||
// Disable auto-scroll when scrolling up
|
||||
autoScrollRef.current = false;
|
||||
} else if (isScrolledToBottom(node)) {
|
||||
// Re-enable auto-scroll when manually scrolled to bottom
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
|
||||
// Store current scroll position for next comparison
|
||||
lastScrollTopRef.current = scrollTop;
|
||||
};
|
||||
|
||||
node.addEventListener('scroll', onScrollRef.current);
|
||||
scrollNodeRef.current = node;
|
||||
} else {
|
||||
if (onScrollRef.current && scrollNodeRef.current) {
|
||||
scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current);
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
|
||||
scrollNodeRef.current = undefined;
|
||||
onScrollRef.current = undefined;
|
||||
}
|
||||
},
|
||||
[isScrolledToBottom],
|
||||
);
|
||||
|
||||
return [messageRef, scrollRef] as const;
|
||||
}
|
613
app/lib/hooks/useStickToBottom.tsx
Normal file
613
app/lib/hooks/useStickToBottom.tsx
Normal file
@ -0,0 +1,613 @@
|
||||
/*
|
||||
*!---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) StackBlitz. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
type DependencyList,
|
||||
type MutableRefObject,
|
||||
type RefCallback,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface StickToBottomState {
|
||||
scrollTop: number;
|
||||
lastScrollTop?: number;
|
||||
ignoreScrollToTop?: number;
|
||||
targetScrollTop: number;
|
||||
calculatedTargetScrollTop: number;
|
||||
scrollDifference: number;
|
||||
resizeDifference: number;
|
||||
|
||||
animation?: {
|
||||
behavior: 'instant' | Required<SpringAnimation>;
|
||||
ignoreEscapes: boolean;
|
||||
promise: Promise<boolean>;
|
||||
};
|
||||
lastTick?: number;
|
||||
velocity: number;
|
||||
accumulated: number;
|
||||
|
||||
escapedFromLock: boolean;
|
||||
isAtBottom: boolean;
|
||||
isNearBottom: boolean;
|
||||
|
||||
resizeObserver?: ResizeObserver;
|
||||
}
|
||||
|
||||
const DEFAULT_SPRING_ANIMATION = {
|
||||
/**
|
||||
* A value from 0 to 1, on how much to damp the animation.
|
||||
* 0 means no damping, 1 means full damping.
|
||||
*
|
||||
* @default 0.7
|
||||
*/
|
||||
damping: 0.7,
|
||||
|
||||
/**
|
||||
* The stiffness of how fast/slow the animation gets up to speed.
|
||||
*
|
||||
* @default 0.05
|
||||
*/
|
||||
stiffness: 0.05,
|
||||
|
||||
/**
|
||||
* The inertial mass associated with the animation.
|
||||
* Higher numbers make the animation slower.
|
||||
*
|
||||
* @default 1.25
|
||||
*/
|
||||
mass: 1.25,
|
||||
};
|
||||
|
||||
export interface SpringAnimation extends Partial<typeof DEFAULT_SPRING_ANIMATION> {}
|
||||
|
||||
export type Animation = ScrollBehavior | SpringAnimation;
|
||||
|
||||
export interface ScrollElements {
|
||||
scrollElement: HTMLElement;
|
||||
contentElement: HTMLElement;
|
||||
}
|
||||
|
||||
export type GetTargetScrollTop = (targetScrollTop: number, context: ScrollElements) => number;
|
||||
|
||||
export interface StickToBottomOptions extends SpringAnimation {
|
||||
resize?: Animation;
|
||||
initial?: Animation | boolean;
|
||||
targetScrollTop?: GetTargetScrollTop;
|
||||
}
|
||||
|
||||
export type ScrollToBottomOptions =
|
||||
| ScrollBehavior
|
||||
| {
|
||||
animation?: Animation;
|
||||
|
||||
/**
|
||||
* Whether to wait for any existing scrolls to finish before
|
||||
* performing this one. Or if a millisecond is passed,
|
||||
* it will wait for that duration before performing the scroll.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
wait?: boolean | number;
|
||||
|
||||
/**
|
||||
* Whether to prevent the user from escaping the scroll,
|
||||
* by scrolling up with their mouse.
|
||||
*/
|
||||
ignoreEscapes?: boolean;
|
||||
|
||||
/**
|
||||
* Only scroll to the bottom if we're already at the bottom.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
preserveScrollPosition?: boolean;
|
||||
|
||||
/**
|
||||
* The extra duration in ms that this scroll event should persist for.
|
||||
* (in addition to the time that it takes to get to the bottom)
|
||||
*
|
||||
* Not to be confused with the duration of the animation -
|
||||
* for that you should adjust the animation option.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
duration?: number | Promise<void>;
|
||||
};
|
||||
|
||||
export type ScrollToBottom = (scrollOptions?: ScrollToBottomOptions) => Promise<boolean> | boolean;
|
||||
export type StopScroll = () => void;
|
||||
|
||||
const STICK_TO_BOTTOM_OFFSET_PX = 70;
|
||||
const SIXTY_FPS_INTERVAL_MS = 1000 / 60;
|
||||
const RETAIN_ANIMATION_DURATION_MS = 350;
|
||||
|
||||
let mouseDown = false;
|
||||
|
||||
globalThis.document?.addEventListener('mousedown', () => {
|
||||
mouseDown = true;
|
||||
});
|
||||
|
||||
globalThis.document?.addEventListener('mouseup', () => {
|
||||
mouseDown = false;
|
||||
});
|
||||
|
||||
globalThis.document?.addEventListener('click', () => {
|
||||
mouseDown = false;
|
||||
});
|
||||
|
||||
export const useStickToBottom = (options: StickToBottomOptions = {}) => {
|
||||
const [escapedFromLock, updateEscapedFromLock] = useState(false);
|
||||
const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false);
|
||||
const [isNearBottom, setIsNearBottom] = useState(false);
|
||||
|
||||
const optionsRef = useRef<StickToBottomOptions>(null!);
|
||||
optionsRef.current = options;
|
||||
|
||||
const isSelecting = useCallback(() => {
|
||||
if (!mouseDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || !selection.rangeCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return (
|
||||
range.commonAncestorContainer.contains(scrollRef.current) ||
|
||||
scrollRef.current?.contains(range.commonAncestorContainer)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setIsAtBottom = useCallback((isAtBottom: boolean) => {
|
||||
state.isAtBottom = isAtBottom;
|
||||
updateIsAtBottom(isAtBottom);
|
||||
}, []);
|
||||
|
||||
const setEscapedFromLock = useCallback((escapedFromLock: boolean) => {
|
||||
state.escapedFromLock = escapedFromLock;
|
||||
updateEscapedFromLock(escapedFromLock);
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
||||
const state = useMemo<StickToBottomState>(() => {
|
||||
let lastCalculation: { targetScrollTop: number; calculatedScrollTop: number } | undefined;
|
||||
|
||||
return {
|
||||
escapedFromLock,
|
||||
isAtBottom,
|
||||
resizeDifference: 0,
|
||||
accumulated: 0,
|
||||
velocity: 0,
|
||||
listeners: new Set(),
|
||||
|
||||
get scrollTop() {
|
||||
return scrollRef.current?.scrollTop ?? 0;
|
||||
},
|
||||
set scrollTop(scrollTop: number) {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollTop;
|
||||
state.ignoreScrollToTop = scrollRef.current.scrollTop;
|
||||
}
|
||||
},
|
||||
|
||||
get targetScrollTop() {
|
||||
if (!scrollRef.current || !contentRef.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight;
|
||||
},
|
||||
get calculatedTargetScrollTop() {
|
||||
if (!scrollRef.current || !contentRef.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { targetScrollTop } = this;
|
||||
|
||||
if (!options.targetScrollTop) {
|
||||
return targetScrollTop;
|
||||
}
|
||||
|
||||
if (lastCalculation?.targetScrollTop === targetScrollTop) {
|
||||
return lastCalculation.calculatedScrollTop;
|
||||
}
|
||||
|
||||
const calculatedScrollTop = Math.max(
|
||||
Math.min(
|
||||
options.targetScrollTop(targetScrollTop, {
|
||||
scrollElement: scrollRef.current,
|
||||
contentElement: contentRef.current,
|
||||
}),
|
||||
targetScrollTop,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
lastCalculation = { targetScrollTop, calculatedScrollTop };
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
lastCalculation = undefined;
|
||||
});
|
||||
|
||||
return calculatedScrollTop;
|
||||
},
|
||||
|
||||
get scrollDifference() {
|
||||
return this.calculatedTargetScrollTop - this.scrollTop;
|
||||
},
|
||||
|
||||
get isNearBottom() {
|
||||
return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX;
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback<ScrollToBottom>(
|
||||
(scrollOptions = {}) => {
|
||||
if (typeof scrollOptions === 'string') {
|
||||
scrollOptions = { animation: scrollOptions };
|
||||
}
|
||||
|
||||
if (!scrollOptions.preserveScrollPosition) {
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
|
||||
const waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0);
|
||||
const behavior = mergeAnimations(optionsRef.current, scrollOptions.animation);
|
||||
const { ignoreEscapes = false } = scrollOptions;
|
||||
|
||||
let durationElapsed: number;
|
||||
let startTarget = state.calculatedTargetScrollTop;
|
||||
|
||||
if (scrollOptions.duration instanceof Promise) {
|
||||
scrollOptions.duration.finally(() => {
|
||||
durationElapsed = Date.now();
|
||||
});
|
||||
} else {
|
||||
durationElapsed = waitElapsed + (scrollOptions.duration ?? 0);
|
||||
}
|
||||
|
||||
const next = async (): Promise<boolean> => {
|
||||
const promise = new Promise(requestAnimationFrame).then(() => {
|
||||
if (!state.isAtBottom) {
|
||||
state.animation = undefined;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const { scrollTop } = state;
|
||||
const tick = performance.now();
|
||||
const tickDelta = (tick - (state.lastTick ?? tick)) / SIXTY_FPS_INTERVAL_MS;
|
||||
state.animation ||= { behavior, promise, ignoreEscapes };
|
||||
|
||||
if (state.animation.behavior === behavior) {
|
||||
state.lastTick = tick;
|
||||
}
|
||||
|
||||
if (isSelecting()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (waitElapsed > Date.now()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop)) {
|
||||
if (state.animation?.behavior === behavior) {
|
||||
if (behavior === 'instant') {
|
||||
state.scrollTop = state.calculatedTargetScrollTop;
|
||||
return next();
|
||||
}
|
||||
|
||||
state.velocity =
|
||||
(behavior.damping * state.velocity + behavior.stiffness * state.scrollDifference) / behavior.mass;
|
||||
state.accumulated += state.velocity * tickDelta;
|
||||
state.scrollTop += state.accumulated;
|
||||
|
||||
if (state.scrollTop !== scrollTop) {
|
||||
state.accumulated = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
if (durationElapsed > Date.now()) {
|
||||
startTarget = state.calculatedTargetScrollTop;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
state.animation = undefined;
|
||||
|
||||
/**
|
||||
* If we're still below the target, then queue
|
||||
* up another scroll to the bottom with the last
|
||||
* requested animatino.
|
||||
*/
|
||||
if (state.scrollTop < state.calculatedTargetScrollTop) {
|
||||
return scrollToBottom({
|
||||
animation: mergeAnimations(optionsRef.current, optionsRef.current.resize),
|
||||
ignoreEscapes,
|
||||
duration: Math.max(0, durationElapsed - Date.now()) || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return state.isAtBottom;
|
||||
});
|
||||
|
||||
return promise.then((isAtBottom) => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!state.animation) {
|
||||
state.lastTick = undefined;
|
||||
state.velocity = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return isAtBottom;
|
||||
});
|
||||
};
|
||||
|
||||
if (scrollOptions.wait !== true) {
|
||||
state.animation = undefined;
|
||||
}
|
||||
|
||||
if (state.animation?.behavior === behavior) {
|
||||
return state.animation.promise;
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
[setIsAtBottom, isSelecting, state],
|
||||
);
|
||||
|
||||
const stopScroll = useCallback(() => {
|
||||
setEscapedFromLock(true);
|
||||
setIsAtBottom(false);
|
||||
}, [setEscapedFromLock, setIsAtBottom]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
({ target }: Event) => {
|
||||
if (target !== scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, ignoreScrollToTop } = state;
|
||||
let { lastScrollTop = scrollTop } = state;
|
||||
|
||||
state.lastScrollTop = scrollTop;
|
||||
state.ignoreScrollToTop = undefined;
|
||||
|
||||
if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) {
|
||||
/**
|
||||
* When the user scrolls up while the animation plays, the `scrollTop` may
|
||||
* not come in separate events; if this happens, to make sure `isScrollingUp`
|
||||
* is correct, set the lastScrollTop to the ignored event.
|
||||
*/
|
||||
lastScrollTop = ignoreScrollToTop;
|
||||
}
|
||||
|
||||
setIsNearBottom(state.isNearBottom);
|
||||
|
||||
/**
|
||||
* Scroll events may come before a ResizeObserver event,
|
||||
* so in order to ignore resize events correctly we use a
|
||||
* timeout.
|
||||
*
|
||||
* @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228
|
||||
*/
|
||||
setTimeout(() => {
|
||||
/**
|
||||
* When theres a resize difference ignore the resize event.
|
||||
*/
|
||||
if (state.resizeDifference || scrollTop === ignoreScrollToTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelecting()) {
|
||||
setEscapedFromLock(true);
|
||||
setIsAtBottom(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isScrollingDown = scrollTop > lastScrollTop;
|
||||
const isScrollingUp = scrollTop < lastScrollTop;
|
||||
|
||||
if (state.animation?.ignoreEscapes) {
|
||||
state.scrollTop = lastScrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScrollingUp) {
|
||||
setEscapedFromLock(true);
|
||||
setIsAtBottom(false);
|
||||
}
|
||||
|
||||
if (isScrollingDown) {
|
||||
setEscapedFromLock(false);
|
||||
}
|
||||
|
||||
if (!state.escapedFromLock && state.isNearBottom) {
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}, 1);
|
||||
},
|
||||
[setEscapedFromLock, setIsAtBottom, isSelecting, state],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
({ target, deltaY }: WheelEvent) => {
|
||||
let element = target as HTMLElement;
|
||||
|
||||
while (!['scroll', 'auto'].includes(getComputedStyle(element).overflow)) {
|
||||
if (!element.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser may cancel the scrolling from the mouse wheel
|
||||
* if we update it from the animation in meantime.
|
||||
* To prevent this, always escape when the wheel is scrolled up.
|
||||
*/
|
||||
if (
|
||||
element === scrollRef.current &&
|
||||
deltaY < 0 &&
|
||||
scrollRef.current.scrollHeight > scrollRef.current.clientHeight &&
|
||||
!state.animation?.ignoreEscapes
|
||||
) {
|
||||
setEscapedFromLock(true);
|
||||
setIsAtBottom(false);
|
||||
}
|
||||
},
|
||||
[setEscapedFromLock, setIsAtBottom, state],
|
||||
);
|
||||
|
||||
const scrollRef = useRefCallback((scroll) => {
|
||||
scrollRef.current?.removeEventListener('scroll', handleScroll);
|
||||
scrollRef.current?.removeEventListener('wheel', handleWheel);
|
||||
scroll?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
scroll?.addEventListener('wheel', handleWheel, { passive: true });
|
||||
}, []);
|
||||
|
||||
const contentRef = useRefCallback((content) => {
|
||||
state.resizeObserver?.disconnect();
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousHeight: number | undefined;
|
||||
|
||||
state.resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { height } = entry.contentRect;
|
||||
const difference = height - (previousHeight ?? height);
|
||||
|
||||
state.resizeDifference = difference;
|
||||
|
||||
/**
|
||||
* Sometimes the browser can overscroll past the target,
|
||||
* so check for this and adjust appropriately.
|
||||
*/
|
||||
if (state.scrollTop > state.targetScrollTop) {
|
||||
state.scrollTop = state.targetScrollTop;
|
||||
}
|
||||
|
||||
setIsNearBottom(state.isNearBottom);
|
||||
|
||||
if (difference >= 0) {
|
||||
/**
|
||||
* If it's a positive resize, scroll to the bottom when
|
||||
* we're already at the bottom.
|
||||
*/
|
||||
const animation = mergeAnimations(
|
||||
optionsRef.current,
|
||||
previousHeight ? optionsRef.current.resize : optionsRef.current.initial,
|
||||
);
|
||||
|
||||
scrollToBottom({
|
||||
animation,
|
||||
wait: true,
|
||||
preserveScrollPosition: true,
|
||||
duration: animation === 'instant' ? undefined : RETAIN_ANIMATION_DURATION_MS,
|
||||
});
|
||||
} else {
|
||||
/**
|
||||
* Else if it's a negative resize, check if we're near the bottom
|
||||
* if we are want to un-escape from the lock, because the resize
|
||||
* could have caused the container to be at the bottom.
|
||||
*/
|
||||
if (state.isNearBottom) {
|
||||
setEscapedFromLock(false);
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}
|
||||
|
||||
previousHeight = height;
|
||||
|
||||
/**
|
||||
* Reset the resize difference after the scroll event
|
||||
* has fired. Requires a rAF to wait for the scroll event,
|
||||
* and a setTimeout to wait for the other timeout we have in
|
||||
* resizeObserver in case the scroll event happens after the
|
||||
* resize event.
|
||||
*/
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (state.resizeDifference === difference) {
|
||||
state.resizeDifference = 0;
|
||||
}
|
||||
}, 1);
|
||||
});
|
||||
});
|
||||
|
||||
state.resizeObserver?.observe(content);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
contentRef,
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
stopScroll,
|
||||
isAtBottom: isAtBottom || isNearBottom,
|
||||
isNearBottom,
|
||||
escapedFromLock,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
function useRefCallback<T extends (ref: HTMLElement | null) => any>(callback: T, deps: DependencyList) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
|
||||
const result = useCallback((ref: HTMLElement | null) => {
|
||||
result.current = ref;
|
||||
return callback(ref);
|
||||
}, deps) as any as MutableRefObject<HTMLElement | null> & RefCallback<HTMLElement>;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const animationCache = new Map<string, Readonly<Required<SpringAnimation>>>();
|
||||
|
||||
function mergeAnimations(...animations: (Animation | boolean | undefined)[]) {
|
||||
const result = { ...DEFAULT_SPRING_ANIMATION };
|
||||
let instant = false;
|
||||
|
||||
for (const animation of animations) {
|
||||
if (animation === 'instant') {
|
||||
instant = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof animation !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
instant = false;
|
||||
|
||||
result.damping = animation.damping ?? result.damping;
|
||||
result.stiffness = animation.stiffness ?? result.stiffness;
|
||||
result.mass = animation.mass ?? result.mass;
|
||||
}
|
||||
|
||||
const key = JSON.stringify(result);
|
||||
|
||||
if (!animationCache.has(key)) {
|
||||
animationCache.set(key, Object.freeze(result));
|
||||
}
|
||||
|
||||
return instant ? 'instant' : animationCache.get(key)!;
|
||||
}
|
Loading…
Reference in New Issue
Block a user