187 lines
		
	
	
	
		
			5.9 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			187 lines
		
	
	
	
		
			5.9 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// 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<T extends Element = Element>(
 | 
						|
  ref: RefObject<T>,
 | 
						|
  /**
 | 
						|
   * Note: If you provide `onSizeChange`, `useSizeObserver()` will always return `null`
 | 
						|
   */
 | 
						|
  onSizeChange?: SizeChangeHandler
 | 
						|
): Size | null {
 | 
						|
  const [size, setSize] = useState<Size | null>(null);
 | 
						|
  const sizeRef = useRef<Size | null>(null);
 | 
						|
  const onSizeChangeRef = useRef<SizeChangeHandler | void>(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<any>, 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<any>();
 | 
						|
  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<Element, 'scrollTop' | 'scrollHeight' | 'clientHeight'>
 | 
						|
>;
 | 
						|
 | 
						|
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));
 | 
						|
 * })
 | 
						|
 *
 | 
						|
 * <div ref={scrollerRef} style={{ overflow: "auto" }}>
 | 
						|
 *   <div ref={scrollerInnerRef}>
 | 
						|
 *     {children}
 | 
						|
 *   </div>
 | 
						|
 * </div>
 | 
						|
 * ```
 | 
						|
 */
 | 
						|
export function useScrollObserver(
 | 
						|
  scrollerRef: RefObject<HTMLElement>,
 | 
						|
  scrollerInnerRef: RefObject<HTMLElement>,
 | 
						|
  onScrollChange: (scroll: Scroll) => void
 | 
						|
): void {
 | 
						|
  const scrollRef = useRef<Scroll | null>(null);
 | 
						|
  const onScrollChangeRef = useRef<ScrollChangeHandler>(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]);
 | 
						|
}
 |