Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement snapshot zooming and panning, sliding diff and blend-mode for diff overlay #151

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/components/SnapshotImage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const meta = {
args: {
componentName: "Shapes",
storyName: "Primary",
baselineImage: { imageUrl: "/A.png", imageWidth: 880 },
latestImage: { imageUrl: "/B.png", imageWidth: 880 },
baselineImage: { imageUrl: "/A.png", imageWidth: 880, imageHeight: 280 },
latestImage: { imageUrl: "/B.png", imageWidth: 880, imageHeight: 280 },
diffImage: { imageUrl: "/B-comparison.png", imageWidth: 880 },
focusImage: { imageUrl: "/B-focus.png", imageWidth: 880 },
comparisonResult: ComparisonResult.Changed,
Expand Down Expand Up @@ -50,7 +50,8 @@ export const BothVisible = {

export const Wider = {
args: {
latestImage: { imageUrl: "/shapes-wider.png", imageWidth: 768 },
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 },
diffVisible: true,
Expand All @@ -67,7 +68,8 @@ export const WiderConstrained = {

export const Taller = {
args: {
latestImage: { imageUrl: "/shapes-taller.png", imageWidth: 588 },
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 },
diffVisible: true,
Expand All @@ -82,8 +84,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,
},
Expand Down
169 changes: 107 additions & 62 deletions src/components/SnapshotImage.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,85 @@
import { Icons } from "@storybook/components";
import { styled } from "@storybook/theming";
import React, { ComponentProps } from "react";
import React, { ComponentProps, RefObject, useRef } from "react";

import { CaptureImage, CaptureOverlayImage, ComparisonResult, Test } from "../gql/graphql";
import { Text } from "./Text";

export const Container = styled.div<{ href?: string; target?: string }>(
({ theme }) => ({
position: "relative",
display: "flex",
background: "transparent",
overflow: "hidden",
margin: 2,
export const Container = styled.div<{ isZoomed?: boolean }>(({ isZoomed, theme }) => ({
position: "relative",
display: "flex",
width: "max-content",
background: "transparent",
overflow: "hidden",
margin: 2,

img: {
maxWidth: "100%",
transition: "filter 0.1s ease-in-out",
},
"img[data-overlay]": {
img: {
maxWidth: "100%",
transition: "filter 0.1s ease-in-out",
},
"img[data-overlay]": {
position: "absolute",
opacity: 0.7,
pointerEvents: "none",
zIndex: 2,
},
"img[data-overlay='diff']": {
mixBlendMode: "multiply",
},
"span[data-divider]": {
position: "absolute",
height: "100%",
width: 2,
zIndex: 3,
background: theme.background.content,
boxShadow: `0 0 20px ${theme.color.defaultText}99`,
pointerEvents: "none",
"&::before, &::after": {
position: "absolute",
opacity: 0.7,
pointerEvents: "none",
top: 2,
opacity: isZoomed ? 1 : 0,
padding: "1px 3px",
background: theme.background.content,
borderRadius: 2,
// boxShadow: `0 0 20px ${theme.color.defaultText}33`,
color: theme.color.defaultText,
fontSize: 9,
fontSmooth: "always",
letterSpacing: 1,
textTransform: "uppercase",
transition: "opacity 0.2s",
},
div: {
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
margin: "30px 15px",
color: theme.color.mediumdark,
p: {
maxWidth: 380,
textAlign: "center",
},
svg: {
width: 28,
height: 28,
},
"&::before": {
content: '"baseline"',
right: 4,
},
"& > svg": {
position: "absolute",
left: "calc(50% - 14px)",
top: "calc(50% - 14px)",
"&::after": {
content: '"latest"',
left: 4,
},
},
"div['data-error']": {
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
margin: "30px 15px",
color: theme.color.mediumdark,
zIndex: 1,
p: {
maxWidth: 380,
textAlign: "center",
},
svg: {
width: 28,
height: 28,
color: theme.color.lightest,
opacity: 0,
transition: "opacity 0.1s ease-in-out",
pointerEvents: "none",
},
}),
({ href }) =>
href && {
display: "inline-flex",
"&:hover": {
"& > svg": {
opacity: 1,
},
img: {
filter: "brightness(85%)",
},
},
}
);
},
}));

interface SnapshotImageProps {
componentName?: NonNullable<NonNullable<Test["story"]>["component"]>["name"];
storyName?: NonNullable<Test["story"]>["name"];
testUrl: Test["webUrl"];
comparisonResult?: ComparisonResult;
latestImage?: Pick<CaptureImage, "imageUrl" | "imageWidth">;
baselineImage?: Pick<CaptureImage, "imageUrl" | "imageWidth">;
Expand All @@ -78,10 +90,26 @@ interface SnapshotImageProps {
focusVisible: boolean;
}

const useDividerPosition = (targetRef: RefObject<HTMLElement>) => {
const [dividerPosition, setDividerPosition] = React.useState<number | null>(null);

React.useEffect(() => {
const onMouseMove = ({ clientX: cx, clientY: cy }: MouseEvent) => {
if (!targetRef?.current) return;
const { x, y, width, height } = targetRef.current.getBoundingClientRect();
const hovering = cx >= x && cx <= x + width && cy >= y && cy <= y + height;
setDividerPosition(hovering ? ((cx - x) / width) * 100 : null);
};
window.addEventListener("mousemove", onMouseMove);
return () => window.removeEventListener("mousemove", onMouseMove);
}, [targetRef]);

return dividerPosition;
};

export const SnapshotImage = ({
componentName,
storyName,
testUrl,
comparisonResult,
latestImage,
baselineImage,
Expand All @@ -95,29 +123,46 @@ 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" } : {};
const showDiff = hasDiff && diffVisible;
const showFocus = hasFocus && focusVisible;

const imageRef = useRef<HTMLImageElement>(null);
const dividerPosition = useDividerPosition(imageRef);
const showDivider = !!latestImage && !!baselineImage && !!dividerPosition;

return (
<Container {...props} {...containerProps}>
<Container {...props}>
{latestImage && (
<img
ref={imageRef}
data-snapshot="latest"
alt={`Latest snapshot for the '${storyName}' story of the '${componentName}' component`}
src={latestImage.imageUrl}
style={{
opacity: showDiff && !showFocus ? 0.7 : 1,
display: baselineImageVisible ? "none" : "block",
filter: showDiff && !showFocus ? `brightness(105%)` : "none",
zIndex: baselineImageVisible ? 0 : 1,
clipPath: showDivider ? `inset(0 0 0 ${dividerPosition}%)` : "none",
cursor: showDivider ? "ew-resize" : "auto",
}}
/>
)}
{baselineImage && (
<img
data-snapshot="baseline"
alt={`Baseline snapshot for the '${storyName}' story of the '${componentName}' component`}
src={baselineImage.imageUrl}
style={{
opacity: showDiff && !showFocus ? 0.7 : 1,
display: baselineImageVisible ? "block" : "none",
position: latestImage ? "absolute" : "relative",
filter: showDiff && !showFocus ? `brightness(105%)` : "none",
zIndex: baselineImageVisible ? 1 : 0,
clipPath:
showDivider && baselineImageVisible
? `inset(0 ${100 - dividerPosition}% 0 0)`
: "none",
cursor: showDivider ? "ew-resize" : "auto",
maxWidth: latestImage
? `${(baselineImage.imageWidth / latestImage.imageWidth) * 100}%`
: "100%",
}}
/>
)}
Expand All @@ -143,9 +188,9 @@ export const SnapshotImage = ({
}}
/>
)}
{hasDiff && <Icons icon="sharealt" />}
{showDivider && <span data-divider style={{ left: `${dividerPosition}%` }} />}
{hasError && !latestImage && (
<div>
<div data-error>
<Icons icon="photo" />
<Text>
A snapshot couldn’t be captured. This often occurs when a story has a code error.
Expand Down
46 changes: 46 additions & 0 deletions src/components/ZoomContainer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";

import { ZoomContainer } from "./ZoomContainer";

export default {
component: ZoomContainer,
};

export const Default = {
args: {
children: (
<>
<img src="/A.png" alt="" />
<br />
<img src="/A.png" alt="" />
</>
),
},
};

export const Wide = {
args: {
children: (
<>
<img src="/A.png" alt="" />
<img src="/A.png" alt="" />
</>
),
},
};

export const Tall = {
args: {
children: (
<>
<img src="/A.png" alt="" />
<br />
<img src="/A.png" alt="" />
<br />
<img src="/A.png" alt="" />
<br />
<img src="/A.png" alt="" />
</>
),
},
};
Loading
Loading