Skip to content

Commit

Permalink
chore: reuse image diff component in trace/html
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Dec 20, 2023
1 parent b33a04b commit f399d53
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 417 deletions.
47 changes: 0 additions & 47 deletions packages/html-reporter/src/imageDiffView.css

This file was deleted.

319 changes: 165 additions & 154 deletions packages/html-reporter/src/imageDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
limitations under the License.
*/

import type { TestAttachment } from './types';
import * as React from 'react';
import { AttachmentLink } from './links';
import type { TabbedPaneTab } from './tabbedPane';
import { TabbedPane } from './tabbedPane';
import './imageDiffView.css';
import './tabbedPane.css';

type TestAttachment = {
name: string;
body?: string;
path?: string;
contentType: string;
};

export type ImageDiff = {
name: string,
Expand All @@ -29,169 +30,179 @@ export type ImageDiff = {
diff?: { attachment: TestAttachment },
};

export const ImageDiffView: React.FunctionComponent<{
imageDiff: ImageDiff,
}> = ({ imageDiff: diff }) => {
// Pre-select a tab called "diff", if any.
const [selectedTab, setSelectedTab] = React.useState<string>('diff');
const diffElement = React.useRef<HTMLDivElement>(null);
const imageElement = React.useRef<HTMLImageElement>(null);
const [sliderPosition, setSliderPosition] = React.useState<number>(0);
const onImageLoaded = (side?: 'left' | 'right') => {
if (diffElement.current)
diffElement.current.style.minHeight = diffElement.current.offsetHeight + 'px';
if (side && diffElement.current && imageElement.current) {
const gap = Math.max(0, (diffElement.current.offsetWidth - imageElement.current.offsetWidth) / 2 - 20);
if (side === 'left')
setSliderPosition(gap);
else if (side === 'right')
setSliderPosition(diffElement.current.offsetWidth - gap);
}
};
const tabs: TabbedPaneTab[] = [];
if (diff.diff) {
tabs.push({
id: 'diff',
title: 'Diff',
render: () => <ImageWithSize src={diff.diff!.attachment.path!} onLoad={() => onImageLoaded()} />
});
tabs.push({
id: 'actual',
title: 'Actual',
render: () => <ImageDiffSlider sliderPosition={sliderPosition} setSliderPosition={setSliderPosition}>
<ImageWithSize src={diff.expected!.attachment.path!} onLoad={() => onImageLoaded('right')} imageRef={imageElement} style={{ boxShadow: 'none' }} />
<ImageWithSize src={diff.actual!.attachment.path!} />
</ImageDiffSlider>,
});
tabs.push({
id: 'expected',
title: diff.expected!.title,
render: () => <ImageDiffSlider sliderPosition={sliderPosition} setSliderPosition={setSliderPosition}>
<ImageWithSize src={diff.expected!.attachment.path!} onLoad={() => onImageLoaded('left')} imageRef={imageElement} />
<ImageWithSize src={diff.actual!.attachment.path!} style={{ boxShadow: 'none' }} />
</ImageDiffSlider>,
});
} else {
tabs.push({
id: 'actual',
title: 'Actual',
render: () => <ImageWithSize src={diff.actual!.attachment.path!} onLoad={() => onImageLoaded()} />
});
tabs.push({
id: 'expected',
title: diff.expected!.title,
render: () => <ImageWithSize src={diff.expected!.attachment.path!} onLoad={() => onImageLoaded()} />
async function loadImage(src?: string): Promise<HTMLImageElement> {
const image = new Image();
if (src) {
image.src = src;
await new Promise((f, r) => {
image.onload = f;
image.onerror = f;
});
}
return <div className='vbox image-diff-view' data-testid='test-result-image-mismatch' ref={diffElement}>
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
{diff.diff && <AttachmentLink attachment={diff.diff.attachment}></AttachmentLink>}
<AttachmentLink attachment={diff.actual!.attachment}></AttachmentLink>
<AttachmentLink attachment={diff.expected!.attachment}></AttachmentLink>
return image;
}

export const ImageDiffView: React.FC<{
diff: ImageDiff,
}> = ({ diff }) => {
const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual');
const [showSxsDiff, setShowSxsDiff] = React.useState<boolean>(false);

const [expectedImage, setExpectedImage] = React.useState<HTMLImageElement | null>(null);
const [actualImage, setActualImage] = React.useState<HTMLImageElement | null>(null);
const [diffImage, setDiffImage] = React.useState<HTMLImageElement | null>(null);
const [measure, ref] = useMeasure<HTMLDivElement>();

React.useEffect(() => {
(async () => {
setExpectedImage(await loadImage(diff.expected?.attachment.path));
setActualImage(await loadImage(diff.actual?.attachment.path));
setDiffImage(await loadImage(diff.diff?.attachment.path));
})();
}, [diff]);

const isLoaded = expectedImage && actualImage && diffImage;

const imageWidth = isLoaded ? Math.max(expectedImage.naturalWidth, actualImage.naturalWidth) : 500;
const fitWidth = Math.min(measure.width, imageWidth || 500);
const scale = fitWidth / imageWidth;
const fitHeight = Math.max(expectedImage?.naturalHeight || 0, actualImage?.naturalHeight || 0) * scale;

const modeStyle: React.CSSProperties = {
flex: 'none',
margin: '0 10px',
cursor: 'pointer',
userSelect: 'none',
};
return <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: 'auto' }} ref={ref}>
{isLoaded && <>
<div style={{ display: 'flex', margin: '10px 0 20px' }}>
{diff.diff && <div style={{ ...modeStyle, fontWeight: mode === 'diff' ? 600 : 'initial' }} onClick={() => setMode('diff')}>Diff</div>}
<div style={{ ...modeStyle, fontWeight: mode === 'actual' ? 600 : 'initial' }} onClick={() => setMode('actual')}>Actual</div>
<div style={{ ...modeStyle, fontWeight: mode === 'expected' ? 600 : 'initial' }} onClick={() => setMode('expected')}>Expected</div>
<div style={{ ...modeStyle, fontWeight: mode === 'slider' ? 600 : 'initial' }} onClick={() => setMode('slider')}>Slider</div>
<div style={{ ...modeStyle, fontWeight: mode === 'sxs' ? 600 : 'initial' }} onClick={() => setMode('sxs')}>Side by side</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', flex: 'auto', minHeight: fitHeight }}>
{diff.diff && mode === 'diff' && <ImageWithSize image={diffImage} scale={scale} />}
{diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} scale={scale} />}
{diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} scale={scale} />}
{diff.diff && mode === 'slider' && <ImageDiffSlider expectedImage={expectedImage} actualImage={actualImage} scale={scale} />}
{diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
<ImageWithSize image={expectedImage} scale={scale / 2}/>
<div style={{ width: 15 }}></div>
<ImageWithSize image={showSxsDiff ? diffImage : actualImage} onClick={() => setShowSxsDiff(!showSxsDiff)} scale={scale / 2} />
</div>}
{!diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} scale={scale} />}
{!diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} scale={scale} />}
{!diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
<ImageWithSize image={expectedImage} scale={scale / 2} />
<div style={{ width: 15 }}></div>
<ImageWithSize image={actualImage} scale={scale / 2}/>
</div>}
</div>
<div style={{ alignSelf: 'start' }}>
<div>{diff.diff && <a href={diff.diff.attachment.path}>{diff.diff.attachment.name}</a>}</div>
<div><a href={diff.actual!.attachment.path}>{diff.actual!.attachment.name}</a></div>
<div><a href={diff.expected!.attachment.path}>{diff.expected!.attachment.name}</a></div>
</div>
</>}
</div>;
};

