react-photo-album
Version:
Responsive photo gallery component for React
184 lines (183 loc) • 5.66 kB
JavaScript
"use client";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { useMemo, useState, useCallback, cloneElement, useLayoutEffect as useLayoutEffect$1, useEffect, useRef, Children, isValidElement } from "react";
const observers = /* @__PURE__ */ new Map();
function createObserver(rootMargin) {
let instance = observers.get(rootMargin);
if (!instance) {
const listeners = /* @__PURE__ */ new Map();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
listeners.get(entry.target)?.forEach((callback) => {
callback(entry);
});
});
},
{ rootMargin }
);
instance = { observer, listeners };
observers.set(rootMargin, instance);
}
return instance;
}
function useIntersectionObserver(rootMargin) {
return useMemo(() => {
const cleanup = [];
const observe = (target, callback) => {
const { observer, listeners } = createObserver(rootMargin);
const callbacks = listeners.get(target) || [];
callbacks.push(callback);
listeners.set(target, callbacks);
observer.observe(target);
cleanup.push(() => {
callbacks.splice(callbacks.indexOf(callback), 1);
if (callbacks.length === 0) {
listeners.delete(target);
observer.unobserve(target);
}
if (listeners.size === 0) {
observer.disconnect();
observers.delete(rootMargin);
}
});
};
const unobserve = () => {
cleanup.forEach((callback) => callback());
cleanup.splice(0, cleanup.length);
};
return { observe, unobserve };
}, [rootMargin]);
}
function Offscreen({ rootMargin, children }) {
const [placeholder, setPlaceholder] = useState();
const { observe, unobserve } = useIntersectionObserver(rootMargin);
const ref = useCallback(
(node) => {
unobserve();
if (node) {
observe(node, ({ isIntersecting }) => {
if (!isIntersecting) {
const { width, height } = node.getBoundingClientRect();
const { margin } = getComputedStyle(node);
setPlaceholder({ width, height, margin });
} else {
setPlaceholder(void 0);
}
});
}
},
[observe, unobserve]
);
return !placeholder ? cloneElement(children, { ref }) : jsx("div", { ref, style: placeholder });
}
const useLayoutEffect = typeof window !== "undefined" ? useLayoutEffect$1 : useEffect;
function useEventCallback(fn) {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args) => ref.current?.(...args), []);
}
var Status = ((Status2) => {
Status2[Status2["IDLE"] = 0] = "IDLE";
Status2[Status2["LOADING"] = 1] = "LOADING";
Status2[Status2["ERROR"] = 2] = "ERROR";
Status2[Status2["FINISHED"] = 3] = "FINISHED";
return Status2;
})(Status || {});
function InfiniteScroll({
photos: initialPhotos,
onClick,
fetch,
retries = 0,
singleton,
error,
loading,
finished,
children,
fetchRootMargin = "800px",
offscreenRootMargin = "2000px"
}) {
const [status, setStatus] = useState(0);
const [photos, setPhotos] = useState(() => initialPhotos ? [initialPhotos] : []);
const { observe, unobserve } = useIntersectionObserver(fetchRootMargin);
const fetching = useRef(false);
const fetchWithRetry = useEventCallback((index) => {
let attempt = 1;
const execute = async () => {
try {
return await fetch(index);
} catch (err) {
if (attempt > retries) {
throw err;
}
await new Promise((resolve) => {
setTimeout(resolve, 1e3 * 2 ** (attempt - 1));
});
attempt += 1;
return execute();
}
};
return execute();
});
const handleFetch = useCallback(async () => {
if (fetching.current) return;
const updateStatus = (newStatus) => {
fetching.current = newStatus === 1;
setStatus(newStatus);
};
updateStatus(1);
try {
const batch = await fetchWithRetry(photos.length);
if (batch === null) {
updateStatus(3);
return;
}
setPhotos((prev) => [...prev, batch]);
updateStatus(0);
} catch (_) {
updateStatus(2);
}
}, [photos.length, fetchWithRetry]);
const sentinelRef = useCallback(
(node) => {
unobserve();
if (node) {
observe(node, ({ isIntersecting }) => {
if (isIntersecting) {
handleFetch();
}
});
}
},
[observe, unobserve, handleFetch]
);
const photosArray = photos.flatMap((batch) => batch);
const handleClick = onClick ? ({ photo, event }) => {
onClick({ photos: photosArray, index: photosArray.findIndex((item) => item === photo), photo, event });
} : void 0;
return jsxs(Fragment, { children: [
singleton ? cloneElement(children, {
photos: photosArray,
onClick: handleClick,
render: {
...children.props.render,
track: ({ children: trackChildren, ...rest }) => jsx("div", { ...rest, children: Children.map(
trackChildren,
(child, index) => isValidElement(child) && jsx(Offscreen, { rootMargin: offscreenRootMargin, children: child }, index)
) })
}
}) : photos.map((batch, index) => jsx(Offscreen, { rootMargin: offscreenRootMargin, children: cloneElement(children, {
photos: batch,
onClick: handleClick
}) }, index)),
status === 2 && error,
status === 1 && loading,
status === 3 && finished,
jsx("span", { ref: sentinelRef })
] });
}
export {
InfiniteScroll as default
};