signal-desktop/ts/hooks/useSizeObserver.tsx

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]);
}