// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { RefObject } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { strictAssert } from '../util/assert'; export type Size = Readonly<{ width: number; height: number; }>; export type SizeChangeHandler = (size: Size) => void; export function isSameSize(a: Size, b: Size): boolean { return a.width === b.width && a.height === b.height; } export function useSizeObserver( ref: RefObject, /** * Note: If you provide `onSizeChange`, `useSizeObserver()` will always return `null` */ onSizeChange?: SizeChangeHandler ): Size | null { const [size, setSize] = useState(null); const sizeRef = useRef(null); const onSizeChangeRef = useRef(onSizeChange); useEffect(() => { // This means you don't need to wrap `onSizeChange` with `useCallback()` onSizeChangeRef.current = onSizeChange; }, [onSizeChange]); useEffect(() => { const observer = new ResizeObserver(entries => { // It's possible that ResizeObserver emit entries after disconnect() if (ref.current == null) { return; } // We're only ever observing one element, and `ResizeObserver` for some // reason is an array of exactly one rect (I assume to support wrapped // inline elements in the future) const borderBoxSize = entries[0].borderBoxSize[0]; // We are assuming a horizontal writing-mode here, we could call // `getBoundingClientRect()` here but MDN says not to. In the future if // we are adding support for a vertical locale we may need to change this const next: Size = { width: borderBoxSize.inlineSize, height: borderBoxSize.blockSize, }; const prev = sizeRef.current; if (prev == null || !isSameSize(prev, next)) { sizeRef.current = next; if (onSizeChangeRef.current != null) { onSizeChangeRef.current(next); } else { setSize(next); } } }); strictAssert( ref.current instanceof Element, 'ref must be assigned to an element' ); observer.observe(ref.current, { box: 'border-box', }); return () => { observer.disconnect(); }; }, [ref]); return size; } // Note we use `any` for ref below because TypeScript doesn't currently have // good inference for JSX generics and it creates confusing errors. We have // a better error being reported by the hook. export type SizeObserverProps = Readonly<{ /** * Note: If you provide `onSizeChange`, in `children()` the `size` will always be `null` */ onSizeChange?: SizeChangeHandler; // eslint-disable-next-line @typescript-eslint/no-explicit-any children(ref: RefObject, size: Size | null): JSX.Element; }>; export function SizeObserver({ onSizeChange, children, }: SizeObserverProps): JSX.Element { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ref = useRef(); const size = useSizeObserver(ref, onSizeChange); return children(ref, size); } // Note: You should just be able to pass an element into utils if you want. export type Scroll = Readonly< Pick >; export type ScrollChangeHandler = (scroll: Scroll) => void; export function isSameScroll(a: Scroll, b: Scroll): boolean { return ( a.scrollTop === b.scrollTop && a.scrollHeight === b.scrollHeight && a.clientHeight === b.clientHeight ); } export function isOverflowing(scroll: Scroll): boolean { return scroll.scrollHeight > scroll.clientHeight; } export function isScrolled(scroll: Scroll): boolean { return scroll.scrollTop > 0; } export function isScrolledToBottom(scroll: Scroll, threshold = 0): boolean { const maxScrollTop = scroll.scrollHeight - scroll.clientHeight; return scroll.scrollTop >= maxScrollTop - threshold; } /** * We need an extra element because there is no ResizeObserver equivalent for * `scrollHeight`. You need something measuring the scroll container and an * inner element wrapping all of its children. * * ``` * const scrollerRef = useRef() * const scrollerInnerRef = useRef() * * useScrollObserver(scrollerRef, scrollerInnerRef, (scroll) => { * setIsOverflowing(isOverflowing(scroll)); * setIsScrolled(isScrolled(scroll)); * setAtBottom(isScrolledToBottom(scroll)); * }) * *
*
* {children} *
*
* ``` */ export function useScrollObserver( scrollerRef: RefObject, scrollerInnerRef: RefObject, onScrollChange: (scroll: Scroll) => void ): void { const scrollRef = useRef(null); const onScrollChangeRef = useRef(onScrollChange); useEffect(() => { // This means you don't need to wrap `onScrollChange` with `useCallback()` onScrollChangeRef.current = onScrollChange; }, [onScrollChange]); const onUpdate = useCallback(() => { const target = scrollerRef.current; strictAssert( target instanceof Element, 'ref must be assigned to an element' ); const next: Scroll = { scrollTop: target.scrollTop, scrollHeight: target.scrollHeight, clientHeight: target.clientHeight, }; const prev = scrollRef.current; if (prev == null || !isSameScroll(prev, next)) { scrollRef.current = next; onScrollChangeRef.current(next); } }, [scrollerRef]); useSizeObserver(scrollerRef, onUpdate); useSizeObserver(scrollerInnerRef, onUpdate); useEffect(() => { strictAssert( scrollerRef.current instanceof Element, 'ref must be assigned to an element' ); const target = scrollerRef.current; target.addEventListener('scroll', onUpdate, { passive: true }); return () => { target.removeEventListener('scroll', onUpdate); }; }, [scrollerRef, onUpdate]); }