diff --git a/.storybook/main.ts b/.storybook/main.ts
index af2f7f2..0656ad6 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -18,6 +18,7 @@ const config: StorybookConfig = {
},
},
},
+ staticDirs: ["../src/public/"],
swc: () => ({
jsc: {
transform: {
diff --git a/package.json b/package.json
index 078b1f4..57f7ff1 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,9 @@
"storybook:publish": "gh-pages -b storybook/publish -d storybook-static"
},
"dependencies": {
- "react-icons": "^5.3.0"
+ "@types/utif": "^3.0.5",
+ "react-icons": "^5.3.0",
+ "utif": "^3.1.0"
},
"peerDependencies": {
"@emotion/react": "^11.13.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 792d542..6c4cbc5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,12 +20,18 @@ importers:
'@mui/material':
specifier: ^6.1.7
version: 6.3.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@types/utif':
+ specifier: ^3.0.5
+ version: 3.0.5
react:
specifier: ^18.3.1
version: 18.3.1
react-icons:
specifier: ^5.3.0
version: 5.4.0(react@18.3.1)
+ utif:
+ specifier: ^3.1.0
+ version: 3.1.0
devDependencies:
'@babel/core':
specifier: ^7.26.0
@@ -1901,6 +1907,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/utif@3.0.5':
+ resolution: {integrity: sha512-ULwKfCC9b5JxJyjkkG0WCeOWz7pfBLuvf1ax1fbxt0e+DnZtLUjGnnwMeAU4ukKYmIa3HyITNHOyeKQyf0aAUg==}
+
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
@@ -3840,6 +3849,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
@@ -4838,6 +4850,9 @@ packages:
resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==}
engines: {node: '>= 0.4'}
+ utif@3.1.0:
+ resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==}
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -6991,6 +7006,10 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/utif@3.0.5':
+ dependencies:
+ '@types/node': 20.17.12
+
'@types/uuid@9.0.8': {}
'@types/yargs-parser@21.0.3': {}
@@ -9406,6 +9425,8 @@ snapshots:
p-try@2.2.0: {}
+ pako@1.0.11: {}
+
param-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -10469,6 +10490,10 @@ snapshots:
punycode: 1.4.1
qs: 6.13.1
+ utif@3.1.0:
+ dependencies:
+ pako: 1.0.11
+
util-deprecate@1.0.2: {}
util@0.12.5:
diff --git a/src/components/controls/ScrollableImages.stories.tsx b/src/components/controls/ScrollableImages.stories.tsx
index 14d7b14..58dc278 100644
--- a/src/components/controls/ScrollableImages.stories.tsx
+++ b/src/components/controls/ScrollableImages.stories.tsx
@@ -24,6 +24,13 @@ const imagesList: ImageInfo[] = [
{ src: shanghai, alt: "Shanghai" },
];
+const tiffImage: ImageInfo[] = [
+ {
+ src: "/images/multi-page-tiff.tiff",
+ alt: "Tiff",
+ },
+];
+
export const All: Story = {
args: { images: imagesList, width: 300, height: 300 },
};
@@ -84,3 +91,7 @@ export const DynamicImages: StoryObj = {
);
},
};
+
+export const TiffImage: Story = {
+ args: { images: tiffImage },
+};
diff --git a/src/components/controls/ScrollableImages.tsx b/src/components/controls/ScrollableImages.tsx
index 27a3f12..a35af17 100644
--- a/src/components/controls/ScrollableImages.tsx
+++ b/src/components/controls/ScrollableImages.tsx
@@ -1,7 +1,8 @@
import { Box, Button, Slider, Stack } from "@mui/material";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
-import React, { useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { extractFramesFromTiff, isTiff } from "../../utils/TiffUtils";
interface ScrollableImagesProps {
images: ImageInfo | ImageInfo[];
@@ -16,6 +17,7 @@ interface ScrollableImagesProps {
interface ImageInfo {
src: string;
+ type?: string;
alt?: string;
}
@@ -29,21 +31,42 @@ const ScrollableImages = ({
numeration = true,
backgroundColor = "#eee",
}: ScrollableImagesProps) => {
- const imageList = (Array.isArray(images) ? images : [images]).map(
- (img, i) => (
-
- ),
- );
+ const [extractedImages, setExtractedImages] = useState([]);
+
+ useEffect(() => {
+ (async () => {
+ const inputImages = Array.isArray(images) ? images : [images];
+ let result: ImageInfo[] = [];
+ let index = 1;
+ for (const image of inputImages) {
+ if (isTiff(image)) {
+ const frames: ImageInfo[] = await extractFramesFromTiff({
+ src: image.src,
+ alt: image.alt ?? `TIFF ${index}`,
+ });
+ result = result.concat(frames);
+ } else {
+ result.push(image);
+ }
+ index++;
+ }
+ setExtractedImages(result);
+ })();
+ }, [images]);
+
+ const imageList = extractedImages.map((img, i) => (
+
+ ));
const imageListLength = imageList.length;
const renderButtons = buttons && imageListLength > 1;
@@ -59,21 +82,21 @@ const ScrollableImages = ({
setNumberValue((index + 1).toString());
};
- const handlePrev = () => {
+ const handlePrev = useCallback(() => {
const newIndex = wrapAround
? (currentIndex - 1 + imageListLength) % imageListLength
: Math.max(0, currentIndex - 1);
setCurrentIndexWrapper(newIndex);
- };
+ }, [currentIndex, imageListLength, wrapAround]);
- const handleNext = () => {
+ const handleNext = useCallback(() => {
const newIndex = wrapAround
? (currentIndex + 1) % imageListLength
: Math.min(currentIndex + 1, imageListLength - 1);
setCurrentIndexWrapper(newIndex);
- };
+ }, [currentIndex, imageListLength, wrapAround]);
- const handleSliderChange = (event: Event, newIndex: number | number[]) => {
+ const handleSliderChange = (_event: Event, newIndex: number | number[]) => {
setCurrentIndexWrapper(Number(newIndex));
};
@@ -213,7 +236,6 @@ const ScrollableImages = ({
component="input"
type="number"
value={numberValue}
- defaultValue={""}
onChange={handleNumberChange}
onKeyDown={handleNumberEnter}
sx={{
@@ -238,7 +260,7 @@ const ScrollableImages = ({
- {`/${imageListLength}`}
+ {`/${String(imageListLength)}`}
)}
diff --git a/src/public/images/multi-page-tiff.tiff b/src/public/images/multi-page-tiff.tiff
new file mode 100644
index 0000000..b2b2ed0
Binary files /dev/null and b/src/public/images/multi-page-tiff.tiff differ
diff --git a/src/utils/TiffUtils.ts b/src/utils/TiffUtils.ts
new file mode 100644
index 0000000..c9cef28
--- /dev/null
+++ b/src/utils/TiffUtils.ts
@@ -0,0 +1,49 @@
+import * as UTIF from "utif";
+import { ImageInfo } from "../components/controls/ScrollableImages";
+
+export async function extractFramesFromTiff(
+ /** Splits a multi-frame Tiff into a list of png images.*/
+ tiffImage: ImageInfo,
+): Promise {
+ const response = await fetch(tiffImage.src);
+ const arrayBuffer = await response.arrayBuffer();
+ const frames = UTIF.decode(arrayBuffer);
+
+ const images: ImageInfo[] = [];
+
+ let index = 1;
+ for (const frame of frames) {
+ UTIF.decodeImage(arrayBuffer, frame);
+ const rgba = UTIF.toRGBA8(frame);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = frame.width;
+ canvas.height = frame.height;
+ const context = canvas.getContext("2d");
+ if (!context) continue;
+ const imageData = context.createImageData(frame.width, frame.height);
+ imageData.data.set(rgba);
+ context.putImageData(imageData, 0, 0);
+
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b), "image/png"),
+ );
+ if (!blob) continue;
+ const url = URL.createObjectURL(blob);
+ images.push({
+ src: url,
+ alt: tiffImage.alt ? `${tiffImage.alt} ${index}` : `TIFF Image ${index}`,
+ type: "image/png",
+ });
+ index++;
+ }
+ return images;
+}
+
+export const isTiff = (image: ImageInfo): boolean => {
+ return (
+ image.type?.includes("tif") ||
+ image.src.endsWith(".tiff") ||
+ image.src.endsWith(".tif")
+ );
+};