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) => ( - {img.alt - ), - ); + 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) => ( + {img.alt + )); 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") + ); +};