rdesign/frontend/node_modules/use-stick-to-bottom/dist/useStickToBottom.js

404 lines
16 KiB
JavaScript

/*!---------------------------------------------------------------------------------------------
* Copyright (c) StackBlitz. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { useCallback, useMemo, useRef, useState, } from "react";
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,
};
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 = {}) => {
const [escapedFromLock, updateEscapedFromLock] = useState(false);
const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false);
const [isNearBottom, setIsNearBottom] = useState(false);
const optionsRef = useRef(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) => {
state.isAtBottom = isAtBottom;
updateIsAtBottom(isAtBottom);
}, []);
const setEscapedFromLock = useCallback((escapedFromLock) => {
state.escapedFromLock = escapedFromLock;
updateEscapedFromLock(escapedFromLock);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
const state = useMemo(() => {
let lastCalculation;
return {
escapedFromLock,
isAtBottom,
resizeDifference: 0,
accumulated: 0,
velocity: 0,
listeners: new Set(),
get scrollTop() {
return scrollRef.current?.scrollTop ?? 0;
},
set scrollTop(scrollTop) {
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((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;
let startTarget = state.calculatedTargetScrollTop;
if (scrollOptions.duration instanceof Promise) {
scrollOptions.duration.finally(() => {
durationElapsed = Date.now();
});
}
else {
durationElapsed = waitElapsed + (scrollOptions.duration ?? 0);
}
const next = async () => {
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 || (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 }) => {
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 }) => {
let element = target;
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;
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(callback, deps) {
// biome-ignore lint/correctness/useExhaustiveDependencies: not needed
const result = useCallback((ref) => {
result.current = ref;
return callback(ref);
}, deps);
return result;
}
const animationCache = new Map();
function mergeAnimations(...animations) {
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);
}