mirror of
https://github.com/lumehq/lume.git
synced 2025-03-28 18:52:33 +01:00
Add lazy carousel (#214)
* refactor: carousel * feat: improve image carousel
This commit is contained in:
parent
e4a317f038
commit
4f0f210076
Binary file not shown.
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
@ -1,23 +1,79 @@
|
|||||||
import { Carousel, CarouselItem } from "@lume/ui";
|
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { open } from "@tauri-apps/plugin-shell";
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
import { useMemo } from "react";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Images({ urls }: { urls: string[] }) {
|
export function Images({ urls }: { urls: string[] }) {
|
||||||
const { settings } = useRouteContext({ strict: false });
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const [slidesInView, setSlidesInView] = useState([]);
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
dragFree: true,
|
||||||
|
align: "start",
|
||||||
|
watchSlides: false,
|
||||||
|
});
|
||||||
|
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
if (settings.image_resize_service.length) {
|
if (settings.image_resize_service.length) {
|
||||||
const newUrls = urls.map(
|
let newUrls: string[];
|
||||||
(url) =>
|
|
||||||
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
if (urls.length === 1) {
|
||||||
);
|
newUrls = urls.map(
|
||||||
|
(url) =>
|
||||||
|
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newUrls = urls.map(
|
||||||
|
(url) =>
|
||||||
|
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return newUrls;
|
return newUrls;
|
||||||
} else {
|
} else {
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
}, [settings.image_resize_service]);
|
}, [settings.image_resize_service]);
|
||||||
|
|
||||||
|
const scrollPrev = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollPrev();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const scrollNext = useCallback(() => {
|
||||||
|
if (emblaApi) emblaApi.scrollNext();
|
||||||
|
}, [emblaApi]);
|
||||||
|
|
||||||
|
const updateSlidesInView = useCallback((emblaApi) => {
|
||||||
|
setSlidesInView((slidesInView) => {
|
||||||
|
if (slidesInView.length === emblaApi.slideNodes().length) {
|
||||||
|
emblaApi.off("slidesInView", updateSlidesInView);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inView = emblaApi
|
||||||
|
.slidesInView()
|
||||||
|
.filter((index) => !slidesInView.includes(index));
|
||||||
|
|
||||||
|
return slidesInView.concat(inView);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emblaApi && urls.length > 1) {
|
||||||
|
updateSlidesInView(emblaApi);
|
||||||
|
|
||||||
|
emblaApi.on("slidesInView", updateSlidesInView);
|
||||||
|
emblaApi.on("reInit", updateSlidesInView);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||||
|
emblaApi?.off("reInit", updateSlidesInView);
|
||||||
|
};
|
||||||
|
}, [emblaApi, updateSlidesInView]);
|
||||||
|
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 group">
|
<div className="px-3 group">
|
||||||
@ -40,26 +96,84 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Carousel
|
<div className="relative pl-2 overflow-hidden group">
|
||||||
items={imageUrls}
|
<div ref={emblaRef} className="w-full">
|
||||||
renderItem={({ item, index, isSnapPoint }) => (
|
<div className="flex w-full gap-2 scrollbar-none">
|
||||||
<CarouselItem key={item + index} isSnapPoint={isSnapPoint}>
|
{imageUrls.map((url, index) => (
|
||||||
<img
|
<LazyImage
|
||||||
src={item}
|
/* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
|
||||||
alt={item}
|
key={url + index}
|
||||||
loading="lazy"
|
url={url}
|
||||||
decoding="async"
|
inView={slidesInView.indexOf(index) > -1}
|
||||||
style={{ contentVisibility: "auto" }}
|
/>
|
||||||
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
))}
|
||||||
onClick={() => open(item)}
|
</div>
|
||||||
onKeyDown={() => open(item)}
|
</div>
|
||||||
onError={({ currentTarget }) => {
|
<div
|
||||||
currentTarget.onerror = null;
|
aria-hidden
|
||||||
currentTarget.src = "/404.jpg";
|
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
|
||||||
}}
|
>
|
||||||
/>
|
<button
|
||||||
</CarouselItem>
|
type="button"
|
||||||
)}
|
disabled={!emblaApi?.canScrollPrev}
|
||||||
/>
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollPrev ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollPrev()}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!emblaApi?.canScrollNext}
|
||||||
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollNext ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollNext()}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
|
const setLoaded = useCallback(() => {
|
||||||
|
if (inView) setHasLoaded(true);
|
||||||
|
}, [inView, setHasLoaded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
|
||||||
|
{!hasLoaded ? (
|
||||||
|
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
inView
|
||||||
|
? url
|
||||||
|
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||||
|
}
|
||||||
|
data-src={url}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onLoad={setLoaded}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user