export const ImageDiffSlider: React.FC<React.PropsWithChildren<{
sliderPosition: number,
setSliderPosition: (position: number) => void,
}>> = ({ children, sliderPosition, setSliderPosition }) => {
const [resizing, setResizing] = React.useState<{ offset: number, size: number } | null>(null);
const size = sliderPosition;

const childrenArray = React.Children.toArray(children);
document.body.style.userSelect = resizing ? 'none' : 'inherit';

const gripStyle: React.CSSProperties = {
...absolute,
zIndex: 100,
cursor: 'ew-resize',
left: resizing ? 0 : size - 4,
right: resizing ? 0 : undefined,
width: resizing ? 'initial' : 8,
export const ImageDiffSlider: React.FC<{
expectedImage: HTMLImageElement,
actualImage: HTMLImageElement,
scale: number,
}> = ({ expectedImage, actualImage, scale }) => {
const absoluteStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
};

return <>
{childrenArray[0]}
<div style={{ ...absolute }}>
<div style={{
...absolute,
display: 'flex',
zIndex: 50,
clip: `rect(0, ${size}px, auto, 0)`,
backgroundColor: 'var(--color-canvas-default)',
const [slider, setSlider] = React.useState<number>(5);
const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight;
const maxSize = {
width: Math.max(expectedImage.naturalWidth, actualImage.naturalWidth),
height: Math.max(expectedImage.naturalHeight, actualImage.naturalHeight),
};
const [resizing, setResizing] = React.useState<{ offset: number, slider: number } | null>(null);

return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column', userSelect: 'none' }}>
<div style={{ margin: 5 }}>
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>Expected </span>}
<span>{expectedImage.naturalWidth}</span>
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
<span>{expectedImage.naturalHeight}</span>
{!sameSize && <span style={{ flex: 'none', margin: '0 5px 0 15px' }}>Actual </span>}
{!sameSize && <span>{actualImage.naturalWidth}</span>}
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>x</span>}
{!sameSize && <span>{actualImage.naturalHeight}</span>}
</div>
<div style={{ position: 'relative', width: maxSize.width * scale, height: maxSize.height * scale }}
onMouseDown={event => setResizing({ offset: event.clientX, slider: slider })}
onMouseUp={() => setResizing(null)}
onMouseMove={event => {
if (!event.buttons) {
setResizing(null);
} else if (resizing) {
const offset = event.clientX;
const delta = offset - resizing.offset;
const newSlider = resizing.slider + delta;

const splitView = (event.target as HTMLElement).parentElement!;
const rect = splitView.getBoundingClientRect();
const slider = Math.min(Math.max(0, newSlider), rect.width);
setSlider(slider);
}
}}>
{childrenArray[1]}
<img style={{ width: expectedImage.naturalWidth * scale }} draggable='false' src={expectedImage.src} />
<div style={{ ...absoluteStyle, bottom: 0, overflow: 'hidden', width: slider }}>
<img style={{ width: actualImage.naturalWidth * scale }} draggable='false' src={actualImage.src} />
</div>
<div
style={gripStyle}
onMouseDown={event => setResizing({ offset: event.clientX, size })}
onMouseUp={() => setResizing(null)}
onMouseMove={event => {
if (!event.buttons) {
setResizing(null);
} else if (resizing) {
const offset = event.clientX;
const delta = offset - resizing.offset;
const newSize = resizing.size + delta;

const splitView = (event.target as HTMLElement).parentElement!;
const rect = splitView.getBoundingClientRect();
const size = Math.min(Math.max(0, newSize), rect.width);
setSliderPosition(size);
}
}}
></div>
<div data-testid='test-result-image-mismatch-grip' style={{
...absolute,
left: size - 1,
width: 20,
zIndex: 80,
margin: '10px -10px',
pointerEvents: 'none',
display: 'flex',
}}>
<div style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 9,
width: 2,
backgroundColor: 'var(--color-diff-blob-expander-icon)',
}}>
</div>
<svg style={{ fill: 'var(--color-diff-blob-expander-icon)' }} viewBox="0 0 27 20"><path d="M9.6 0L0 9.6l9.6 9.6z"></path><path d="M17 19.2l9.5-9.6L16.9 0z"></path></svg>
<div style={{ position: 'absolute', top: 0, bottom: 0, left: slider, width: 6, background: '#57606a80', cursor: 'ew-resize', overflow: 'visible', display: 'flex', alignItems: 'center' }}>
<svg style={{ fill: '#57606a80', width: 30, flex: 'none', marginLeft: -12, pointerEvents: 'none' }} viewBox="0 0 27 20"><path d="M9.6 0L0 9.6l9.6 9.6z"></path><path d="M17 19.2l9.5-9.6L16.9 0z"></path></svg>
</div>
</div>
</>;
</div>;
</div>;
};

