From d898e7ecc0e782c195c1feae88542bc1d3387d08 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 14 Mar 2025 12:43:30 -0600 Subject: [PATCH] use in viewport --- web/src/hooks/useEffectWithTarget.ts | 64 ++++++++++++++++++++++++++++ web/src/hooks/useInViewport.tsx | 58 +++++++++++++++++++++++++ web/src/lib/depAreSame.ts | 9 ++++ web/src/lib/domTarget.ts | 34 +++++++++++++++ web/src/lib/isBrowser.ts | 7 +++ 5 files changed, 172 insertions(+) create mode 100644 web/src/hooks/useEffectWithTarget.ts create mode 100644 web/src/hooks/useInViewport.tsx create mode 100644 web/src/lib/depAreSame.ts create mode 100644 web/src/lib/domTarget.ts create mode 100644 web/src/lib/isBrowser.ts diff --git a/web/src/hooks/useEffectWithTarget.ts b/web/src/hooks/useEffectWithTarget.ts new file mode 100644 index 000000000..da8bce4fc --- /dev/null +++ b/web/src/hooks/useEffectWithTarget.ts @@ -0,0 +1,64 @@ +import { DependencyList, EffectCallback, useEffect, useLayoutEffect } from 'react'; +import { useRef } from 'react'; +import { useUnmount } from './useUnmount'; +import { depsAreSame } from '@/lib/depAreSame'; +import type { BasicTarget } from '@/lib/domTarget'; +import { getTargetElement } from '@/lib/domTarget'; + +const createEffectWithTarget = (useEffectType: typeof useEffect | typeof useLayoutEffect) => { + /** + * + * @param effect + * @param deps + * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom + */ + const useEffectWithTarget = ( + effect: EffectCallback, + deps: DependencyList, + target: BasicTarget | BasicTarget[] + ) => { + const hasInitRef = useRef(false); + + const lastElementRef = useRef<(Element | null)[]>([]); + const lastDepsRef = useRef([]); + + const unLoadRef = useRef(); + + useEffectType(() => { + const targets = Array.isArray(target) ? target : [target]; + const els = targets.map((item) => getTargetElement(item)); + + // init run + if (!hasInitRef.current) { + hasInitRef.current = true; + lastElementRef.current = els; + lastDepsRef.current = deps; + + unLoadRef.current = effect(); + return; + } + + if ( + els.length !== lastElementRef.current.length || + !depsAreSame(lastElementRef.current, els) || + !depsAreSame(lastDepsRef.current, deps) + ) { + unLoadRef.current?.(); + + lastElementRef.current = els; + lastDepsRef.current = deps; + unLoadRef.current = effect(); + } + }); + + useUnmount(() => { + unLoadRef.current?.(); + // for react-refresh + hasInitRef.current = false; + }); + }; + + return useEffectWithTarget; +}; + +export const useEffectWithTarget = createEffectWithTarget(useEffect); diff --git a/web/src/hooks/useInViewport.tsx b/web/src/hooks/useInViewport.tsx new file mode 100644 index 000000000..727cf7633 --- /dev/null +++ b/web/src/hooks/useInViewport.tsx @@ -0,0 +1,58 @@ +import 'intersection-observer'; +import { useState } from 'react'; +import type { BasicTarget } from '../lib/domTarget'; +import { getTargetElement } from '../lib/domTarget'; +import { useEffectWithTarget } from './useEffectWithTarget'; + +type CallbackType = (entry: IntersectionObserverEntry) => void; + +export interface Options { + rootMargin?: string; + threshold?: number | number[]; + root?: BasicTarget; + callback?: CallbackType; +} + +function useInViewport(target: BasicTarget | BasicTarget[], options?: Options) { + const { callback, ...option } = options || {}; + + const [state, setState] = useState(); + const [ratio, setRatio] = useState(); + + useEffectWithTarget( + () => { + const targets = Array.isArray(target) ? target : [target]; + const els = targets.map((element) => getTargetElement(element)).filter(Boolean); + + if (!els.length) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + setRatio(entry.intersectionRatio); + setState(entry.isIntersecting); + callback?.(entry); + } + }, + { + ...option, + root: getTargetElement(options?.root) + } + ); + + els.forEach((el) => observer.observe(el!)); + + return () => { + observer.disconnect(); + }; + }, + [options?.rootMargin, options?.threshold, callback], + target + ); + + return [state, ratio] as const; +} + +export default useInViewport; diff --git a/web/src/lib/depAreSame.ts b/web/src/lib/depAreSame.ts new file mode 100644 index 000000000..842de4525 --- /dev/null +++ b/web/src/lib/depAreSame.ts @@ -0,0 +1,9 @@ +import type { DependencyList } from 'react'; + +export function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean { + if (oldDeps === deps) return true; + for (let i = 0; i < oldDeps.length; i++) { + if (!Object.is(oldDeps[i], deps[i])) return false; + } + return true; +} diff --git a/web/src/lib/domTarget.ts b/web/src/lib/domTarget.ts new file mode 100644 index 000000000..c55daf0a1 --- /dev/null +++ b/web/src/lib/domTarget.ts @@ -0,0 +1,34 @@ +import type { MutableRefObject } from 'react'; +import isFunction from 'lodash/isFunction'; +import isBrowser from './isBrowser'; + +type TargetValue = T | undefined | null; + +type TargetType = HTMLElement | Element | Window | Document; + +export type BasicTarget = + | (() => TargetValue) + | TargetValue + | MutableRefObject>; + +export function getTargetElement(target: BasicTarget, defaultElement?: T) { + if (!isBrowser) { + return undefined; + } + + if (!target) { + return defaultElement; + } + + let targetElement: TargetValue; + + if (isFunction(target)) { + targetElement = target(); + } else if ('current' in target) { + targetElement = target.current; + } else { + targetElement = target; + } + + return targetElement; +} diff --git a/web/src/lib/isBrowser.ts b/web/src/lib/isBrowser.ts new file mode 100644 index 000000000..eee18805d --- /dev/null +++ b/web/src/lib/isBrowser.ts @@ -0,0 +1,7 @@ +export const isBrowser = !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +export default isBrowser;