diff --git a/public/chromatic-site-banner.png b/public/chromatic-site-banner.png new file mode 100644 index 00000000..7ca33172 Binary files /dev/null and b/public/chromatic-site-banner.png differ diff --git a/public/chromatic-site-desktop.png b/public/chromatic-site-desktop.png new file mode 100644 index 00000000..aff4ef58 Binary files /dev/null and b/public/chromatic-site-desktop.png differ diff --git a/public/chromatic-site-mobile.png b/public/chromatic-site-mobile.png new file mode 100644 index 00000000..9e82412e Binary files /dev/null and b/public/chromatic-site-mobile.png differ diff --git a/src/components/SnapshotImage.stories.tsx b/src/components/SnapshotImage.stories.tsx index 9a04939e..0767fd17 100644 --- a/src/components/SnapshotImage.stories.tsx +++ b/src/components/SnapshotImage.stories.tsx @@ -51,6 +51,7 @@ export const BothVisible = { export const Wider = { args: { + baselineImage: { imageUrl: "/shapes-taller.png", imageWidth: 588, imageHeight: 684 }, latestImage: { imageUrl: "/shapes-wider.png", imageWidth: 768, imageHeight: 472 }, diffImage: { imageUrl: "/shapes-comparison.png", imageWidth: 768 }, focusImage: { imageUrl: "/shapes-focus.png", imageWidth: 768 }, @@ -68,6 +69,7 @@ export const WiderConstrained = { export const Taller = { args: { + baselineImage: { imageUrl: "/shapes-wider.png", imageWidth: 768, imageHeight: 472 }, latestImage: { imageUrl: "/shapes-taller.png", imageWidth: 588, imageHeight: 684 }, diffImage: { imageUrl: "/shapes-comparison.png", imageWidth: 768 }, focusImage: { imageUrl: "/shapes-focus.png", imageWidth: 768 }, @@ -83,8 +85,22 @@ export const TallerConstrained = { }, } satisfies Story; +export const NoBaseline = { + args: { + baselineImage: undefined, + }, +} satisfies Story; + +export const NoLatest = { + args: { + latestImage: undefined, + baselineImageVisible: true, + }, +} satisfies Story; + export const CaptureError = { args: { + baselineImage: undefined, latestImage: undefined, comparisonResult: ComparisonResult.CaptureError, }, diff --git a/src/components/SnapshotImage.tsx b/src/components/SnapshotImage.tsx index 1008044c..2a5bec6f 100644 --- a/src/components/SnapshotImage.tsx +++ b/src/components/SnapshotImage.tsx @@ -1,4 +1,4 @@ -import { PhotoIcon, ShareAltIcon } from "@storybook/icons"; +import { PhotoIcon } from "@storybook/icons"; import { styled, useTheme } from "@storybook/theming"; import React, { ComponentProps } from "react"; @@ -7,55 +7,50 @@ import { Spinner } from "./design-system"; import { Stack } from "./Stack"; import { Text } from "./Text"; -export const Container = styled.div<{ href?: string; target?: string }>( - ({ theme }) => ({ - position: "relative", - display: "flex", - background: "transparent", - overflow: "hidden", - margin: 2, - maxWidth: "calc(100% - 4px)", +export const Container = styled.div(({ theme }) => ({ + position: "relative", + display: "flex", + width: "max-content", + maxWidth: "calc(100% - 4px)", + background: "transparent", + overflow: "hidden", + margin: 2, - "& > div": { - display: "flex", - flexDirection: "column", - alignItems: "center", - width: "100%", - p: { - maxWidth: 380, - textAlign: "center", - }, - svg: { - width: 24, - height: 24, - }, + img: { + maxWidth: "100%", + transition: "filter 0.1s ease-in-out", + }, + "img[data-overlay]": { + position: "absolute", + opacity: 0.7, + pointerEvents: "none", + }, + "& > div": { + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "100%", + p: { + maxWidth: 380, + textAlign: "center", }, - "& > svg": { - position: "absolute", - left: "calc(50% - 14px)", - top: "calc(50% - 14px)", - width: 20, - height: 20, - color: theme.color.lightest, - opacity: 0, - transition: "opacity 0.1s ease-in-out", - pointerEvents: "none", + svg: { + width: 24, + height: 24, }, - }), - ({ href }) => - href && { - display: "inline-flex", - cursor: "pointer", - "&:hover": { - "& > svg": { - opacity: 1, - }, - img: { - filter: "brightness(85%)", - }, - }, - } -); + }, + "& > svg": { + position: "absolute", + left: "calc(50% - 14px)", + top: "calc(50% - 14px)", + width: 20, + height: 20, + color: theme.color.lightest, + opacity: 0, + transition: "opacity 0.1s ease-in-out", + pointerEvents: "none", + }, +})); const ImageWrapper = styled.div<{ isVisible?: boolean }>(({ isVisible }) => ({ position: isVisible ? "static" : "absolute", @@ -102,7 +97,6 @@ const getOverlayImageLoaded = ({ interface SnapshotImageProps { componentName?: NonNullable["component"]>["name"]; storyName?: NonNullable["name"]; - testUrl: Test["webUrl"]; comparisonResult?: ComparisonResult; latestImage?: Pick; baselineImage?: Pick; @@ -116,7 +110,6 @@ interface SnapshotImageProps { export const SnapshotImage = ({ componentName, storyName, - testUrl, comparisonResult, latestImage, baselineImage, @@ -131,9 +124,6 @@ export const SnapshotImage = ({ const hasDiff = !!latestImage && !!diffImage && comparisonResult === ComparisonResult.Changed; const hasError = comparisonResult === ComparisonResult.CaptureError; const hasFocus = hasDiff && !!focusImage; - const containerProps = hasDiff - ? { as: "a" as any, href: testUrl, target: "_blank", title: "View on Chromatic.com" } - : {}; const showDiff = hasDiff && diffVisible; const showFocus = hasFocus && focusVisible; @@ -150,7 +140,7 @@ export const SnapshotImage = ({ }); return ( - + {latestImage && ( setFocusImageLoaded(true)} /> )} - {hasDiff && } {hasError && !latestImage && ( diff --git a/src/components/ZoomContainer.stories.tsx b/src/components/ZoomContainer.stories.tsx new file mode 100644 index 00000000..cce48981 --- /dev/null +++ b/src/components/ZoomContainer.stories.tsx @@ -0,0 +1,52 @@ +import { type Meta } from "@storybook/react"; +import React from "react"; + +import { ZoomContainer, ZoomProvider } from "./ZoomContainer"; + +export default { + component: ZoomContainer, + decorators: (Story) => ( + + + + ), +} satisfies Meta; + +export const Default = { + args: { + children: , + }, +}; + +export const Wide = { + args: { + children: , + }, +}; + +export const Tall = { + args: { + children: , + }, +}; + +export const Small = { + args: { + children: , + }, +}; + +export const Mirror = { + render() { + return ( +
+ + + + + + +
+ ); + }, +}; diff --git a/src/components/ZoomContainer.tsx b/src/components/ZoomContainer.tsx new file mode 100644 index 00000000..9f5dc2d4 --- /dev/null +++ b/src/components/ZoomContainer.tsx @@ -0,0 +1,212 @@ +import { styled } from "@storybook/theming"; +import React, { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +const TRANSITION_DURATION_MS = 200; + +const Container = styled.div({ + margin: "auto", + width: "max-content", + maxWidth: "100%", + maxHeight: "100%", + overflow: "hidden", + img: { + verticalAlign: "top", + }, +}); + +const Content = styled.div({ + width: "max-content", +}); + +const initialState = { + containerHeight: 0, + containerWidth: 0, + contentHeight: 0, + contentWidth: 0, + contentScale: 1, + translateX: 0, + translateY: 0, + multiplierX: 1, + multiplierY: 1, + zoomed: false, + transition: "none", + transitionTimeout: 0, +}; + +type State = typeof initialState; + +export const ZoomContext = createContext<[State, Dispatch>]>(null as any); + +export const ZoomProvider = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +export const useZoom = () => { + const [state, setState] = useContext(ZoomContext); + + const containerRef = useRef(null); + const contentRef = useRef(null); + + const updateState = useCallback(() => { + const { current: container } = containerRef; + const { current: content } = contentRef; + if (!container || !content) return; + + const contentWidth = content.offsetWidth; + const contentHeight = content.offsetHeight; + const containerWidth = Math.min(contentWidth, container.offsetWidth); + const containerHeight = Math.min(contentHeight, container.offsetHeight); + + const contentScale = Math.min( + 1, + contentWidth ? containerWidth / contentWidth : 1, + contentHeight ? containerHeight / contentHeight : 1 + ); + const translateX = + contentWidth > containerWidth + ? (contentWidth - containerWidth) / -2 + : (containerWidth - contentWidth * contentScale) / -2; + const translateY = + contentHeight > containerHeight + ? (contentHeight - containerHeight) / -2 + : (containerHeight - contentHeight * contentScale) / -2; + + setState((currentState) => ({ + ...currentState, + containerHeight, + containerWidth, + contentHeight, + contentWidth, + contentScale, + translateX, + translateY, + })); + }, [containerRef, contentRef, setState]); + + const onMouseEvent = useCallback( + (e: MouseEvent) => { + if (!containerRef.current) return; + const { x, y, width, height } = containerRef.current.getBoundingClientRect(); + + setState((currentState) => { + const { clientX: cx, clientY: cy, type: eventType } = e; + const hovered = cx >= x && cx <= x + width && cy >= y && cy <= y + height; + if (!hovered) return currentState; + + const { containerHeight, containerWidth, contentHeight, contentWidth } = currentState; + const ratioX = contentWidth < containerWidth ? contentWidth / contentHeight + 1 : 1; + const ratioY = contentHeight < containerHeight ? contentHeight / contentWidth + 1 : 1; + const multiplierX = ((cx - x) / containerWidth) * 2 * 1.2 * ratioX - 0.2 * ratioX; + const multiplierY = ((cy - y) / containerHeight) * 2 * 1.2 * ratioY - 0.2 * ratioY; + + const clicked = eventType === "click" && (e as any).pointerType !== "touch"; + const zoomed = clicked ? !currentState.zoomed : currentState.zoomed; + const update = { + ...currentState, + multiplierX: zoomed ? multiplierX : 1, + multiplierY: zoomed ? multiplierY : 1, + zoomed, + }; + if (!clicked) return update; + + window.clearTimeout(currentState.transitionTimeout); + return { + ...update, + transition: `transform ${TRANSITION_DURATION_MS}ms`, + transitionTimeout: window.setTimeout( + () => setState((s) => ({ ...s, transition: "none", transitionTimeout: 0 })), + TRANSITION_DURATION_MS + ), + }; + }); + }, + [containerRef, setState] + ); + + const onToggleZoom = useCallback( + (e: React.MouseEvent) => onMouseEvent(e.nativeEvent), + [onMouseEvent] + ); + + useEffect(() => { + window.addEventListener("mousemove", onMouseEvent); + return () => window.removeEventListener("mousemove", onMouseEvent); + }, [onMouseEvent]); + + const resizeObserver = useMemo(() => new ResizeObserver(() => updateState()), [updateState]); + + useEffect(() => { + const { current: container } = containerRef; + const { current: content } = contentRef; + if (!container || !content) return () => {}; + + resizeObserver.observe(container); + resizeObserver.observe(content); + + return () => { + resizeObserver.unobserve(container); + resizeObserver.unobserve(content); + }; + }, [containerRef, contentRef, resizeObserver]); + + const translateX = clamp( + state.multiplierX * state.translateX, + -(state.contentWidth - state.containerWidth), + 0 + ); + const translateY = clamp( + state.multiplierY * state.translateY, + -(state.contentHeight - state.containerHeight), + 0 + ); + const scale = state.zoomed ? 1 : state.contentScale; + const contentStyle = { + transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`, + transition: state.transition, + ...(state.contentScale < 1 && { cursor: state.zoomed ? "zoom-out" : "zoom-in" }), + }; + + return { + containerProps: { + ref: containerRef, + }, + contentProps: { + ref: contentRef, + style: contentStyle, + onClick: onToggleZoom, + }, + renderProps: { + isZoomed: state.zoomed, + toggleZoom: onToggleZoom, + }, + }; +}; + +export const ZoomContainer = ({ + children, + render, +}: { + children?: ReactNode; + render?: (props: ReturnType["renderProps"]) => ReactNode; +}) => { + const { containerProps, contentProps, renderProps } = useZoom(); + + return ( + + {render ? render(renderProps) : children} + + ); +}; diff --git a/src/screens/VisualTests/SnapshotComparison.tsx b/src/screens/VisualTests/SnapshotComparison.tsx index 83a24823..b8ffedb0 100644 --- a/src/screens/VisualTests/SnapshotComparison.tsx +++ b/src/screens/VisualTests/SnapshotComparison.tsx @@ -5,6 +5,7 @@ import React, { useEffect } from "react"; import { Link } from "../../components/design-system"; import { SnapshotImage } from "../../components/SnapshotImage"; import { Text } from "../../components/Text"; +import { ZoomContainer, ZoomProvider } from "../../components/ZoomContainer"; import { ComparisonResult, TestResult, TestStatus } from "../../gql/graphql"; import { summarizeTests } from "../../utils/summarizeTests"; import { useSelectedBuildState, useSelectedStoryState } from "./BuildContext"; @@ -282,20 +283,26 @@ export const SnapshotComparison = ({ )} {!isInProgress && selectedComparison && ( - + + ( + + )} + /> + )} {!isInProgress && captureErrorData && ( diff --git a/src/screens/VisualTests/SnapshotControls.tsx b/src/screens/VisualTests/SnapshotControls.tsx index 3e81e57b..1a964cae 100644 --- a/src/screens/VisualTests/SnapshotControls.tsx +++ b/src/screens/VisualTests/SnapshotControls.tsx @@ -107,6 +107,7 @@ export const SnapshotControls = ({ isOutdated }: { isOutdated: boolean }) => { + {/* */} );