const ImageWithSize: React.FunctionComponent<{
src: string,
onLoad?: () => void,
imageRef?: React.RefObject<HTMLImageElement>,
style?: React.CSSProperties,
}> = ({ src, onLoad, imageRef, style }) => {
const newRef = React.useRef<HTMLImageElement>(null);
const ref = imageRef ?? newRef;
const [size, setSize] = React.useState<{ width: number, height: number } | null>(null);
return <div className='image-wrapper'>
<div>
<span style={{ flex: '1 1 0', textAlign: 'end' }}>{ size ? size.width : ''}</span>
image: HTMLImageElement,
scale: number,
onClick?: () => void;
}> = ({ image, scale, onClick }) => {
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
<div style={{ margin: 5 }}>
<span>{image.naturalWidth}</span>
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
<span style={{ flex: '1 1 0', textAlign: 'start' }}>{ size ? size.height : ''}</span>
<span>{image.naturalHeight}</span>
</div>
<div style={{ display: 'flex', flex: 'none', width: image.naturalWidth * scale }}>
<img
style={{ cursor: onClick ? 'pointer' : 'initial' }}
draggable='false'
src={image.src}
onClick={onClick} />
</div>
<img src={src} onLoad={() => {
onLoad?.();
if (ref.current)
setSize({ width: ref.current.naturalWidth, height: ref.current.naturalHeight });
}} ref={ref} style={style} />
</div>;
};

const absolute: React.CSSProperties = {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
};
export function useMeasure<T extends Element>() {
const ref = React.useRef<T | null>(null);
const [measure, setMeasure] = React.useState(new DOMRect(0, 0, 10, 10));
React.useLayoutEffect(() => {
const target = ref.current;
if (!target)
return;
const resizeObserver = new ResizeObserver((entries: any) => {
const entry = entries[entries.length - 1];
if (entry && entry.contentRect)
setMeasure(entry.contentRect);
});
resizeObserver.observe(target);
return () => resizeObserver.disconnect();
}, [ref]);
return [measure, ref] as const;
}
Loading

0 comments on commit f399d53

Please sign in to comment.