diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index c2495ce5..eafe4f03 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -1,9 +1,12 @@ import { Button } from "@usememos/mui"; import { ArrowDownIcon, LoaderIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, useMemo } from "react"; +import { useLocation } from "react-router-dom"; import PullToRefresh from "react-simple-pull-to-refresh"; +import ScrollToTop from "@/components/ScrollToTop"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; +import { Routes } from "@/router"; import { useMemoList, useMemoStore } from "@/store/v1"; import { Memo } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; @@ -26,11 +29,33 @@ const PagedMemoList = (props: Props) => { const { md } = useResponsiveWidth(); const memoStore = useMemoStore(); const memoList = useMemoList(); + const containerRef = useRef(null); + const [containerRightOffset, setContainerRightOffset] = useState(0); const [state, setState] = useState({ isRequesting: true, // Initial request nextPageToken: "", }); const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value; + const location = useLocation(); + + const shouldShowScrollToTop = useMemo( + () => [Routes.ROOT, Routes.EXPLORE, Routes.ARCHIVED].includes(location.pathname as Routes) || location.pathname.startsWith("/u/"), + [location.pathname], + ); + + useEffect(() => { + const updateOffset = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const offset = window.innerWidth - rect.right; + setContainerRightOffset(offset); + } + }; + + updateOffset(); + window.addEventListener("resize", updateOffset); + return () => window.removeEventListener("resize", updateOffset); + }, []); const fetchMoreMemos = async (nextPageToken: string) => { setState((state) => ({ ...state, isRequesting: true })); @@ -56,7 +81,7 @@ const PagedMemoList = (props: Props) => { }, [props.filter, props.pageSize]); const children = ( - <> +
{sortedMemoList.map((memo) => props.renderer(memo))} {state.isRequesting && (
@@ -77,7 +102,8 @@ const PagedMemoList = (props: Props) => {

{t("message.no-data")}

)} - + +
); // In case of md screen, we don't need pull to refresh. diff --git a/web/src/components/ScrollToTop.tsx b/web/src/components/ScrollToTop.tsx new file mode 100644 index 00000000..81244f30 --- /dev/null +++ b/web/src/components/ScrollToTop.tsx @@ -0,0 +1,64 @@ +import clsx from "clsx"; +import { ArrowUpIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface ScrollToTopProps { + className?: string; + style?: React.CSSProperties; + enabled?: boolean; +} + +const ScrollToTop = ({ className, style, enabled = true }: ScrollToTopProps) => { + const [isVisible, setIsVisible] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + const handleScroll = () => { + const shouldBeVisible = window.scrollY > 400; + if (shouldBeVisible !== isVisible) { + if (shouldBeVisible) { + setShouldRender(true); + setTimeout(() => setIsVisible(true), 50); + } else { + setIsVisible(false); + setTimeout(() => setShouldRender(false), 200); + } + } + }; + + if (enabled) { + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + } + }, [enabled, isVisible]); + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }; + + if (!enabled || !shouldRender) { + return null; + } + + return ( + + ); +}; + +export default ScrollToTop;