diff --git a/package.json b/package.json index c9a7f233..2fc864a5 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,9 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@mui/icons-material": "^7.0.0", "react-icons": "^5.3.0", - "utif": "^3.1.0" + "utif": "^3.1.0", + "@mui/icons-material": "^7.0.0" }, "peerDependencies": { "@emotion/react": "^11.13.3", @@ -74,6 +74,7 @@ "@eslint/js": "^10.0.1", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/inter": "^5.2.8", + "@mui/material": "^7.0.0", "@fontsource/outfit": "^5.2.8", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-image": "^3.0.3", @@ -120,7 +121,14 @@ "typedoc": "^0.28.5", "typescript": "^5.6.3", "typescript-eslint": "^8.15.0", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@jsonforms/core": "^3.7.0", + "@jsonforms/material-renderers": "^3.7.0", + "@jsonforms/react": "^3.7.0", + "keycloak-js": "^26.2.1", + "react": "^18.3.1" }, "optionalDependencies": { "keycloak-js": "^26.2.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1089d746..26d59d05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,33 +21,12 @@ importers: .: dependencies: - '@emotion/react': - specifier: ^11.13.3 - version: 11.14.0(@types/react@18.3.18)(react@18.3.1) - '@emotion/styled': - specifier: ^11.13.0 - version: 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) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 - '@jsonforms/core': - specifier: ^3.7.0 - version: 3.7.0 - '@jsonforms/material-renderers': - specifier: ^3.7.0 - version: 3.7.0(408a1c23dbeeab334b849b456cede2fb) - '@jsonforms/react': - specifier: ^3.7.0 - version: 3.7.0(@jsonforms/core@3.7.0)(react@18.3.1) '@mui/icons-material': specifier: ^7.0.0 version: 7.3.10(@mui/material@7.3.10(@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/react@18.3.18)(react@18.3.1) - '@mui/material': - specifier: ^7.0.0 - version: 7.3.10(@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) - react: - specifier: ^18.3.1 - version: 18.3.1 react-icons: specifier: ^5.3.0 version: 5.4.0(react@18.3.1) @@ -70,6 +49,12 @@ importers: '@chromatic-com/storybook': specifier: ^3.2.2 version: 3.2.3(react@18.3.1)(storybook@8.6.18(prettier@3.4.2)) + '@emotion/react': + specifier: ^11.13.3 + version: 11.14.0(@types/react@18.3.18)(react@18.3.1) + '@emotion/styled': + specifier: ^11.13.0 + version: 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) '@eslint/eslintrc': specifier: ^3.2.0 version: 3.2.0 @@ -85,6 +70,18 @@ importers: '@fontsource/outfit': specifier: ^5.2.8 version: 5.2.8 + '@jsonforms/core': + specifier: ^3.7.0 + version: 3.7.0 + '@jsonforms/material-renderers': + specifier: ^3.7.0 + version: 3.7.0(408a1c23dbeeab334b849b456cede2fb) + '@jsonforms/react': + specifier: ^3.7.0 + version: 3.7.0(@jsonforms/core@3.7.0)(react@18.3.1) + '@mui/material': + specifier: ^7.0.0 + version: 7.3.10(@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) '@rollup/plugin-commonjs': specifier: ^28.0.1 version: 28.0.2(rollup@4.30.0) @@ -184,6 +181,9 @@ importers: gh-pages: specifier: ^6.2.0 version: 6.3.0 + react: + specifier: ^18.3.1 + version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) diff --git a/src/components/controls/Image.stories.tsx b/src/components/controls/Image.stories.tsx new file mode 100644 index 00000000..2d7d0fc2 --- /dev/null +++ b/src/components/controls/Image.stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { Image } from "./Image"; + +import diamond from "../../public/images/diamond.jpg"; + +const meta: Meta = { + title: "Components/Controls/Image", + component: Image, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: "Image with placeholder, fallback and loading indicator", + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const BasicImage: Story = { + args: { src: diamond, style: { width: "20vw" } }, + parameters: { + docs: { + description: { + story: "Basic Image", + }, + }, + }, +}; + +export const ErrorImage: Story = { + args: { src: "doesnotexist.jpg", style: { width: "20vw" } }, + parameters: { + docs: { + description: { + story: "Image displayed when original image fails to load", + }, + }, + }, +}; diff --git a/src/components/controls/Image.test.tsx b/src/components/controls/Image.test.tsx new file mode 100644 index 00000000..0b5dada3 --- /dev/null +++ b/src/components/controls/Image.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Image } from "./Image"; + +import placeholderStaticImport from "../../public/images/diamond.jpg"; + +describe("Image", () => { + it("should render spinner while image isn't loaded", () => { + render({"foo"}); + + const image = screen.getByAltText("foo"); + + expect(image).toHaveAttribute("aria-busy", "true"); + + fireEvent.load(image); + + expect(image).toHaveAttribute("aria-busy", "false"); + }); + + it("should render placeholder image if an error occurs while loading image", () => { + render({"foo"}); + + const image = screen.getByAltText("foo"); + fireEvent.error(image); + + expect(image).toHaveAttribute("aria-errormessage", "Image not available"); + }); +}); diff --git a/src/components/controls/Image.tsx b/src/components/controls/Image.tsx new file mode 100644 index 00000000..5f495fe1 --- /dev/null +++ b/src/components/controls/Image.tsx @@ -0,0 +1,87 @@ +"use client"; +import { + DetailedHTMLProps, + ImgHTMLAttributes, + SyntheticEvent, + useState, +} from "react"; +import placeholder from "../../public/generic/no-image.png"; +import CircularProgress from "@mui/material/CircularProgress"; +import Box from "@mui/material/Box"; + +export interface ImageProps + extends Omit< + Omit< + DetailedHTMLProps, HTMLImageElement>, + "onLoad" | "onError" + >, + "src" + > { + src?: string | null; + onLoad?: () => void; + onError?: () => void; +} + +/** + * Smart image component that displays a placeholder on error, and a loading indicator if the image is still loading + */ +export const Image = ({ src, alt, onLoad, onError, ...props }: ImageProps) => { + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + const handleError = (e: SyntheticEvent) => { + if (onError) { + onError(); + } + + e.currentTarget.src = placeholder; + setIsError(true); + }; + + const handleLoad = () => { + if (onLoad) { + onLoad(); + } + + setIsLoading(false); + }; + + return ( + + {isLoading && ( + + + + )} + {alt + + ); +}; diff --git a/src/components/controls/ImageWithZoom.stories.tsx b/src/components/controls/ImageWithZoom.stories.tsx new file mode 100644 index 00000000..cca59f42 --- /dev/null +++ b/src/components/controls/ImageWithZoom.stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { ImageWithZoom } from "./ImageWithZoom"; + +import diamond from "../../public/images/diamond.jpg"; + +const meta: Meta = { + title: "Components/Controls/ImageWithZoom", + component: ImageWithZoom, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: "Image with user-controlled magnified area", + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const BasicImage: Story = { + args: { src: diamond, alt: "Diamond" }, + parameters: { + docs: { + description: { + story: "Basic image with magnified view on side", + }, + }, + }, +}; + +export const Brightness: Story = { + args: { src: diamond, alt: "Diamond", brightness: 0.5 }, + parameters: { + docs: { + description: { + story: "Image with brightness filter applied", + }, + }, + }, +}; diff --git a/src/components/controls/ImageWithZoom.test.tsx b/src/components/controls/ImageWithZoom.test.tsx new file mode 100644 index 00000000..93f867bd --- /dev/null +++ b/src/components/controls/ImageWithZoom.test.tsx @@ -0,0 +1,68 @@ +import { ImageWithZoom } from "./ImageWithZoom"; +import { render, screen } from "@testing-library/react"; + +/** + * This is particularly hard to test without visual testing (screenshot matching) + * With unit tests, refs don't work properly, nor is it particularly useful because there might be visual changes, + * but CSS remains the same. We should revisit this once we implement visual matching through Playwright/Vitest browser mode. + */ + +vi.mock("./Image", () => ({ + Image: ({ + onLoad, + alt, + src, + onClick, + }: { + onClick?: (e: Record) => void; + onLoad?: () => void; + alt: string; + src: string; + }) => { + if (onLoad) { + onLoad(); + } + if (onClick) { + onClick({ + currentTarget: { + getBoundingClientRect: () => ({ + left: 0, + top: 0, + width: 100, + height: 100, + }), + }, + }); + } + return {alt}; + }, +})); + +describe("Image with Zoom Viewer", () => { + it("should update brightness/contrast", () => { + render( + , + ); + + // https://github.com/vitest-dev/vitest/issues/9797 + const zoomView = screen.getByLabelText("Zoom View"); + expect(zoomView).toHaveAttribute( + "style", + expect.stringContaining("brightness(1.5)"), + ); + expect(zoomView).toHaveAttribute( + "style", + expect.stringContaining("contrast(0.5)"), + ); + }); + + it("should update image colour inversion", () => { + render(); + + const zoomView = screen.getByLabelText("Zoom View"); + expect(zoomView).toHaveAttribute( + "style", + expect.stringContaining("invert(1)"), + ); + }); +}); diff --git a/src/components/controls/ImageWithZoom.tsx b/src/components/controls/ImageWithZoom.tsx new file mode 100644 index 00000000..7366c760 --- /dev/null +++ b/src/components/controls/ImageWithZoom.tsx @@ -0,0 +1,219 @@ +"use client"; +import { useCallback, useMemo, useState, useRef, useEffect } from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import { clampNumber } from "../../utils/generic"; +import { Image } from "./Image"; +import { useWindowSize } from "../../utils/hooks"; + +export interface ImageWithZoomProps { + src: string; + alt?: string; + /** Width of zoomed view (magnified view) */ + zoomWidth?: string; + /** Width of lens. The zoom effect is a ratio between the lens size and the width of the magnified view. */ + lensWidth?: string; + /** Max total width */ + maxWidth?: string; + /** Always leave enough space on the left for the magnified view */ + alwaysPad?: boolean; + width?: string; + /** Whether to invert colours */ + invert?: boolean; + /** CSS filter brightness value (0 to 2, 1 being the default) */ + brightness?: number; + /** CSS filter contrast value (0 to 2, 1 being the default) */ + contrast?: number; +} + +/** + * Image viewer with zoomed in view that the user can move around + */ +export const ImageWithZoom = ({ + src, + alt, + width = "80%", + zoomWidth = "15vh", + maxWidth = "100vw", + lensWidth = "4vh", + alwaysPad = false, + invert = false, + brightness = 1, + contrast = 1, +}: ImageWithZoomProps) => { + const [isLoading, setIsLoading] = useState(true); + // Whether or not user has already zoomed in at least once + const [userZoomedIn, setUserZoomedIn] = useState(false); + + const [windowWidth, windowHeight] = useWindowSize(); + + const lensRef = useRef(null); + const zoomRef = useRef(null); + // The zoom view wrapper is a separate ref because that way it's unaffected by image filters + const zoomViewRef = useRef(null); + + const { breakpoints } = useTheme(); + + const imageFilter = useMemo( + // CSS value responsible for applying filters + () => + `invert(${invert ? "1" : "0"}) brightness(${brightness}) contrast(${contrast})`, + [invert, brightness, contrast], + ); + + const moveZoomWindow = useCallback( + (windowPos = "0", hideLens = false) => { + if (zoomViewRef.current && zoomRef.current) { + if (hideLens && lensRef.current) { + lensRef.current.style.display = "none"; + } + // Position window to left of image on larger screens + if (window.innerWidth > breakpoints.values.xl) { + zoomViewRef.current.style.left = "0px"; + } else { + // Move the zoomed in view left or right depending on where the lens is + // When the window is resized, it defaults to the left-hand side + zoomViewRef.current.style.left = windowPos; + } + } + }, + [zoomViewRef, zoomRef, lensRef, breakpoints], + ); + + // Width and height are dependencies as we need to listen to both to update the zoom window position + useEffect(() => { + moveZoomWindow("0", true); + }, [windowWidth, windowHeight, moveZoomWindow]); + + useEffect(() => { + // Reset loading indicator, user status when component is closed + return () => { + setIsLoading(false); + setUserZoomedIn(false); + }; + }, []); + + const updateMagPosition = useCallback( + (e: React.MouseEvent) => { + if (lensRef.current && zoomRef.current && zoomViewRef.current) { + // The zoom window starts off hidden + zoomViewRef.current.style.display = "block"; + lensRef.current.style.display = "block"; + setUserZoomedIn(true); + + const target = e.currentTarget.getBoundingClientRect(); + const ratioX = + zoomRef.current.offsetWidth / lensRef.current.offsetWidth; + const ratioY = + zoomRef.current.offsetHeight / lensRef.current.offsetHeight; + const halfWidth = lensRef.current.offsetWidth / 2; + const halfHeight = lensRef.current.offsetHeight / 2; + + // Limit the lens location to within bounds + /* + * If you modify the lens size in the CSS file, it will result in a zoom change inversely proportional + * to the size of the lens. The larger the lens, the lesser the zoom, and vice versa. + * This is to keep the lens true to what is actually displayed on the zoomed in view. + */ + const posX = clampNumber( + e.clientX - target.left - halfWidth, + target.width - halfWidth * 2, + ); + const posY = clampNumber( + e.clientY - target.top - halfHeight, + target.height - halfHeight * 2, + ); + + lensRef.current.style.left = `${posX}px`; + lensRef.current.style.top = `${posY}px`; + + // This moves the background in the zoom div, which corresponds to a zoomed in version of the larger + // image. + zoomRef.current.style.backgroundSize = `${target.width * ratioX}px ${target.height * ratioY}px`; + zoomRef.current.style.backgroundPosition = `-${posX * ratioX}px -${posY * ratioY}px`; + + moveZoomWindow( + posX > target.width / 2 + ? "0" + : `${target.width - zoomRef.current.offsetWidth}px`, + ); + } + }, + [lensRef, zoomRef, zoomViewRef, moveZoomWindow], + ); + + return ( + + {!isLoading && ( +
+
+
+ )} +
+ {!isLoading && ( +
+ )} + {alt} setIsLoading(false)} + onClick={updateMagPosition} + /> +
+ {!isLoading && ( + + Click to zoom in + + )} + + ); +}; diff --git a/src/components/controls/User.tsx b/src/components/controls/User.tsx index d7039dba..7c422594 100644 --- a/src/components/controls/User.tsx +++ b/src/components/controls/User.tsx @@ -99,7 +99,9 @@ const User = ({ + Math.min(Math.max(value, min), max); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts new file mode 100644 index 00000000..45831f05 --- /dev/null +++ b/src/utils/hooks.ts @@ -0,0 +1,19 @@ +import { useState, useLayoutEffect } from "react"; + +/** + * Hook for listening to window size changes + * + * @returns Window width and height + */ +export const useWindowSize = () => { + const [size, setSize] = useState([0, 0]); + useLayoutEffect(() => { + function updateSize() { + setSize([window.innerWidth, window.innerHeight]); + } + window.addEventListener("resize", updateSize); + updateSize(); + return () => window.removeEventListener("resize", updateSize); + }, []); + return size; +};