From 2eb33973947c9526366efbfdb243cd34beb021d6 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Thu, 28 Dec 2023 02:13:14 -0800 Subject: [PATCH] Scroll-To-Bottom: complete Framework. Fixes #304, Fixes #60, Fixes #59 --- .../scroll-to-bottom/ScrollToBottom.tsx | 149 ++++++++++++------ .../scroll-to-bottom/ScrollToBottomButton.tsx | 68 ++++---- .../scroll-to-bottom/useScrollToBottom.tsx | 10 +- 3 files changed, 140 insertions(+), 87 deletions(-) diff --git a/src/apps/chat/components/scroll-to-bottom/ScrollToBottom.tsx b/src/apps/chat/components/scroll-to-bottom/ScrollToBottom.tsx index 924dbdc0e..121dd4c3b 100644 --- a/src/apps/chat/components/scroll-to-bottom/ScrollToBottom.tsx +++ b/src/apps/chat/components/scroll-to-bottom/ScrollToBottom.tsx @@ -1,3 +1,21 @@ +/** + * Copyright (c) 2023-2024 Enrico Ros + * + * This subsystem is responsible for 'snap-to-bottom' and 'scroll-to-bottom' features, + * with an animated, gradual scroll. + * + * See the `ScrollToBottomButton` component for the button that triggers the scroll. + * + * Example usage: + * + * + * + * + * + * Within the Context (children components), functions are made available by using: + * const { notifyBooting, setStickToBottom } = useScrollToBottom(); + * + */ import * as React from 'react'; import { Box } from '@mui/joy'; @@ -9,10 +27,25 @@ import { ScrollToBottomState, UseScrollToBottomProvider } from './useScrollToBot // set this to true to debug this component -const DEBUG_SCROLL_TO_BOTTOM = true; +const DEBUG_SCROLL_TO_BOTTOM = false; +// NOTE: in Chrome a wheel scroll event is 100px +const USER_STICKY_MARGIN = 60; + +// during the 'booting' timeout, scrolls happen instantly instead of smoothly const BOOTING_TIMEOUT = 400; -const USER_STICKY_MARGIN = 10; + + +function DebugBorderBox(props: { heightPx: number, color: string }) { + return ( + + ); +} export function ScrollToBottom(props: { @@ -54,7 +87,7 @@ export function ScrollToBottom(props: { const scrollable = scrollableElementRef.current; if (scrollable) { if (DEBUG_SCROLL_TO_BOTTOM) - console.log(' - doScrollToBottom()', { scrollHeight: scrollable.scrollHeight, offsetHeight: scrollable.offsetHeight }); + console.log(' -> doScrollToBottom()', { scrollHeight: scrollable.scrollHeight, offsetHeight: scrollable.offsetHeight }); // eat the next scroll event isProgrammaticScroll.current = true; @@ -72,18 +105,43 @@ export function ScrollToBottom(props: { React.useEffect(() => { if (!state.booting || !isBrowser) return; - const clearBootingHandler = () => { + const _clearBootingHandler = () => { if (DEBUG_SCROLL_TO_BOTTOM) - console.log(' - booting complete, clearing state'); + console.log(' -> booting done'); + + setState(state => ({ ...state, booting: false })); - setState((state): ScrollToBottomState => ({ ...state, booting: false })); + if (bootToBottom) + doScrollToBottom(); }; // cancelable listener - const timeout = window.setTimeout(clearBootingHandler, BOOTING_TIMEOUT); + const timeout = window.setTimeout(_clearBootingHandler, BOOTING_TIMEOUT); return () => clearTimeout(timeout); - }, [state.booting]); + }, [bootToBottom, doScrollToBottom, state.booting]); + /** + * Children elements resize event listener + * - note that the 'scrollable' will likely have a fixed size, while its children are the ones who become scrollable + */ + React.useEffect(() => { + const scrollable = scrollableElementRef.current; + if (!scrollable) return; + + const _containerResizeObserver = new ResizeObserver(entries => { + if (DEBUG_SCROLL_TO_BOTTOM) + console.log(' -> scrollable children resized', entries.length); + + if (entries.length > 0 && state.stickToBottom) + doScrollToBottom(); + }); + + + // cancelable observer of resize of scrollable's children elements + Array.from(scrollable.children).forEach(child => _containerResizeObserver.observe(child)); + return () => _containerResizeObserver.disconnect(); + + }, [state.stickToBottom, doScrollToBottom]); /** * (User) Scroll events listener @@ -95,7 +153,7 @@ export function ScrollToBottom(props: { const scrollable = scrollableElementRef.current; if (!scrollable) return; - const scrollEventsListener = () => { + const _scrollEventsListener = () => { // ignore scroll events during programmatic scrolls // NOTE: some will go through, but somewhat the framework is stable if (isProgrammaticScroll.current) { @@ -110,59 +168,48 @@ export function ScrollToBottom(props: { const stickToBottom = atBottom; // update state only if anything changed - if (state.atBottom !== atBottom || state.stickToBottom !== stickToBottom) - setState(state => ({ ...state, stickToBottom, atBottom })); + setState(state => (state.stickToBottom !== stickToBottom || state.atBottom !== atBottom) + ? ({ ...state, stickToBottom, atBottom }) + : state, + ); }; - // cancelable listener (user and programatic scroll events) - scrollable.addEventListener('scroll', scrollEventsListener); - return () => scrollable.removeEventListener('scroll', scrollEventsListener); + // _scrollEventsListener(true); - }, [state.atBottom, state.booting, state.stickToBottom]); - - - /** - * Underlying element resize events listener - */ - React.useEffect(() => { - const scrollable = scrollableElementRef.current; - if (!scrollable) return; + // cancelable listener (user and programatic scroll events) + scrollable.addEventListener('scroll', _scrollEventsListener); + return () => scrollable.removeEventListener('scroll', _scrollEventsListener); - const resizeObserver = new ResizeObserver(entries => { - const resizedEntry = entries.find(entry => entry.target === scrollable); - if (!resizedEntry) return; + }, [state.booting]); - if (DEBUG_SCROLL_TO_BOTTOM) - console.log('-> scrollable resized', { ...resizedEntry.borderBoxSize }); - if (state.stickToBottom) - doScrollToBottom(); - }); + // actions for this context - // cancelable listener (resize of scrollable element) - resizeObserver.observe(scrollable); - return () => resizeObserver.disconnect(); + const notifyBooting = React.useCallback(() => { + if (bootToBottom) + setState(state => state.booting ? state : ({ ...state, booting: true })); + }, [bootToBottom]); - }, [state.stickToBottom, doScrollToBottom]); + /*const notifyContentUpdated = React.useCallback(() => { + if (DEBUG_SCROLL_TO_BOTTOM) + console.log('-= notifyContentUpdated'); + if (state.stickToBottom) + doScrollToBottom(); + }, [doScrollToBottom, state.stickToBottom]);*/ - // actions for this context + const setStickToBottom = React.useCallback((stickToBottom: boolean) => { + if (DEBUG_SCROLL_TO_BOTTOM) + console.log('-= setStickToBottom', stickToBottom); - const notifyBooting = React.useCallback(() => { - // update state only if we are using the booting framework - if (bootToBottom) { - setState(state => ({ ...state, booting: true })); - } - }, [bootToBottom]); + setState(state => state.stickToBottom !== stickToBottom + ? ({ ...state, stickToBottom }) + : state, + ); - const setStickToBottom = React.useCallback((stick: boolean) => { - // update state only if anything changed, and scroll to bottom if requested - if (state.stickToBottom != stick) { - setState(state => ({ ...state, stickToBottom: stick })); - if (stick) - doScrollToBottom(); - } - }, [doScrollToBottom, state.stickToBottom]); + if (stickToBottom) + doScrollToBottom(); + }, [doScrollToBottom]); return ( @@ -173,6 +220,8 @@ export function ScrollToBottom(props: { }}> {props.children} + {DEBUG_SCROLL_TO_BOTTOM && } + {DEBUG_SCROLL_TO_BOTTOM && } ); diff --git a/src/apps/chat/components/scroll-to-bottom/ScrollToBottomButton.tsx b/src/apps/chat/components/scroll-to-bottom/ScrollToBottomButton.tsx index aec2c71c4..d1468b819 100644 --- a/src/apps/chat/components/scroll-to-bottom/ScrollToBottomButton.tsx +++ b/src/apps/chat/components/scroll-to-bottom/ScrollToBottomButton.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { IconButton, Tooltip, Typography } from '@mui/joy'; -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import { IconButton } from '@mui/joy'; +import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown'; import { useScrollToBottom } from './useScrollToBottom'; @@ -20,37 +20,37 @@ export function ScrollToBottomButton() { return null; return ( - - Scroll to bottom - - }> - - - - + // + // Scroll to bottom + // + // }> + + + + // ); } \ No newline at end of file diff --git a/src/apps/chat/components/scroll-to-bottom/useScrollToBottom.tsx b/src/apps/chat/components/scroll-to-bottom/useScrollToBottom.tsx index 9a05e604a..76efbfb94 100644 --- a/src/apps/chat/components/scroll-to-bottom/useScrollToBottom.tsx +++ b/src/apps/chat/components/scroll-to-bottom/useScrollToBottom.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; - +/** + * State is minimal - to keep state machinery stable and simple + */ export interface ScrollToBottomState { // config stickToBottom: boolean; @@ -10,6 +12,9 @@ export interface ScrollToBottomState { atBottom: boolean | undefined; } +/** + * Actions are very simplified, for providing a minimal control surface from the outside + */ export interface ScrollToBottomActions { notifyBooting: () => void; setStickToBottom: (stick: boolean) => void; @@ -17,12 +22,11 @@ export interface ScrollToBottomActions { type ScrollToBottomContext = ScrollToBottomState & ScrollToBottomActions; -// React Context with ...state and ...actions const UseScrollToBottom = React.createContext(undefined); export const UseScrollToBottomProvider = UseScrollToBottom.Provider; -export const useScrollToBottom = (): ScrollToBottomState & ScrollToBottomActions => { +export const useScrollToBottom = (): ScrollToBottomContext => { const context = React.useContext(UseScrollToBottom); if (!context) throw new Error('useScrollToBottom must be used within a ScrollToBottomProvider');