Skip to content

Commit

Permalink
Scroll-To-Bottom: complete Framework. Fixes #304, Fixes #60, Fixes #59
Browse files Browse the repository at this point in the history
  • Loading branch information
enricoros committed Dec 28, 2023
1 parent e27c353 commit 2eb3397
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 87 deletions.
149 changes: 99 additions & 50 deletions src/apps/chat/components/scroll-to-bottom/ScrollToBottom.tsx
Original file line number Diff line number Diff line change
@@ -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:
* <ScrollToBottom bootToBottom stickToBottom sx={{ overflowY: 'auto', height: '100%' }}>
* <LongMessagesList />
* <ScrollToBottomButton />
* </ScrollToBottom>
*
* 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';
Expand All @@ -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 (
<Box sx={{
position: 'absolute', bottom: 0, right: 0, left: 0,
height: `${props.heightPx}px`,
border: `1px solid ${props.color}`,
pointerEvents: 'none',
}} />
);
}


export function ScrollToBottom(props: {
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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 (
Expand All @@ -173,6 +220,8 @@ export function ScrollToBottom(props: {
}}>
<Box ref={scrollableElementRef} sx={props.sx}>
{props.children}
{DEBUG_SCROLL_TO_BOTTOM && <DebugBorderBox heightPx={USER_STICKY_MARGIN} color='red' />}
{DEBUG_SCROLL_TO_BOTTOM && <DebugBorderBox heightPx={100} color='blue' />}
</Box>
</UseScrollToBottomProvider>
);
Expand Down
68 changes: 34 additions & 34 deletions src/apps/chat/components/scroll-to-bottom/ScrollToBottomButton.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -20,37 +20,37 @@ export function ScrollToBottomButton() {
return null;

return (
<Tooltip title={
<Typography variant='solid' level='title-sm' sx={{ px: 1 }}>
Scroll to bottom
</Typography>
}>
<IconButton
variant='outlined' color='primary' size='sm'
onClick={handleStickToBottom}
sx={{
// place this on the bottom-right corner (FAB-like)
position: 'absolute',
bottom: '2rem',
right: {
xs: '1rem',
md: '2rem',
},

// style it
backgroundColor: 'background.surface',
borderRadius: '50%',
boxShadow: 'sm',

// fade it in when hovering
transition: 'all 0.15s',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
<ArrowDropDownIcon />
</IconButton>
</Tooltip>
// <Tooltip title={
// <Typography variant='solid' level='title-sm' sx={{ px: 1 }}>
// Scroll to bottom
// </Typography>
// }>
<IconButton
variant='outlined' color='neutral' size='md'
onClick={handleStickToBottom}
sx={{
// place this on the bottom-right corner (FAB-like)
position: 'absolute',
bottom: '2rem',
right: {
xs: '1rem',
md: '2rem',
},

// style it
backgroundColor: 'background.surface',
borderRadius: '50%',
boxShadow: 'md',

// fade it in when hovering
// transition: 'all 0.15s',
// '&:hover': {
// transform: 'scale(1.1)',
// },
}}
>
<KeyboardDoubleArrowDownIcon />
</IconButton>
// </Tooltip>
);
}
10 changes: 7 additions & 3 deletions src/apps/chat/components/scroll-to-bottom/useScrollToBottom.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,19 +12,21 @@ 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;
}

type ScrollToBottomContext = ScrollToBottomState & ScrollToBottomActions;

// React Context with ...state and ...actions
const UseScrollToBottom = React.createContext<ScrollToBottomContext | undefined>(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');
Expand Down

0 comments on commit 2eb3397

Please sign in to comment.