- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useCallbackRef } from "./useCallbackRef";
|
|
|
|
export interface ScrollRevealOptions {
|
|
threshold?: number | number[];
|
|
rootMargin?: string;
|
|
}
|
|
export function useScrollReveal(options: ScrollRevealOptions = {}) {
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const observedElementsRef = useRef<Set<HTMLElement>>(new Set());
|
|
const mutationObserversRef = useRef<Map<Element, MutationObserver>>(
|
|
new Map(),
|
|
);
|
|
const optionsRef = useRef(options);
|
|
|
|
useEffect(() => {
|
|
optionsRef.current = options;
|
|
}, [options]);
|
|
|
|
const getObserver = () => {
|
|
if (!observerRef.current) {
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.setAttribute("data-revealed", "true");
|
|
|
|
const revealChildren = (el: Element) => {
|
|
Array.from(el.children).forEach((child) => {
|
|
if (child.getAttribute("data-revealed") !== "true") {
|
|
child.setAttribute("data-revealed", "true");
|
|
}
|
|
revealChildren(child);
|
|
});
|
|
};
|
|
|
|
revealChildren(entry.target);
|
|
|
|
if (!mutationObserversRef.current.has(entry.target)) {
|
|
const mo = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === "childList") {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node instanceof Element) {
|
|
if (node.getAttribute("data-revealed") !== "true") {
|
|
node.setAttribute("data-revealed", "true");
|
|
}
|
|
revealChildren(node);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
mo.observe(entry.target, { childList: true, subtree: true });
|
|
mutationObserversRef.current.set(entry.target, mo);
|
|
}
|
|
|
|
observerRef.current?.unobserve(entry.target);
|
|
if (entry.target instanceof HTMLElement) {
|
|
observedElementsRef.current.delete(entry.target);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{
|
|
threshold: optionsRef.current.threshold ?? 0.15,
|
|
rootMargin: optionsRef.current.rootMargin ?? "0px 0px -40px 0px",
|
|
},
|
|
);
|
|
}
|
|
return observerRef.current;
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
observerRef.current?.disconnect();
|
|
observedElementsRef.current.clear();
|
|
mutationObserversRef.current.forEach((mo) => mo.disconnect());
|
|
mutationObserversRef.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
const observe = useCallbackRef((element: HTMLElement | null) => {
|
|
if (!element) return;
|
|
|
|
if (typeof IntersectionObserver === "undefined") {
|
|
element.setAttribute("data-revealed", "true");
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== "undefined" && window.matchMedia) {
|
|
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
if (mediaQuery.matches) {
|
|
element.setAttribute("data-revealed", "true");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!observedElementsRef.current.has(element) &&
|
|
element.getAttribute("data-revealed") !== "true"
|
|
) {
|
|
getObserver().observe(element);
|
|
observedElementsRef.current.add(element);
|
|
}
|
|
});
|
|
|
|
return observe;
|
|
}
|