Skip to content

Commit 32cae44

Browse files
committed
feat(frontend): add sliding list of images
1 parent 5e3cd6e commit 32cae44

3 files changed

Lines changed: 290 additions & 71 deletions

File tree

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,24 @@
1-
import React, { useState, useMemo } from "react";
2-
import { Box, IconButton } from "@mui/material";
3-
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
4-
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
1+
import React, { useMemo } from "react";
52
import type { Artifact } from "workflows-lib";
3+
import { ScrollableImages, ImageInfo } from "./ScrollableImages";
64

75
interface ImageGalleryProps {
86
artifactList: Artifact[];
97
}
108

119
export const ImageGallery: React.FC<ImageGalleryProps> = ({ artifactList }) => {
12-
const [currentIndex, setCurrentIndex] = useState(0);
13-
14-
const imageArtifacts = useMemo(() => {
15-
return artifactList.filter((artifact) => artifact.mimeType === "image/png");
10+
const imageArtifactsInfos: ImageInfo[] = useMemo(() => {
11+
return artifactList
12+
.filter((artifact) => artifact.mimeType === "image/png")
13+
.map((artifact, index) => ({
14+
src: artifact.url,
15+
alt: `Gallery Image ${String(index + 1)}`,
16+
}));
1617
}, [artifactList]);
1718

18-
const handlePrevious = () => {
19-
setCurrentIndex((prevIndex) =>
20-
prevIndex === 0 ? imageArtifacts.length - 1 : prevIndex - 1
21-
);
22-
};
23-
24-
const handleNext = () => {
25-
setCurrentIndex((prevIndex) =>
26-
prevIndex === imageArtifacts.length - 1 ? 0 : prevIndex + 1
27-
);
28-
};
29-
3019
return (
31-
<>
32-
{imageArtifacts.length === 0 ? null : (
33-
<Box sx={{ display: "flex", alignItems: "center" }}>
34-
<IconButton onClick={handlePrevious} aria-label="previous image">
35-
<ArrowBackIcon />
36-
</IconButton>
37-
<Box
38-
component="img"
39-
src={imageArtifacts[currentIndex].url}
40-
alt={`Gallery Image ${String(currentIndex + 1)}`}
41-
sx={{
42-
width: "300px",
43-
height: "300px",
44-
borderRadius: 1,
45-
border: "solid",
46-
objectFit: "contain",
47-
}}
48-
/>
49-
<IconButton onClick={handleNext} aria-label="next image">
50-
<ArrowForwardIcon />
51-
</IconButton>
52-
</Box>
53-
)}
54-
</>
20+
imageArtifactsInfos.length > 0 && (
21+
<ScrollableImages images={imageArtifactsInfos} backgroundColor="#FFF" />
22+
)
5523
);
5624
};
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { Box, Button, Slider, Stack } from "@mui/material";
2+
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
3+
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
4+
import React, { useCallback, useEffect, useRef, useState } from "react";
5+
6+
interface ScrollableImagesProps {
7+
images: ImageInfo | ImageInfo[];
8+
width?: number;
9+
height?: number;
10+
buttons?: boolean;
11+
wrapAround?: boolean;
12+
slider?: boolean;
13+
numeration?: boolean;
14+
backgroundColor?: string;
15+
}
16+
17+
interface ImageInfo {
18+
src: string;
19+
alt?: string;
20+
}
21+
22+
const ScrollableImages = ({
23+
images,
24+
width = 300,
25+
height = 300,
26+
buttons = true,
27+
wrapAround = true,
28+
slider = true,
29+
numeration = true,
30+
backgroundColor = "#eee",
31+
}: ScrollableImagesProps) => {
32+
const imageList = (Array.isArray(images) ? images : [images]).map(
33+
(img, i) => (
34+
<img
35+
key={i}
36+
src={img.src}
37+
alt={img.alt ?? `Image ${String(i + 1)}`}
38+
style={{
39+
width: "100%",
40+
height: "100%",
41+
objectFit: "contain",
42+
display: "block",
43+
}}
44+
/>
45+
)
46+
);
47+
48+
const imageListLength = imageList.length;
49+
const renderButtons = buttons && imageListLength > 1;
50+
const renderSlider = slider && imageListLength > 1;
51+
const renderNumbers = numeration && imageListLength > 1;
52+
53+
const [currentIndex, setCurrentIndex] = useState(0);
54+
const [numberValue, setNumberValue] = useState("1");
55+
const containerRef = useRef<HTMLDivElement>(null);
56+
57+
const setCurrentIndexWrapper = (index: number) => {
58+
setCurrentIndex(index);
59+
setNumberValue((index + 1).toString());
60+
};
61+
62+
const handlePrev = useCallback(() => {
63+
const newIndex = wrapAround
64+
? (currentIndex - 1 + imageListLength) % imageListLength
65+
: Math.max(0, currentIndex - 1);
66+
setCurrentIndexWrapper(newIndex);
67+
}, [currentIndex, imageListLength, wrapAround]);
68+
69+
const handleNext = useCallback(() => {
70+
const newIndex = wrapAround
71+
? (currentIndex + 1) % imageListLength
72+
: Math.min(currentIndex + 1, imageListLength - 1);
73+
setCurrentIndexWrapper(newIndex);
74+
}, [currentIndex, imageListLength, wrapAround]);
75+
76+
const handleSliderChange = (_event: Event, newIndex: number | number[]) => {
77+
setCurrentIndexWrapper(Number(newIndex));
78+
};
79+
80+
const handleNumberChange = (event: React.ChangeEvent<HTMLInputElement>) => {
81+
setNumberValue(event.target.value);
82+
};
83+
84+
const handleNumberEnter = (event: React.KeyboardEvent<HTMLInputElement>) => {
85+
if (event.key === "Enter") {
86+
const n = parseInt(numberValue);
87+
let newIndex: number;
88+
if (isNaN(n)) {
89+
newIndex = currentIndex;
90+
} else if (n > imageListLength) {
91+
newIndex = imageListLength - 1;
92+
} else if (n < 1) {
93+
newIndex = 0;
94+
} else {
95+
newIndex = n - 1;
96+
}
97+
setCurrentIndexWrapper(newIndex);
98+
}
99+
};
100+
101+
const handleArrowKeys = (event: React.KeyboardEvent<HTMLInputElement>) => {
102+
if (event.key === "ArrowLeft") handlePrev();
103+
else if (event.key === "ArrowRight") handleNext();
104+
};
105+
106+
useEffect(() => {
107+
const element = containerRef.current;
108+
if (!element) return;
109+
110+
const handleWheel = (event: WheelEvent) => {
111+
event.preventDefault();
112+
if (event.deltaY < 0) handlePrev();
113+
else if (event.deltaY > 0) handleNext();
114+
};
115+
116+
element.addEventListener("wheel", handleWheel, { passive: false });
117+
118+
return () => {
119+
element.removeEventListener("wheel", handleWheel);
120+
};
121+
}, [handlePrev, handleNext]);
122+
123+
useEffect(() => {
124+
if (currentIndex >= imageListLength && imageListLength) {
125+
setCurrentIndexWrapper(imageListLength - 1);
126+
}
127+
}, [imageListLength, currentIndex]);
128+
129+
return (
130+
<>
131+
<Stack
132+
direction="column"
133+
alignItems="center"
134+
style={{ width }}
135+
data-testid="scrollable-images"
136+
>
137+
<Box
138+
ref={containerRef}
139+
tabIndex={0}
140+
onKeyDown={handleArrowKeys}
141+
sx={{
142+
display: "flex",
143+
justifyContent: "center",
144+
alignItems: "center",
145+
}}
146+
>
147+
{renderButtons && (
148+
<Button
149+
onClick={handlePrev}
150+
size="small"
151+
sx={{ minWidth: 36, width: 36, height: 36 }}
152+
data-testid="prev-button"
153+
>
154+
<ArrowBackIcon fontSize="small" />
155+
</Button>
156+
)}
157+
<Box
158+
data-index={currentIndex}
159+
sx={{
160+
position: "relative",
161+
width,
162+
height,
163+
border: "1px solid #ccc",
164+
display: "flex",
165+
justifyContent: "center",
166+
alignItems: "center",
167+
backgroundColor: backgroundColor,
168+
"& .slider-wrapper": { display: "none" },
169+
"&:hover .slider-wrapper": { display: "flex" },
170+
}}
171+
data-testid="image-container"
172+
>
173+
{imageList[currentIndex]}
174+
{renderSlider && (
175+
<Box
176+
className="slider-wrapper"
177+
sx={{
178+
position: "absolute",
179+
width: width,
180+
bottom: 0,
181+
paddingBottom: "8px",
182+
justifyContent: "center",
183+
alignItems: "center",
184+
}}
185+
>
186+
<Slider
187+
min={0}
188+
max={imageListLength - 1}
189+
value={currentIndex}
190+
onChange={handleSliderChange}
191+
sx={{ width: "75%" }}
192+
data-testid="slider"
193+
/>
194+
</Box>
195+
)}
196+
</Box>
197+
198+
{renderButtons && (
199+
<Button
200+
onClick={handleNext}
201+
size="small"
202+
sx={{ minWidth: 36, width: 36, height: 36 }}
203+
data-testid="next-button"
204+
>
205+
<ArrowForwardIcon fontSize="small" />
206+
</Button>
207+
)}
208+
</Box>
209+
{renderNumbers && (
210+
<Box sx={{ display: "flex" }}>
211+
<Box
212+
data-testid="numeration"
213+
component="input"
214+
type="number"
215+
value={numberValue}
216+
defaultValue={""}
217+
onChange={handleNumberChange}
218+
onKeyDown={handleNumberEnter}
219+
sx={{
220+
width: "49%",
221+
fontSize: "1rem",
222+
outline: "none",
223+
border: "none",
224+
backgroundColor: "transparent",
225+
color: "inherit",
226+
textAlign: "right",
227+
fontFamily: "inherit",
228+
appearance: "none",
229+
WebkitAppearance: "none",
230+
MozAppearance: "textfield",
231+
232+
"&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": {
233+
WebkitAppearance: "none",
234+
margin: 0,
235+
},
236+
}}
237+
></Box>
238+
<Box
239+
sx={{ fontFamily: "inherit", fontSize: "1rem", color: "inherit" }}
240+
>
241+
{`/${String(imageListLength)}`}
242+
</Box>
243+
</Box>
244+
)}
245+
</Stack>
246+
</>
247+
);
248+
};
249+
250+
export { ScrollableImages };
251+
export type { ScrollableImagesProps, ImageInfo };

0 commit comments

Comments
 (0)