From daf13ed51ebf48e6f7e1cfceb989a8e801b5226c Mon Sep 17 00:00:00 2001
From: shadowusr <58862284+shadowusr@users.noreply.github.com>
Date: Tue, 17 Sep 2024 17:57:28 +0300
Subject: [PATCH] refactor: re-write screenshots viewing to using css only
(#600)
* refactor: re-write screenshots viewing to using css only
* refactor: change screenshot component api to more concise
* fix: fix images sizing when not loaded
* fix: fix images aspect ratio
* chore(new-ui): fix review issues
---
.../modals/screenshot-accepter/body.jsx | 8 +-
.../modals/screenshot-accepter/style.css | 3 +
.../section/body/page-screenshot.tsx | 4 +-
.../state/screenshot/diff-circle.jsx | 59 --------
.../components/state/screenshot/full.jsx | 29 ----
.../components/state/screenshot/resized.jsx | 92 ------------
.../state/screenshot/with-encode-uri.jsx | 44 ------
.../state/screenshot/with-synced-scale.jsx | 116 ---------------
lib/static/components/state/state-error.jsx | 6 +-
.../components/state/state-fail/index.jsx | 107 +++++---------
.../components/state/state-fail/index.styl | 13 --
.../state-fail/onion-skin-diff/index.jsx | 44 ------
.../state-fail/onion-skin-diff/index.styl | 19 ---
.../components/state/state-fail/prop-types.js | 16 --
.../state/state-fail/swipe-diff/index.jsx | 138 ------------------
.../state/state-fail/swipe-diff/index.styl | 82 -----------
.../state/state-fail/switch-diff/index.jsx | 36 -----
.../state/state-fail/switch-diff/index.styl | 12 --
.../state/state-fail/useFitImages.js | 63 --------
lib/static/components/state/state-success.jsx | 6 +-
.../components/DiffViewer/ListMode.module.css | 4 +
.../new-ui/components/DiffViewer/ListMode.tsx | 28 ++++
.../DiffViewer/OnionSkinMode.module.css | 23 +++
.../components/DiffViewer/OnionSkinMode.tsx | 38 +++++
.../components/DiffViewer/OnlyDiffMode.tsx | 12 ++
.../DiffViewer/SideBySideMode.module.css | 11 ++
.../components/DiffViewer/SideBySideMode.tsx | 31 ++++
.../DiffViewer/SideBySideToFitMode.module.css | 15 ++
.../DiffViewer/SideBySideToFitMode.tsx | 36 +++++
.../DiffViewer/SwipeMode.module.css | 43 ++++++
.../components/DiffViewer/SwipeMode.tsx | 71 +++++++++
.../DiffViewer/SwitchMode.module.css | 15 ++
.../components/DiffViewer/SwitchMode.tsx | 31 ++++
.../components/DiffViewer/common.module.css | 14 ++
.../new-ui/components/DiffViewer/types.ts | 9 ++
.../new-ui/components/DiffViewer/utils.ts | 7 +
.../Screenshot/DiffCircle.module.css | 13 ++
.../components/Screenshot/DiffCircle.tsx | 64 ++++++++
.../components/Screenshot/index.module.css | 17 +++
.../new-ui/components/Screenshot/index.tsx | 80 ++++++++++
.../new-ui/components/Screenshot/utils.ts | 19 +++
lib/static/new-ui/types/store.ts | 3 +-
lib/static/styles.css | 55 +------
test/func/tests/utils.js | 2 +-
test/setup/globals.js | 3 +-
.../modals/screenshot-accepter/body.jsx | 8 +-
.../state/screenshot/diff-circle.jsx | 43 ------
.../components/state/screenshot/full.jsx | 21 ---
.../components/state/screenshot/resized.jsx | 21 ---
.../components/Screenshot/DiffCircle.tsx | 65 +++++++++
.../new-ui/components/Screenshot/index.tsx | 21 +++
51 files changed, 733 insertions(+), 987 deletions(-)
delete mode 100644 lib/static/components/state/screenshot/diff-circle.jsx
delete mode 100644 lib/static/components/state/screenshot/full.jsx
delete mode 100644 lib/static/components/state/screenshot/resized.jsx
delete mode 100644 lib/static/components/state/screenshot/with-encode-uri.jsx
delete mode 100644 lib/static/components/state/screenshot/with-synced-scale.jsx
delete mode 100644 lib/static/components/state/state-fail/onion-skin-diff/index.jsx
delete mode 100644 lib/static/components/state/state-fail/onion-skin-diff/index.styl
delete mode 100644 lib/static/components/state/state-fail/prop-types.js
delete mode 100644 lib/static/components/state/state-fail/swipe-diff/index.jsx
delete mode 100644 lib/static/components/state/state-fail/swipe-diff/index.styl
delete mode 100644 lib/static/components/state/state-fail/switch-diff/index.jsx
delete mode 100644 lib/static/components/state/state-fail/switch-diff/index.styl
delete mode 100644 lib/static/components/state/state-fail/useFitImages.js
create mode 100644 lib/static/new-ui/components/DiffViewer/ListMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/ListMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/OnionSkinMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/OnionSkinMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/OnlyDiffMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/SideBySideMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/SideBySideMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/SwipeMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/SwipeMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/SwitchMode.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/SwitchMode.tsx
create mode 100644 lib/static/new-ui/components/DiffViewer/common.module.css
create mode 100644 lib/static/new-ui/components/DiffViewer/types.ts
create mode 100644 lib/static/new-ui/components/DiffViewer/utils.ts
create mode 100644 lib/static/new-ui/components/Screenshot/DiffCircle.module.css
create mode 100644 lib/static/new-ui/components/Screenshot/DiffCircle.tsx
create mode 100644 lib/static/new-ui/components/Screenshot/index.module.css
create mode 100644 lib/static/new-ui/components/Screenshot/index.tsx
create mode 100644 lib/static/new-ui/components/Screenshot/utils.ts
delete mode 100644 test/unit/lib/static/components/state/screenshot/diff-circle.jsx
delete mode 100644 test/unit/lib/static/components/state/screenshot/full.jsx
delete mode 100644 test/unit/lib/static/components/state/screenshot/resized.jsx
create mode 100644 test/unit/lib/static/new-ui/components/Screenshot/DiffCircle.tsx
create mode 100644 test/unit/lib/static/new-ui/components/Screenshot/index.tsx
diff --git a/lib/static/components/modals/screenshot-accepter/body.jsx b/lib/static/components/modals/screenshot-accepter/body.jsx
index 925e03125..bc4c4a835 100644
--- a/lib/static/components/modals/screenshot-accepter/body.jsx
+++ b/lib/static/components/modals/screenshot-accepter/body.jsx
@@ -1,10 +1,10 @@
import React, {Component, Fragment} from 'react';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
-import ResizedScreenshot from '../../state/screenshot/resized';
import StateFail from '../../state/state-fail';
import {isNoRefImageError} from '../../../../common-utils';
import ViewInBrowserIcon from '../../icons/view-in-browser';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
class ScreenshotAccepterBody extends Component {
static propTypes = {
@@ -36,11 +36,7 @@ class ScreenshotAccepterBody extends Component {
}
_renderImageBox(image) {
- return (
-
-
-
- );
+ return ;
}
_renderTitle() {
diff --git a/lib/static/components/modals/screenshot-accepter/style.css b/lib/static/components/modals/screenshot-accepter/style.css
index b1d690616..4135642db 100644
--- a/lib/static/components/modals/screenshot-accepter/style.css
+++ b/lib/static/components/modals/screenshot-accepter/style.css
@@ -136,6 +136,9 @@
padding-left: 15px;
padding-right: 15px;
margin-bottom: 10px;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
}
.screenshot-accepter__body .screenshot-accepter__icon_view-in-browser {
diff --git a/lib/static/components/section/body/page-screenshot.tsx b/lib/static/components/section/body/page-screenshot.tsx
index 374620382..0f607d533 100644
--- a/lib/static/components/section/body/page-screenshot.tsx
+++ b/lib/static/components/section/body/page-screenshot.tsx
@@ -1,7 +1,7 @@
import React, {Component} from 'react';
import Details from '../../details';
-import ResizedScreenshot from '../../state/screenshot/resized';
import {ImageFile} from '../../../../types';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
interface PageScreenshotProps {
image: ImageFile;
@@ -11,7 +11,7 @@ export class PageScreenshot extends Component {
render(): JSX.Element {
return }
+ content={(): JSX.Element => }
/>;
}
}
diff --git a/lib/static/components/state/screenshot/diff-circle.jsx b/lib/static/components/state/screenshot/diff-circle.jsx
deleted file mode 100644
index 6655e43f0..000000000
--- a/lib/static/components/state/screenshot/diff-circle.jsx
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict';
-
-import React, {Component} from 'react';
-import PropTypes from 'prop-types';
-import {CIRCLE_RADIUS} from '../../../../constants/defaults';
-
-export default class DiffCircle extends Component {
- static propTypes = {
- originalImageWidth: PropTypes.number.isRequired,
- diffTarget: PropTypes.object.isRequired,
- diffBounds: PropTypes.object.isRequired,
- display: PropTypes.bool.isRequired,
- toggleDiff: PropTypes.func.isRequired
- };
-
- _getRect() {
- const {originalImageWidth, diffBounds, diffTarget} = this.props;
- const targetRect = diffTarget.getBoundingClientRect();
- const sizeCoeff = diffTarget.offsetWidth / originalImageWidth;
-
- const rectHeight = Math.ceil(sizeCoeff * (diffBounds.bottom - diffBounds.top + 1));
- const rectWidth = Math.ceil(sizeCoeff * (diffBounds.right - diffBounds.left + 1));
-
- const rectMiddleX = (diffBounds.left + diffBounds.right) / 2;
- const rectMiddleY = (diffBounds.top + diffBounds.bottom) / 2;
-
- return {
- x: targetRect.left + sizeCoeff * rectMiddleX,
- y: targetRect.top + sizeCoeff * rectMiddleY,
- minSize: Math.floor(Math.sqrt(rectWidth * rectWidth + rectHeight * rectHeight))
- };
- }
-
- render() {
- const {display, toggleDiff} = this.props;
-
- if (!display) {
- return null;
- }
-
- const diffRect = this._getRect();
- const diffCircle = {
- width: `${diffRect.minSize}px`,
- height: `${diffRect.minSize}px`,
- top: `${Math.ceil(diffRect.y - diffRect.minSize / 2)}px`,
- left: `${Math.ceil(diffRect.x - diffRect.minSize / 2)}px`
- };
- const radius = diffRect.minSize + CIRCLE_RADIUS;
-
- return (
- toggleDiff()}
- >
-
- );
- }
-}
diff --git a/lib/static/components/state/screenshot/full.jsx b/lib/static/components/state/screenshot/full.jsx
deleted file mode 100644
index 5b4034f56..000000000
--- a/lib/static/components/state/screenshot/full.jsx
+++ /dev/null
@@ -1,29 +0,0 @@
-'use strict';
-
-import React, {Component} from 'react';
-import PropTypes from 'prop-types';
-import withEncodeUri from './with-encode-uri';
-
-class FullScreenshot extends Component {
- static propTypes = {
- imgRef: PropTypes.shape({
- current: PropTypes.instanceOf(Element)
- }),
- style: PropTypes.shape({
- width: PropTypes.number,
- opacity: PropTypes.number,
- visibility: PropTypes.string
- }),
- // from withEncodeUri
- imageUrl: PropTypes.string.isRequired,
- className: PropTypes.string
- };
-
- render() {
- const {imageUrl, className, imgRef, style} = this.props;
-
- return ;
- }
-}
-
-export default withEncodeUri(FullScreenshot);
diff --git a/lib/static/components/state/screenshot/resized.jsx b/lib/static/components/state/screenshot/resized.jsx
deleted file mode 100644
index 9bfd4b93d..000000000
--- a/lib/static/components/state/screenshot/resized.jsx
+++ /dev/null
@@ -1,92 +0,0 @@
-'use strict';
-
-import React, {Component, Fragment} from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import DiffCircle from './diff-circle';
-import withEncodeUri from './with-encode-uri';
-
-class ResizedScreenshot extends Component {
- constructor(props) {
- super(props);
-
- this.state = {
- showDiffCircle: false,
- diffTarget: {}
- };
- }
-
- static propTypes = {
- style: PropTypes.object,
- image: PropTypes.shape({
- path: PropTypes.string.isRequired,
- size: PropTypes.shape({
- width: PropTypes.number,
- height: PropTypes.number
- })
- }).isRequired,
- diffClusters: PropTypes.array,
- overrideWidth: PropTypes.number,
- // from withEncodeUri
- imageUrl: PropTypes.string.isRequired,
- className: PropTypes.string
- };
-
- _getScreenshotComponent(elem, diffClusters) {
- return
- {diffClusters && diffClusters.map((c, id) => )}
- {elem}
- ;
- }
-
- toggleDiff = () => {
- this.setState({showDiffCircle: !this.state.showDiffCircle});
- };
-
- _handleDiffClick = () => ({target}) => {
- this.toggleDiff();
- this.setState({diffTarget: target.parentElement});
- };
-
- render() {
- const {imageUrl, image: {size: imgSize}, diffClusters, overrideWidth, style} = this.props;
- const cursorStyle = diffClusters ? {cursor: 'pointer'} : {};
-
- if (!imgSize) {
- const elem = (
-
-
-
- );
-
- return this._getScreenshotComponent(elem, diffClusters);
- }
-
- const imgClassNames = classNames(
- 'image-box__screenshot',
- this.props.className
- );
-
- const paddingTop = ((imgSize.height / imgSize.width) * 100).toFixed(2);
- const elem = (
-
-
-
- );
-
- return this._getScreenshotComponent(elem, diffClusters);
- }
-}
-
-export default withEncodeUri(ResizedScreenshot);
diff --git a/lib/static/components/state/screenshot/with-encode-uri.jsx b/lib/static/components/state/screenshot/with-encode-uri.jsx
deleted file mode 100644
index 4c2ca6876..000000000
--- a/lib/static/components/state/screenshot/with-encode-uri.jsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {isUrl} from '../../../../common-utils';
-
-// for prevent image caching
-function addTimestamp(imagePath) {
- return `${imagePath}?t=${Date.now()}`;
-}
-
-function encodeUri(imagePath) {
- if (isUrl(imagePath)) {
- return imagePath;
- }
-
- return imagePath
- // we can't use path.sep here because on Windows browser returns '/' instead of '\\'
- .split(/\/|\\/)
- .map((item) => encodeURIComponent(item))
- .join('/');
-}
-
-function withEncodeUri(ScreenshotComponent) {
- return function wrapper(props) {
- const {noCache, image: {path: imgPath}} = props;
-
- const imageUrl = noCache
- ? addTimestamp(encodeUri(imgPath))
- : encodeUri(imgPath);
-
- return ;
- };
-}
-
-withEncodeUri.propTypes = {
- noCache: PropTypes.bool,
- image: PropTypes.shape({
- path: PropTypes.string.isRequired
- }).isRequired
-};
-
-export default withEncodeUri;
diff --git a/lib/static/components/state/screenshot/with-synced-scale.jsx b/lib/static/components/state/screenshot/with-synced-scale.jsx
deleted file mode 100644
index 34fda26a4..000000000
--- a/lib/static/components/state/screenshot/with-synced-scale.jsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import useResizeObserver from '@react-hook/resize-observer';
-import PropTypes from 'prop-types';
-import React, {useEffect, useRef, useState} from 'react';
-
-export default (WrappedComponent) => {
- function WithSyncedScale(props) {
- const {image1, image2} = props;
-
- const scaleFactor = useRef(1);
- const shouldScale = useRef(image1.size.width !== image2.size.width);
- const isFirstImageWider = useRef(image1.size.width > image2.size.width);
-
- const [state, setState] = useState({
- syncedImage1: {
- containerRef: useRef(null),
- width: image1.size.width
- },
- syncedImage2: {
- containerRef: useRef(null),
- width: image2.size.width
- },
- resizesNum: 0
- });
-
- const getRenderedImgWidth = (syncedImage) => {
- return syncedImage.containerRef.current
- ? syncedImage.containerRef.current.offsetWidth
- : syncedImage.width;
- };
-
- const _calcScaleFactor = () => {
- if (!shouldScale.current) {
- return scaleFactor.current;
- }
-
- const {image1, image2} = props;
- const {syncedImage1, syncedImage2} = state;
-
- const imgWidth = isFirstImageWider.current ? image1.size.width : image2.size.width;
- const renderedImgWidth = isFirstImageWider.current
- ? getRenderedImgWidth(syncedImage1)
- : getRenderedImgWidth(syncedImage2);
-
- return renderedImgWidth / imgWidth;
- };
-
- const _getScaledWidth = (image) => {
- if (!shouldScale.current) {
- return image.size.width;
- }
-
- const scaledWidth = Math.ceil(image.size.width * scaleFactor.current);
-
- return Math.min(scaledWidth, image.size.width);
- };
-
- const _handleResize = () => {
- if (!shouldScale.current) {
- return;
- }
-
- const {image1, image2} = props;
- scaleFactor.current = _calcScaleFactor();
-
- setState({
- syncedImage1: {
- ...state.syncedImage1,
- width: isFirstImageWider.current ? image1.size.width : _getScaledWidth(image1)
- },
- syncedImage2: {
- ...state.syncedImage2,
- width: isFirstImageWider.current ? _getScaledWidth(image2) : image2.size.width
- },
- resizesNum: state.resizesNum + 1
- });
- };
-
- useEffect(() => {
- _handleResize();
- }, []);
-
- const containerRef = useRef(null);
- useResizeObserver(containerRef, _handleResize);
-
- const {syncedImage1, syncedImage2, resizesNum} = state;
-
- return (
-
-
-
- );
- }
-
- WithSyncedScale.propTypes = {
- image1: PropTypes.shape({
- size: PropTypes.shape({
- width: PropTypes.number,
- height: PropTypes.number
- })
- }).isRequired,
- image2: PropTypes.shape({
- size: PropTypes.shape({
- width: PropTypes.number,
- height: PropTypes.number
- })
- }).isRequired
- };
-
- return WithSyncedScale;
-};
diff --git a/lib/static/components/state/state-error.jsx b/lib/static/components/state/state-error.jsx
index b7fba739c..8e67d6832 100644
--- a/lib/static/components/state/state-error.jsx
+++ b/lib/static/components/state/state-error.jsx
@@ -10,12 +10,12 @@ import escapeHtml from 'escape-html';
import ansiHtml from 'ansi-html-community';
import stripAnsi from 'strip-ansi';
import * as actions from '../../modules/actions';
-import ResizedScreenshot from './screenshot/resized';
import ErrorDetails from './error-details';
import Details from '../details';
import {ERROR_TITLE_TEXT_LENGTH} from '../../../constants/errors';
import {isAssertViewError, isImageDiffError, isNoRefImageError, mergeSnippetIntoErrorStack, trimArray} from '../../../common-utils';
import {Card} from '@gravity-ui/uikit';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
ansiHtml.setColors({
reset: ['#', '#'],
@@ -52,7 +52,7 @@ class StateError extends Component {
const {image, error} = this.props;
if (image.actualImg && isNoRefImageError(error)) {
- return ;
+ return ;
}
return null;
@@ -122,7 +122,7 @@ class StateError extends Component {
: {...error, message: `${errorPattern.name}\n${error?.message}`, hint: () => parseHtmlString(errorPattern.hint)};
return (
-
+
{
this._shouldDrawErrorInfo(extendedError)
?
{
const [diffMode, setDiffMode] = useState(diffModeProp);
- const [fitWidths, {expectedRef, actualRef}] = useFitImages(image, isScreenshotAccepterOpened);
useEffect(() => {
setDiffMode(diffModeProp);
@@ -30,84 +31,52 @@ const StateFail = ({image, diffMode: diffModeProp, isScreenshotAccepterOpened})
);
};
- const getLabelKey = () => {
- const images = [image.expectedImg, image.actualImg];
- const sizes = images.map(image => `${image.size.width}${image.size.height}`);
- const key = sizes.join('');
-
- return key;
- };
-
- const drawImageBox = (image, {label, diffClusters, width, ref} = {}) => {
- const titleText = `${label} (${image.size.width}x${image.size.height})`;
- const titleKey = getLabelKey();
-
- return (
-
- {label && !isScreenshotAccepterOpened &&
{titleText}
}
-
-
- );
- };
-
- const renderOnlyDiff = () => {
- const {diffImg, diffClusters} = image;
-
- return drawImageBox(diffImg, {diffClusters});
- };
-
- const drawExpectedAndActual = ({expectedImg, expectedWidth}, {actualImg, actualWidth}) => {
- return (
-
- {drawImageBox(expectedImg, {label: 'Expected', width: expectedWidth, ref: expectedRef})}
- {drawImageBox(actualImg, {label: 'Actual', width: actualWidth, ref: actualRef})}
-
- );
- };
-
- const renderThreeImages = (fitWidths = []) => {
- const {expectedImg, actualImg, diffImg, diffClusters} = image;
- const [expectedWidth, actualWidth, diffWidth] = fitWidths;
+ const getImageLabel = (text, image) => {
+ if (isScreenshotAccepterOpened) {
+ return null;
+ }
- return
- {drawExpectedAndActual({expectedImg, expectedWidth}, {actualImg, actualWidth})}
- {drawImageBox(diffImg, {label: 'Diff', diffClusters, width: diffWidth})}
- ;
+ return {`${text} (${image.size.width}x${image.size.height})`}
;
};
const renderImages = () => {
- const {expectedImg, actualImg} = image;
+ const expectedImg = Object.assign({}, image.expectedImg, {
+ label: getImageLabel('Expected', image.expectedImg)
+ });
+ const actualImg = Object.assign({}, image.actualImg, {
+ label: getImageLabel('Actual', image.actualImg)
+ });
+ const diffImg = Object.assign({}, image.diffImg, {
+ label: getImageLabel('Diff', image.diffImg),
+ diffClusters: image.diffClusters
+ });
switch (diffMode) {
case DiffModes.ONLY_DIFF.id:
- return renderOnlyDiff();
+ return ;
case DiffModes.SWITCH.id:
- return ;
+ return ;
case DiffModes.SWIPE.id:
- return ;
+ return ;
case DiffModes.ONION_SKIN.id:
- return ;
+ return ;
case DiffModes.THREE_UP_SCALED.id:
- return
- {renderThreeImages()}
-
;
+ return ;
- case DiffModes.THREE_UP_SCALED_TO_FIT.id:
- return
- {renderThreeImages(fitWidths)}
-
;
+ case DiffModes.THREE_UP_SCALED_TO_FIT.id: {
+ // In screenshot accepter we want images to fit .image-box__container height by making it container-type: size and specifying 100cqh.
+ // In regular view we want images to fit viewport minus approximate header and accept buttons height.
+ const desiredHeight = isScreenshotAccepterOpened ? '100cqh' : 'calc(100vh - 180px)';
+ return ;
+ }
case DiffModes.THREE_UP.id:
default:
- return renderThreeImages();
+ return ;
}
};
diff --git a/lib/static/components/state/state-fail/index.styl b/lib/static/components/state/state-fail/index.styl
index ade5aa816..f5c32aff2 100644
--- a/lib/static/components/state/state-fail/index.styl
+++ b/lib/static/components/state/state-fail/index.styl
@@ -2,16 +2,3 @@
display: flex
justify-content: center
margin: 10px auto
-
-.image-box__scaled
- display: flex
- flex-flow: row wrap
-
- .image-box__image
- padding: 0 5px
-
- &:first-child
- padding-left: 0
-
- &:last-child
- padding-right: 0
diff --git a/lib/static/components/state/state-fail/onion-skin-diff/index.jsx b/lib/static/components/state/state-fail/onion-skin-diff/index.jsx
deleted file mode 100644
index b863d0b46..000000000
--- a/lib/static/components/state/state-fail/onion-skin-diff/index.jsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React, {Component} from 'react';
-import FullScreenshot from '../../screenshot/full';
-import withSyncedScale from '../../screenshot/with-synced-scale';
-import {imageType, syncedImageType} from '../prop-types';
-
-import './index.styl';
-import {Slider} from '@gravity-ui/uikit';
-
-const DEFAULT_IMAGE_TRANSPARENCY = 0.5;
-
-class OnionSkinDiff extends Component {
- static propTypes = {
- image1: imageType.isRequired,
- image2: imageType.isRequired,
- // from withSyncedScale
- syncedImage1: syncedImageType.isRequired,
- syncedImage2: syncedImageType.isRequired
- };
-
- state = {imgTransparency: DEFAULT_IMAGE_TRANSPARENCY};
-
- _handleChangeTransparency = (value) => {
- return this.setState({imgTransparency: value});
- };
-
- render() {
- const {image1, image2, syncedImage1, syncedImage2} = this.props;
- const {imgTransparency} = this.state;
-
- return (
-
- );
- }
-}
-
-export default withSyncedScale(OnionSkinDiff);
diff --git a/lib/static/components/state/state-fail/onion-skin-diff/index.styl b/lib/static/components/state/state-fail/onion-skin-diff/index.styl
deleted file mode 100644
index c0394ecd3..000000000
--- a/lib/static/components/state/state-fail/onion-skin-diff/index.styl
+++ /dev/null
@@ -1,19 +0,0 @@
-.onion-skin-diff
- &__img-container
- display: grid
- grid-template-areas: 'one one'
- max-width: max-content
-
- &__img
- max-width: 100%
- grid-area: one
- border: 1px solid #ccc
-
- &__footer
- display: flex
- justify-content: center
-
- &__slider
- width: 250px
- margin-top: 10px
- cursor: pointer
diff --git a/lib/static/components/state/state-fail/prop-types.js b/lib/static/components/state/state-fail/prop-types.js
deleted file mode 100644
index f6a10bb5d..000000000
--- a/lib/static/components/state/state-fail/prop-types.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import PropTypes from 'prop-types';
-
-export const imageType = PropTypes.shape({
- path: PropTypes.string.isRequired,
- size: PropTypes.shape({
- width: PropTypes.number,
- height: PropTypes.number
- })
-});
-
-export const syncedImageType = PropTypes.shape({
- containerRef: PropTypes.shape({
- current: PropTypes.instanceOf(Element)
- }),
- width: PropTypes.number
-});
diff --git a/lib/static/components/state/state-fail/swipe-diff/index.jsx b/lib/static/components/state/state-fail/swipe-diff/index.jsx
deleted file mode 100644
index a00bd2aaf..000000000
--- a/lib/static/components/state/state-fail/swipe-diff/index.jsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import React, {Component} from 'react';
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import FullScreenshot from '../../screenshot/full';
-import ArrowsMove from '../../../icons/arrows-move';
-import withSyncedScale from '../../screenshot/with-synced-scale';
-import {imageType, syncedImageType} from '../prop-types';
-
-import './index.styl';
-
-function clamp(n, min = 0, max = 100) {
- return Math.max(Math.min(n, max), min);
-}
-
-class SwipeDiff extends Component {
- static propTypes = {
- image1: imageType.isRequired,
- image2: imageType.isRequired,
- // from withSyncedScale
- syncedImage1: syncedImageType.isRequired,
- syncedImage2: syncedImageType.isRequired,
- resizesNum: PropTypes.number.isRequired,
- getRenderedImgWidth: PropTypes.func.isRequired
- };
-
- constructor(props) {
- super(props);
-
- this._swipeContainer = React.createRef();
- this._swipeContainerLeft = 0;
-
- this.state = {
- mousePosX: 0,
- isPressed: false,
- dividerWasMoved: false
- };
- }
-
- componentDidMount() {
- this._swipeContainerLeft = this._swipeContainer.current.getBoundingClientRect().left;
-
- this.setState({mousePosX: this._getMiddleMousePosX()});
- }
-
- componentDidUpdate(prevProps) {
- if (this.state.dividerWasMoved || prevProps.resizesNum === this.props.resizesNum) {
- return;
- }
-
- this.setState({mousePosX: this._getMiddleMousePosX()});
- }
-
- _getMiddleMousePosX() {
- const {image1, syncedImage1, syncedImage2, getRenderedImgWidth} = this.props;
- const middlePosX = Math.min(getRenderedImgWidth(syncedImage1), getRenderedImgWidth(syncedImage2)) / 2;
-
- return clamp(middlePosX, 0, image1.size.width);
- }
-
- _getMousePosX(e) {
- const newMousePosX = e.pageX - this._swipeContainerLeft - window.scrollX;
- return clamp(newMousePosX, 0, this.props.syncedImage1.width);
- }
-
- _calcImgPosX(width) {
- const imgPosX = this.state.mousePosX / width * 100;
- return clamp(imgPosX, 0, 100);
- }
-
- _handleMouseMove = (e) => {
- if (!this.state.isPressed) {
- return;
- }
-
- this.setState({mousePosX: this._getMousePosX(e)});
- };
-
- _handleMouseDown = (e) => {
- this.setState({mousePosX: this._getMousePosX(e), isPressed: true, dividerWasMoved: true});
-
- window.addEventListener('mousemove', this._handleMouseMove);
- window.addEventListener('mouseup', this._handleMouseUp);
-
- document.body.classList.add('disable-user-select');
- };
-
- _handleMouseUp = () => {
- this.setState({isPressed: false});
- document.body.classList.remove('disable-user-select');
-
- window.removeEventListener('mouseup', this._handleMouseUp);
- window.removeEventListener('mousemove', this._handleMouseMove);
- };
-
- render() {
- const {image1, image2, syncedImage1, syncedImage2, getRenderedImgWidth} = this.props;
- const {isPressed} = this.state;
-
- const maxRenderedWidth = Math.max(getRenderedImgWidth(syncedImage1), getRenderedImgWidth(syncedImage2));
- const img1PosX = 100 - this._calcImgPosX(maxRenderedWidth);
- const img2PosX = this._calcImgPosX(maxRenderedWidth);
-
- const className = classNames(
- 'swipe-diff',
- {'swipe-diff_pressed': isPressed}
- );
-
- return (
-
- );
- }
-}
-
-export default withSyncedScale(SwipeDiff);
diff --git a/lib/static/components/state/state-fail/swipe-diff/index.styl b/lib/static/components/state/state-fail/swipe-diff/index.styl
deleted file mode 100644
index 661891d95..000000000
--- a/lib/static/components/state/state-fail/swipe-diff/index.styl
+++ /dev/null
@@ -1,82 +0,0 @@
-.swipe-diff
- display: grid
- grid-template-areas: 'one one'
- max-width: max-content
- overflow: hidden
- cursor: move
-
- &_pressed .swipe-diff-divider__arrows
- opacity: 0
-
- &__overlay
- overflow: hidden
-
- &__img
- max-width: 100%
- pointer-events: none
- user-select: none
- border: 1px solid #ccc
-
- &__first
- grid-area: one
- line-height: 0
- transform: translateX(calc(var(--img1-pos-x) * -1))
-
- .swipe-diff__frame
- transform: translateX(var(--img1-pos-x))
-
- &__second
- line-height: 0
- grid-area: one
- transform: translateX(var(--img2-pos-x))
- overflow: hidden
-
- .swipe-diff__frame
- transform: translateX(calc(var(--img2-pos-x) * -1))
-
- &-divider
- transform: translateX(50%)
- position: absolute
- height: 100%
- top: 0
- right: 0
- display: flex
- align-items: flex-end
- justify-content: center
- flex-direction: column
-
- &__line
- position: absolute
- height: 100%
- width: 100%
- left: 0
- top: 0
- display: flex
- align-items: center
- justify-content: center
- flex-direction: column
-
- &:after
- content: ''
- display: block
- height: 100%
- border-left-width: 1px
- border-left-style: solid
- border-left-color: black
-
- &__arrows
- pointer-events: none
- box-sizing: border-box
- margin-left: 1px
- transform: translateX(-0.5px)
- opacity: 1
- transition: opacity 500ms
-
- .arrows-move
- width: 60px
-
- &__icon
- stroke: black
-
-.disable-user-select
- user-select: none
diff --git a/lib/static/components/state/state-fail/switch-diff/index.jsx b/lib/static/components/state/state-fail/switch-diff/index.jsx
deleted file mode 100644
index c94366db0..000000000
--- a/lib/static/components/state/state-fail/switch-diff/index.jsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React, {Component} from 'react';
-import FullScreenshot from '../../screenshot/full';
-import withSyncedScale from '../../screenshot/with-synced-scale';
-import {imageType, syncedImageType} from '../prop-types';
-
-import './index.styl';
-
-class SwitchDiff extends Component {
- static propTypes = {
- image1: imageType.isRequired,
- image2: imageType.isRequired,
- // from withSyncedScale
- syncedImage1: syncedImageType.isRequired,
- syncedImage2: syncedImageType.isRequired
- };
-
- state = {showFirst: true};
-
- _handleClick = () => this.setState({showFirst: !this.state.showFirst});
-
- render() {
- const {image1, image2, syncedImage1, syncedImage2} = this.props;
- const {showFirst} = this.state;
- const displayImage1 = showFirst ? 'visible' : 'hidden';
- const displayImage2 = !showFirst ? 'visible' : 'hidden';
-
- return (
-
-
-
-
- );
- }
-}
-
-export default withSyncedScale(SwitchDiff);
diff --git a/lib/static/components/state/state-fail/switch-diff/index.styl b/lib/static/components/state/state-fail/switch-diff/index.styl
deleted file mode 100644
index 8d457e2e4..000000000
--- a/lib/static/components/state/state-fail/switch-diff/index.styl
+++ /dev/null
@@ -1,12 +0,0 @@
-.switch-diff
- display: grid
- grid-template-areas: 'one one'
- max-width: max-content
- cursor: pointer
-
- &__img
- grid-area: one
- max-width: 100%
- pointer-events: none
- user-select: none
- border: 1px solid #ccc
diff --git a/lib/static/components/state/state-fail/useFitImages.js b/lib/static/components/state/state-fail/useFitImages.js
deleted file mode 100644
index 679ae9418..000000000
--- a/lib/static/components/state/state-fail/useFitImages.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import {useMemo} from 'react';
-import {clamp} from 'lodash';
-import useWindowSize from '../../../hooks/useWindowSize';
-import useElementSize from '../../../hooks/useElementSize';
-
-const TITLE_DEFAULT_TOP_OFFSET = 280;
-const MIN_SHRINK_RATIO = 1;
-const MAX_SHRINK_RATIO = 2;
-
-export default function useFitImages(image, isScreenshotAccepterOpened) {
- const {height: windowHeight} = useWindowSize();
- const [expectedRef, expectedPos] = useElementSize();
- const [actualRef, actualPos] = useElementSize();
-
- const imagesWidth = useMemo(() => {
- return [image.expectedImg, image.actualImg, image.diffImg].map(img => img.size.width);
- }, [image]);
-
- const imagesHeight = useMemo(() => {
- return [image.expectedImg, image.actualImg, image.diffImg].map(img => img.size.height);
- }, [image]);
-
- const sectionsWidth = useMemo(() => {
- const expectedWidth = expectedPos.width;
- const actualWidth = actualPos.width;
- const diffWidth = Math.max(expectedWidth, actualWidth);
-
- return [expectedWidth, actualWidth, diffWidth];
- }, [expectedPos, actualPos]);
-
- const displayedImagesWidth = useMemo(() => {
- return sectionsWidth.map((sectionWidth, i) => Math.min(sectionWidth, imagesWidth[i]));
- }, [sectionsWidth, imagesWidth]);
-
- const displayedImagesHeight = useMemo(() => {
- return imagesHeight.map((height, i) => height * displayedImagesWidth[i] / imagesWidth[i]);
- }, [imagesHeight, imagesWidth, displayedImagesWidth]);
-
- const topOffsets = useMemo(() => {
- const expectedRightBorder = expectedPos.left + expectedPos.width;
- const actualLeftBorder = actualPos.left;
- const imageGap = (actualLeftBorder - expectedRightBorder) / 2;
-
- const expectedTitleOffset = isScreenshotAccepterOpened ? expectedPos.top : TITLE_DEFAULT_TOP_OFFSET;
- const actualTitleOffset = isScreenshotAccepterOpened ? actualPos.top : TITLE_DEFAULT_TOP_OFFSET;
-
- const expectedImageOffset = expectedTitleOffset + expectedPos.height + imageGap;
- const actualImageOffset = actualTitleOffset + actualPos.height + imageGap;
- const diffImageOffset = Math.min(expectedImageOffset, actualImageOffset);
-
- return [expectedImageOffset, actualImageOffset, diffImageOffset];
- }, [expectedPos, actualPos, isScreenshotAccepterOpened]);
-
- return useMemo(() => {
- const availableHeights = topOffsets.map(offset => windowHeight - offset);
- const shrinkCoef = displayedImagesHeight.reduce((acc, height, i) => {
- return clamp(height / availableHeights[i], acc, MAX_SHRINK_RATIO);
- }, MIN_SHRINK_RATIO);
- const fitWidths = displayedImagesWidth.map((width) => width / shrinkCoef);
-
- return [fitWidths, {expectedRef, actualRef}];
- }, [topOffsets, displayedImagesWidth, windowHeight]);
-}
diff --git a/lib/static/components/state/state-success.jsx b/lib/static/components/state/state-success.jsx
index 3361a1bd2..5a9c095a0 100644
--- a/lib/static/components/state/state-success.jsx
+++ b/lib/static/components/state/state-success.jsx
@@ -2,8 +2,8 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
-import ResizedScreenshot from './screenshot/resized';
import {isUpdatedStatus} from '../../../common-utils';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
export default class StateSuccess extends Component {
static propTypes = {
@@ -15,9 +15,7 @@ export default class StateSuccess extends Component {
const {status, expectedImg} = this.props;
return (
-
-
-
+
);
}
}
diff --git a/lib/static/new-ui/components/DiffViewer/ListMode.module.css b/lib/static/new-ui/components/DiffViewer/ListMode.module.css
new file mode 100644
index 000000000..bae8df1d4
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/ListMode.module.css
@@ -0,0 +1,4 @@
+.list-mode {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/ListMode.tsx b/lib/static/new-ui/components/DiffViewer/ListMode.tsx
new file mode 100644
index 000000000..2530303da
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/ListMode.tsx
@@ -0,0 +1,28 @@
+import React, {ReactNode} from 'react';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+
+import styles from './ListMode.module.css';
+import {ScreenshotDisplayData} from '@/static/new-ui/components/DiffViewer/types';
+
+interface SideBySideToFitModeProps {
+ actual: ScreenshotDisplayData;
+ diff: ScreenshotDisplayData;
+ expected: ScreenshotDisplayData;
+}
+
+export function ListMode(props: SideBySideToFitModeProps): ReactNode {
+ return
+
+ {props.expected.label}
+
+
+
+ {props.actual.label}
+
+
+
+ {props.diff.label}
+
+
+
;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/OnionSkinMode.module.css b/lib/static/new-ui/components/DiffViewer/OnionSkinMode.module.css
new file mode 100644
index 000000000..e8552e7a2
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/OnionSkinMode.module.css
@@ -0,0 +1,23 @@
+.onion-skin {
+ cursor: pointer;
+}
+
+.image-wrapper--actual {
+ position: absolute;
+}
+
+.image {
+ max-width: none;
+}
+
+.slider-container {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.slider {
+ cursor: pointer;
+ margin-top: 10px;
+ width: 250px;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/OnionSkinMode.tsx b/lib/static/new-ui/components/DiffViewer/OnionSkinMode.tsx
new file mode 100644
index 000000000..654ea345f
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/OnionSkinMode.tsx
@@ -0,0 +1,38 @@
+import classNames from 'classnames';
+import React, {ReactNode, useState} from 'react';
+
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+import {ImageFile} from '@/types';
+import commonStyles from './common.module.css';
+import styles from './OnionSkinMode.module.css';
+import {Slider} from '@gravity-ui/uikit';
+
+interface OnionSkinModeProps {
+ expected: ImageFile;
+ actual: ImageFile;
+}
+
+export function OnionSkinMode(props: OnionSkinModeProps): ReactNode {
+ const {expected, actual} = props;
+ const maxNaturalWidth = Math.max(expected.size.width, actual.size.width);
+ const maxNaturalHeight = Math.max(expected.size.height, actual.size.height);
+
+ const [rightImageOpacity, setRightImageOpacity] = useState(0.5);
+
+ const onUpdateHandler = (value: number | [number, number]): void=> {
+ setRightImageOpacity(value as number);
+ };
+
+ const wrapperStyle = {'--max-natural-width': maxNaturalWidth, '--max-natural-height': maxNaturalHeight} as React.CSSProperties;
+ const actualImageStyle: React.CSSProperties = {opacity: rightImageOpacity};
+
+ return ;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/OnlyDiffMode.tsx b/lib/static/new-ui/components/DiffViewer/OnlyDiffMode.tsx
new file mode 100644
index 000000000..9aeab925d
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/OnlyDiffMode.tsx
@@ -0,0 +1,12 @@
+import React, {ReactNode} from 'react';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+import {ImageFile} from '@/types';
+import {CoordBounds} from 'looks-same';
+
+interface OnlyDiffModeProps {
+ diff: ImageFile & {diffClusters?: CoordBounds[]};
+}
+
+export function OnlyDiffMode(props: OnlyDiffModeProps): ReactNode {
+ return ;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SideBySideMode.module.css b/lib/static/new-ui/components/DiffViewer/SideBySideMode.module.css
new file mode 100644
index 000000000..2586b2ff9
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SideBySideMode.module.css
@@ -0,0 +1,11 @@
+.side-by-side-mode {
+ display: flex;
+ justify-content: space-between;
+ column-gap: 4px;
+}
+
+.image-wrapper {
+ flex: var(--natural-width) 0 0;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SideBySideMode.tsx b/lib/static/new-ui/components/DiffViewer/SideBySideMode.tsx
new file mode 100644
index 000000000..bc5bf1e37
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SideBySideMode.tsx
@@ -0,0 +1,31 @@
+import React, {ReactNode} from 'react';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+
+import styles from './SideBySideMode.module.css';
+import {ScreenshotDisplayData} from './types';
+import {getImageSizeCssVars} from '@/static/new-ui/components/DiffViewer/utils';
+
+interface SideBySideToFitModeProps {
+ actual: ScreenshotDisplayData;
+ diff: ScreenshotDisplayData;
+ expected: ScreenshotDisplayData;
+}
+
+export function SideBySideMode(props: SideBySideToFitModeProps): ReactNode {
+ const {expected, actual, diff} = props;
+
+ return
+
+ {expected.label}
+
+
+
+ {actual.label}
+
+
+
+ {diff.label}
+
+
+
;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.module.css b/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.module.css
new file mode 100644
index 000000000..f3cff1b8b
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.module.css
@@ -0,0 +1,15 @@
+.side-by-side-to-fit-mode {
+ display: flex;
+ justify-content: space-between;
+ column-gap: 4px;
+}
+
+.image-wrapper {
+ flex: var(--natural-width) 0 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.image {
+ max-height: max(var(--desired-height), calc(var(--natural-height) * 1px / 2));
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.tsx b/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.tsx
new file mode 100644
index 000000000..0d968361c
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SideBySideToFitMode.tsx
@@ -0,0 +1,36 @@
+import React, {ReactNode} from 'react';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+
+import styles from './SideBySideToFitMode.module.css';
+import {ScreenshotDisplayData} from './types';
+import {getImageSizeCssVars} from '@/static/new-ui/components/DiffViewer/utils';
+
+interface SideBySideToFitModeProps {
+ expected: ScreenshotDisplayData;
+ actual: ScreenshotDisplayData;
+ diff: ScreenshotDisplayData;
+ /**
+ * A valid CSS value assignable to height, e.g. `10px` or `calc(100vh - 50px)`.
+ * Images will try to fit the `desiredHeight`, but will only shrink no more than 2 times.
+ * */
+ desiredHeight: string;
+}
+
+export function SideBySideToFitMode(props: SideBySideToFitModeProps): ReactNode {
+ const {expected, actual, diff} = props;
+
+ return
+
+ {expected.label}
+
+
+
+ {actual.label}
+
+
+
+ {diff.label}
+
+
+
;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SwipeMode.module.css b/lib/static/new-ui/components/DiffViewer/SwipeMode.module.css
new file mode 100644
index 000000000..a5de7dbd2
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SwipeMode.module.css
@@ -0,0 +1,43 @@
+.divider {
+ cursor: col-resize;
+ background-color: #000;
+ height: 100%;
+ position: relative;
+ width: 1px;
+ z-index: 2;
+}
+
+.divider-icons {
+ display: flex;
+ gap: 10px;
+ left: 50%;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%) scale(1.2);
+ transition: .3s opacity ease;
+}
+
+.is-dragging {
+ cursor: col-resize;
+}
+
+.is-dragging .divider-icons {
+ opacity: 0;
+}
+
+.image {
+ max-width: none;
+ pointer-events: none;
+ width: auto;
+}
+
+.left-section {
+ background-color: white;
+ max-width: fit-content;
+ overflow: hidden;
+ z-index: 1;
+}
+
+.right-section {
+ position: absolute;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SwipeMode.tsx b/lib/static/new-ui/components/DiffViewer/SwipeMode.tsx
new file mode 100644
index 000000000..f3ac30a6b
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SwipeMode.tsx
@@ -0,0 +1,71 @@
+import {ChevronLeft, ChevronRight} from '@gravity-ui/icons';
+import classNames from 'classnames';
+import {clamp} from 'lodash';
+import React, {MouseEventHandler, ReactNode, useEffect, useRef, useState} from 'react';
+
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+import {ImageFile} from '@/types';
+import commonStyles from './common.module.css';
+import styles from './SwipeMode.module.css';
+
+interface SwitchModeProps {
+ expected: ImageFile;
+ actual: ImageFile;
+}
+
+export function SwipeMode(props: SwitchModeProps): ReactNode {
+ const {expected, actual} = props;
+ const maxNaturalWidth = Math.max(expected.size.width, actual.size.width);
+ const maxNaturalHeight = Math.max(expected.size.height, actual.size.height);
+
+ const swipeContainer = useRef(null);
+ const [dividerPosition, setDividerPosition] = useState(0);
+ const [isDragging, setIsDragging] = useState(false);
+
+ useEffect(() => {
+ if (swipeContainer.current) {
+ setDividerPosition(swipeContainer.current.getBoundingClientRect().width / 2);
+ }
+ }, [swipeContainer]);
+
+ const updateDividerPosition = (e: Pick): void => {
+ const swipeContainerLeft = swipeContainer.current?.getBoundingClientRect().left ?? 0;
+ const newMousePosX = e.pageX - swipeContainerLeft - window.scrollX;
+
+ setDividerPosition(clamp(newMousePosX, 0, maxNaturalWidth));
+ };
+
+ const handleMouseMove = (e: MouseEvent): void => {
+ updateDividerPosition(e);
+ };
+
+ const handleMouseUp = (): void => {
+ window.removeEventListener('mouseup', handleMouseUp);
+ window.removeEventListener('mousemove', handleMouseMove);
+
+ setIsDragging(false);
+ };
+
+ const handleMouseDown: MouseEventHandler = (e) => {
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+
+ setIsDragging(true);
+ updateDividerPosition(e);
+ };
+
+ const wrapperStyle = {'--max-natural-width': maxNaturalWidth, '--max-natural-height': maxNaturalHeight} as React.CSSProperties;
+
+ return ;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SwitchMode.module.css b/lib/static/new-ui/components/DiffViewer/SwitchMode.module.css
new file mode 100644
index 000000000..ae0f1310a
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SwitchMode.module.css
@@ -0,0 +1,15 @@
+.switch-mode {
+ cursor: pointer;
+}
+
+.screenshot-container {
+ position: absolute;
+}
+
+.image {
+ max-width: none;
+}
+
+.image--hidden {
+ visibility: hidden;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/SwitchMode.tsx b/lib/static/new-ui/components/DiffViewer/SwitchMode.tsx
new file mode 100644
index 000000000..3808d5560
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/SwitchMode.tsx
@@ -0,0 +1,31 @@
+import classNames from 'classnames';
+import React, {ReactNode, useState} from 'react';
+
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+import {ImageFile} from '@/types';
+import commonStyles from './common.module.css';
+import styles from './SwitchMode.module.css';
+
+interface SwitchModeProps {
+ expected: ImageFile;
+ actual: ImageFile;
+}
+
+export function SwitchMode(props: SwitchModeProps): ReactNode {
+ const {expected, actual} = props;
+ const maxNaturalWidth = Math.max(expected.size.width, actual.size.width);
+ const maxNaturalHeight = Math.max(expected.size.height, actual.size.height);
+
+ const [isExpectedHidden, setIsExpectedHidden] = useState(true);
+
+ const onClickHandler = (): void=> {
+ setIsExpectedHidden(!isExpectedHidden);
+ };
+
+ const wrapperStyle = {'--max-natural-width': maxNaturalWidth, '--max-natural-height': maxNaturalHeight} as React.CSSProperties;
+
+ return
+
+
+
;
+}
diff --git a/lib/static/new-ui/components/DiffViewer/common.module.css b/lib/static/new-ui/components/DiffViewer/common.module.css
new file mode 100644
index 000000000..8710f6496
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/common.module.css
@@ -0,0 +1,14 @@
+.images-container {
+ aspect-ratio: var(--max-natural-width) / var(--max-natural-height);
+ box-shadow: 0 0 0 1px #ccc;
+ display: flex;
+ max-width: calc(var(--max-natural-width) * 1px);
+ overflow: hidden;
+ position: relative;
+ user-select: none;
+ width: 100%;
+}
+
+.screenshot-container {
+ height: calc(var(--natural-height) / var(--max-natural-height) * 100%);
+}
diff --git a/lib/static/new-ui/components/DiffViewer/types.ts b/lib/static/new-ui/components/DiffViewer/types.ts
new file mode 100644
index 000000000..37c0a8669
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/types.ts
@@ -0,0 +1,9 @@
+import {ReactNode} from 'react';
+import {CoordBounds} from 'looks-same';
+
+import {ImageFile} from '@/types';
+
+export interface ScreenshotDisplayData extends ImageFile {
+ label?: ReactNode;
+ diffClusters?: CoordBounds[];
+}
diff --git a/lib/static/new-ui/components/DiffViewer/utils.ts b/lib/static/new-ui/components/DiffViewer/utils.ts
new file mode 100644
index 000000000..9c8af1375
--- /dev/null
+++ b/lib/static/new-ui/components/DiffViewer/utils.ts
@@ -0,0 +1,7 @@
+import React from 'react';
+import {ImageSize} from '@/types';
+
+export const getImageSizeCssVars = (size: ImageSize): React.CSSProperties => ({
+ '--natural-width': size.width,
+ '--natural-height': size.height
+} as React.CSSProperties);
diff --git a/lib/static/new-ui/components/Screenshot/DiffCircle.module.css b/lib/static/new-ui/components/Screenshot/DiffCircle.module.css
new file mode 100644
index 000000000..66e1b9f02
--- /dev/null
+++ b/lib/static/new-ui/components/Screenshot/DiffCircle.module.css
@@ -0,0 +1,13 @@
+.diff-circle {
+ position: fixed;
+ border-radius: 50%;
+ z-index: 9999999;
+ opacity: 0.3;
+ background-color: #FF00FF;
+}
+
+@keyframes :global(diff-bubbles) {
+ 100% {
+ transform: scale(var(--diff-bubbles-scale))
+ }
+}
diff --git a/lib/static/new-ui/components/Screenshot/DiffCircle.tsx b/lib/static/new-ui/components/Screenshot/DiffCircle.tsx
new file mode 100644
index 000000000..d9ba2751e
--- /dev/null
+++ b/lib/static/new-ui/components/Screenshot/DiffCircle.tsx
@@ -0,0 +1,64 @@
+import {CoordBounds} from 'looks-same';
+import React, {RefObject, useImperativeHandle, useState} from 'react';
+
+import {CIRCLE_RADIUS} from '@/constants';
+import {ImageSize} from '@/types';
+import styles from './DiffCircle.module.css';
+
+interface DiffCircleProps {
+ diffImageOriginalSize: ImageSize;
+ diffImageRef: RefObject;
+ diffCluster: CoordBounds;
+}
+
+export interface DiffCircleHandle {
+ pulse: () => void;
+}
+
+export const DiffCircle = React.forwardRef(function DiffCircle(props, ref) {
+ const [animation, setAnimation] = useState(false);
+
+ useImperativeHandle(ref, () => ({
+ pulse: (): void => {
+ setAnimation(false);
+ window.requestAnimationFrame(() => setAnimation(true));
+ }
+ }));
+
+ const {diffImageOriginalSize, diffCluster, diffImageRef} = props;
+
+ if (!animation || !diffImageRef.current) {
+ return null;
+ }
+
+ const originalImageWidth = diffImageOriginalSize.width;
+ const targetRect = diffImageRef.current.getBoundingClientRect();
+ const sizeCoeff = diffImageRef.current.offsetWidth / originalImageWidth;
+
+ const rectHeight = Math.ceil(sizeCoeff * (diffCluster.bottom - diffCluster.top + 1));
+ const rectWidth = Math.ceil(sizeCoeff * (diffCluster.right - diffCluster.left + 1));
+
+ const rectMiddleX = (diffCluster.left + diffCluster.right) / 2;
+ const rectMiddleY = (diffCluster.top + diffCluster.bottom) / 2;
+
+ const x = targetRect.left + sizeCoeff * rectMiddleX;
+ const y = targetRect.top + sizeCoeff * rectMiddleY;
+ const minSize = Math.floor(Math.sqrt(rectWidth * rectWidth + rectHeight * rectHeight));
+
+ const diffCircle = {
+ width: `${minSize}px`,
+ height: `${minSize}px`,
+ top: `${Math.ceil(y - minSize / 2)}px`,
+ left: `${Math.ceil(x - minSize / 2)}px`
+ };
+ const radius = minSize + CIRCLE_RADIUS;
+
+ return (
+ setAnimation(false)}
+ >
+
+ );
+});
diff --git a/lib/static/new-ui/components/Screenshot/index.module.css b/lib/static/new-ui/components/Screenshot/index.module.css
new file mode 100644
index 000000000..42d4cb6ee
--- /dev/null
+++ b/lib/static/new-ui/components/Screenshot/index.module.css
@@ -0,0 +1,17 @@
+.container {
+ position: relative;
+ display: inline-flex;
+}
+
+.container--clickable {
+ cursor: pointer;
+}
+
+.image {
+ aspect-ratio: var(--natural-width) / var(--natural-height);
+ max-width: calc(var(--natural-width) * 1px);
+ width: 100%;
+ object-fit: contain;
+ object-position: top left;
+ filter: drop-shadow(1px 0 0 #ccc) drop-shadow(-1px 0 0 #ccc) drop-shadow(0 1px 0 #ccc) drop-shadow(0 -1px 0 #ccc);
+}
diff --git a/lib/static/new-ui/components/Screenshot/index.tsx b/lib/static/new-ui/components/Screenshot/index.tsx
new file mode 100644
index 000000000..2abe40879
--- /dev/null
+++ b/lib/static/new-ui/components/Screenshot/index.tsx
@@ -0,0 +1,80 @@
+import classNames from 'classnames';
+import {CoordBounds} from 'looks-same';
+import React, {ReactNode, useCallback, useRef} from 'react';
+import {createPortal} from 'react-dom';
+
+import {addTimestamp, encodePathSegments} from '@/static/new-ui/components/Screenshot/utils';
+import {DiffCircle, DiffCircleHandle} from '@/static/new-ui/components/Screenshot/DiffCircle';
+import {ImageSize} from '@/types';
+import styles from './index.module.css';
+
+interface ScreenshotProps {
+ containerClassName?: string;
+ containerStyle?: React.CSSProperties;
+ imageClassName?: string;
+ diffClusters?: CoordBounds[];
+ /** When cache is disabled, current timestamp is added to image src to prevent it from caching. */
+ disableCache?: boolean;
+ image: {
+ /** URL or path to the image. Local paths will be automatically encoded. */
+ path: string;
+ size?: ImageSize;
+ };
+ style?: React.CSSProperties;
+}
+
+export function Screenshot(props: ScreenshotProps): ReactNode {
+ const imageRef = useRef(null);
+ const circlesRef = useRef([]);
+
+ const handleDiffClick = useCallback(() => {
+ if (!circlesRef.current) {
+ return;
+ }
+
+ for (const circle of circlesRef.current) {
+ circle.pulse();
+ }
+ }, [circlesRef]);
+
+ const {image} = props;
+
+ const encodedImageSrc = encodePathSegments(image.path);
+ const imageSrc = props.disableCache ? addTimestamp(encodedImageSrc) : encodedImageSrc;
+
+ const containerClassName = classNames(styles.container, props.containerClassName, {
+ [styles['container--clickable']]: props.diffClusters?.length
+ });
+ const containerStyle: React.CSSProperties = Object.assign({}, props.containerStyle);
+ if (image.size) {
+ type CSSProperty = 'width';
+ containerStyle['--natural-width' as CSSProperty] = image.size.width;
+ containerStyle['--natural-height' as CSSProperty] = image.size.height;
+ }
+
+ const imageClassName = classNames(styles.image, props.imageClassName);
+ const imageStyle: React.CSSProperties = Object.assign({}, props.style);
+ if (image.size) {
+ imageStyle.aspectRatio = `${image.size.width} / ${image.size.height} auto`;
+ }
+
+ let diffCircles: ReactNode[] = [];
+ if (props.diffClusters?.length) {
+ diffCircles = props.diffClusters.map((c, id) => image.size && createPortal( {
+ if (handle) {
+ circlesRef.current[id] = handle;
+ }
+ }}
+ key={id}
+ />, document.body));
+ }
+
+ return
+
+ {diffCircles}
+
;
+}
diff --git a/lib/static/new-ui/components/Screenshot/utils.ts b/lib/static/new-ui/components/Screenshot/utils.ts
new file mode 100644
index 000000000..67c2c67d5
--- /dev/null
+++ b/lib/static/new-ui/components/Screenshot/utils.ts
@@ -0,0 +1,19 @@
+import {isUrl} from '@/common-utils';
+
+// To prevent image caching.
+export function addTimestamp(imagePath: string): string {
+ return `${imagePath}?t=${Date.now()}`;
+}
+
+// Since local filenames may contain special characters like %, they need to be encoded.
+export function encodePathSegments(imagePath: string): string {
+ if (isUrl(imagePath)) {
+ return imagePath;
+ }
+
+ return imagePath
+ // we can't use path.sep here because on Windows browser returns '/' instead of '\\'
+ .split(/[/\\]/)
+ .map((item) => encodeURIComponent(item))
+ .join('/');
+}
diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts
index 7087e9e1b..801d12fa5 100644
--- a/lib/static/new-ui/types/store.ts
+++ b/lib/static/new-ui/types/store.ts
@@ -1,4 +1,4 @@
-import {TestStatus, ViewMode} from '@/constants';
+import {DiffModeId, TestStatus, ViewMode} from '@/constants';
import {BrowserItem, ImageFile, ReporterConfig, TestError, TestStepCompressed} from '@/types';
import {HtmlReporterValues} from '@/plugin-api';
@@ -125,6 +125,7 @@ export interface State {
browsers: BrowserItem[];
tree: TreeEntity;
view: {
+ diffMode: DiffModeId;
testNameFilter: string;
viewMode: ViewMode;
filteredBrowsers: BrowserItem[];
diff --git a/lib/static/styles.css b/lib/static/styles.css
index 25e441b4d..20136ae36 100644
--- a/lib/static/styles.css
+++ b/lib/static/styles.css
@@ -207,13 +207,18 @@ main.container {
margin-bottom: 10px;
}
-.image-box__container,
-.image-box__screenshot-container {
+.image-box__container {
display: flex;
flex-direction: column;
margin-top: 10px;
}
+.screenshot-accepter .image-box__container {
+ container-type: size;
+ flex: 1 0 0;
+ overflow: scroll;
+}
+
.image-box__container .image-box__image:not(:last-child) {
margin-bottom: 5px;
}
@@ -223,20 +228,6 @@ main.container {
display: none;
}
-.image-box__image {
- display: inline-block;
- vertical-align: top;
- flex-basis: calc(100% / 3);
- flex-grow: 1;
- box-sizing: border-box;
- max-width: 100%;
-}
-
-.image-box__image.image-box__image_single {
- display: block;
- padding-right: 0px;
-}
-
.image-box__title {
margin-bottom: 5px;
}
@@ -247,24 +238,6 @@ main.container {
border: 1px solid #ccc;
}
-.image-box__screenshot-container_auto-size {
- height: auto;
-}
-
-.image-box__screenshot-container_fixed-size {
- display: table-cell;
- background-size: contain;
- background-repeat: no-repeat;
-}
-
-.image-box__screenshot {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
-}
-
.grouped-tests {
margin-top: 10px;
}
@@ -831,20 +804,6 @@ a:active {
}
}
-.diff-circle {
- position: fixed;
- border-radius: 50%;
- z-index: 1;
- opacity: 0.3;
- background-color: #FF00FF;
-}
-
-@keyframes diff-bubbles {
- 100% {
- transform: scale(var(--diff-bubbles-scale))
- }
-}
-
.ui.loader {
z-index: 50;
}
diff --git a/test/func/tests/utils.js b/test/func/tests/utils.js
index 47eeeb197..6d5daba52 100644
--- a/test/func/tests/utils.js
+++ b/test/func/tests/utils.js
@@ -26,7 +26,7 @@ const hideHeader = async (browser) => {
const hideScreenshots = async (browser) => {
await browser.execute(() => {
- document.querySelectorAll('.image-box__image').forEach(el => {
+ document.querySelectorAll('.image-box__container').forEach(el => {
el.style.display = 'none';
});
});
diff --git a/test/setup/globals.js b/test/setup/globals.js
index 6927ed86f..08e638f97 100644
--- a/test/setup/globals.js
+++ b/test/setup/globals.js
@@ -3,7 +3,8 @@ const chai = require('chai');
const Promise = require('bluebird');
require('jsdom-global')(``, {
- url: 'http://localhost'
+ url: 'http://localhost',
+ pretendToBeVisual: true
});
Promise.config({longStackTraces: true});
diff --git a/test/unit/lib/static/components/modals/screenshot-accepter/body.jsx b/test/unit/lib/static/components/modals/screenshot-accepter/body.jsx
index 5e005d321..498c2d4af 100644
--- a/test/unit/lib/static/components/modals/screenshot-accepter/body.jsx
+++ b/test/unit/lib/static/components/modals/screenshot-accepter/body.jsx
@@ -6,7 +6,7 @@ import {mkConnectedComponent} from '../../../utils';
describe('', () => {
const sandbox = sinon.sandbox.create();
- let ScreenshotAccepterBody, ResizedScreenshot, StateFail;
+ let ScreenshotAccepterBody, Screenshot, StateFail;
const mkBrowser = (opts) => {
const browser = defaults(opts, {
@@ -55,11 +55,11 @@ describe('', () => {
};
beforeEach(() => {
- ResizedScreenshot = sandbox.stub().returns(null);
+ Screenshot = sandbox.stub().returns(null);
StateFail = sandbox.stub().returns(null);
ScreenshotAccepterBody = proxyquire('lib/static/components/modals/screenshot-accepter/body', {
- '../../state/screenshot/resized': {default: ResizedScreenshot},
+ '@/static/new-ui/components/Screenshot': {Screenshot},
'../../state/state-fail': {default: StateFail}
}).default;
});
@@ -104,7 +104,7 @@ describe('', () => {
mkBodyComponent({image}, {tree});
- assert.calledOnceWith(ResizedScreenshot, {image: image.actualImg});
+ assert.calledOnceWith(Screenshot, {image: image.actualImg});
});
it('should contain description only for actual image', () => {
diff --git a/test/unit/lib/static/components/state/screenshot/diff-circle.jsx b/test/unit/lib/static/components/state/screenshot/diff-circle.jsx
deleted file mode 100644
index f31d8a413..000000000
--- a/test/unit/lib/static/components/state/screenshot/diff-circle.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import {fireEvent} from '@testing-library/react';
-import React from 'react';
-import DiffCircle from 'lib/static/components/state/screenshot/diff-circle';
-import {mkConnectedComponent} from '../../../utils';
-
-describe('DiffCircle component', () => {
- const sandbox = sinon.createSandbox();
-
- afterEach(() => sandbox.restore());
-
- it('should set "debounce" option', () => {
- const stateComponent = mkConnectedComponent(
- ({left: 10, top: 10})}}
- diffBounds = {{left: 5, top: 5, right: 5, bottom: 5}}
- display = {true}
- toggleDiff = {() => {}}
- />
- );
-
- const {width, height, top, left} = stateComponent.container.querySelector('.diff-circle').style;
-
- assert.deepEqual({width, height, top, left}, {width: '1px', height: '1px', top: '15px', left: '15px'});
- });
-
- it('should toggle diff when animation is ended', () => {
- const toggleDiff = sandbox.stub();
- const stateComponent = mkConnectedComponent(
- ({left: 10, top: 10})}}
- diffBounds = {{left: 5, top: 5, right: 5, bottom: 5}}
- display = {true}
- toggleDiff = {toggleDiff}
- />
- );
-
- fireEvent.animationEnd(stateComponent.container.querySelector('.diff-circle'));
-
- assert.calledOnce(toggleDiff);
- });
-});
diff --git a/test/unit/lib/static/components/state/screenshot/full.jsx b/test/unit/lib/static/components/state/screenshot/full.jsx
deleted file mode 100644
index 3d76799bb..000000000
--- a/test/unit/lib/static/components/state/screenshot/full.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import {render} from '@testing-library/react';
-import React from 'react';
-import FullScreenshot from 'lib/static/components/state/screenshot/full';
-
-describe('"FullScreenshot" component', () => {
- it('should encode symbols in path', () => {
- const screenshotComponent = render();
-
- const image = screenshotComponent.getByRole('img');
-
- assert.include(image.src, 'images/%24/path');
- });
-
- it('should replace backslashes with slashes for screenshots', () => {
- const screenshotComponent = render();
-
- const image = screenshotComponent.getByRole('img');
-
- assert.include(image.src, 'images/path');
- });
-});
diff --git a/test/unit/lib/static/components/state/screenshot/resized.jsx b/test/unit/lib/static/components/state/screenshot/resized.jsx
deleted file mode 100644
index cded95c11..000000000
--- a/test/unit/lib/static/components/state/screenshot/resized.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import {render} from '@testing-library/react';
-import React from 'react';
-import ResizedScreenshot from 'lib/static/components/state/screenshot/resized';
-
-describe('"ResizedScreenshot" component', () => {
- it('should encode symbols in path', () => {
- const screenshotComponent = render();
-
- const image = screenshotComponent.getByRole('img');
-
- assert.include(image.src, 'images/%24/path');
- });
-
- it('should replace backslashes with slashes for screenshots', () => {
- const screenshotComponent = render();
-
- const image = screenshotComponent.getByRole('img');
-
- assert.include(image.src, 'images/path');
- });
-});
diff --git a/test/unit/lib/static/new-ui/components/Screenshot/DiffCircle.tsx b/test/unit/lib/static/new-ui/components/Screenshot/DiffCircle.tsx
new file mode 100644
index 000000000..c50aa4b22
--- /dev/null
+++ b/test/unit/lib/static/new-ui/components/Screenshot/DiffCircle.tsx
@@ -0,0 +1,65 @@
+import {render, waitFor} from '@testing-library/react';
+import {expect} from 'chai';
+import React, {createRef} from 'react';
+
+import styles from '@/static/new-ui/components/Screenshot/DiffCircle.module.css';
+import {DiffCircle, DiffCircleHandle} from '@/static/new-ui/components/Screenshot/DiffCircle';
+
+const makeImageElement = (): HTMLElement => {
+ const diffImageElement = {offsetWidth: 1000} as HTMLElement;
+ diffImageElement.getBoundingClientRect = (): DOMRect => ({
+ top: 0,
+ left: 0,
+ width: 1000,
+ height: 500,
+ x: 0,
+ y: 0
+ } as DOMRect);
+
+ return diffImageElement;
+};
+
+describe('', () => {
+ const diffImageOriginalSize = {width: 1000, height: 500};
+ const diffCluster = {left: 100, top: 50, right: 200, bottom: 150};
+ let diffImageRef: React.MutableRefObject;
+
+ beforeEach(() => {
+ diffImageRef = createRef();
+ });
+
+ it('should render when pulse is called', async () => {
+ const ref: React.RefObject = createRef();
+ diffImageRef.current = makeImageElement();
+ const {container} = render();
+
+ ref.current?.pulse();
+
+ await waitFor(() => {
+ const diffCircleElement = container.querySelector(`.${styles.diffCircle}`);
+
+ expect(diffCircleElement).not.to.be.null;
+ });
+ });
+
+ it('should render at correct coords', async () => {
+ const ref: React.RefObject = createRef();
+ const {container} = render();
+ diffImageRef.current = makeImageElement();
+
+ ref.current?.pulse();
+
+ await waitFor(() => {
+ const diffCircleElement = container.querySelector(`.${styles.diffCircle}`) as HTMLDivElement;
+
+ expect(diffCircleElement).to.not.be.null;
+ // Circle size should be equal to sqrt(diffClusterHeight^2 + diffClusterWidth^2) = sqrt(100^2 + 100^2) = 142
+ expect(diffCircleElement?.style.width).to.equal('142px');
+ expect(diffCircleElement?.style.height).to.equal('142px');
+ // Circle left should be equal to clusterMiddleY - circleSize / 2 = (50 + 150) / 2 - 142 / 2 = 29
+ expect(diffCircleElement?.style.top).to.equal('29px');
+ // Circle left should be equal to clusterMiddleX - circleSize / 2 = (100 + 200) / 2 - 142 / 2 = 79
+ expect(diffCircleElement?.style.left).to.equal('79px');
+ });
+ });
+});
diff --git a/test/unit/lib/static/new-ui/components/Screenshot/index.tsx b/test/unit/lib/static/new-ui/components/Screenshot/index.tsx
new file mode 100644
index 000000000..2b46bd68d
--- /dev/null
+++ b/test/unit/lib/static/new-ui/components/Screenshot/index.tsx
@@ -0,0 +1,21 @@
+import {render} from '@testing-library/react';
+import React from 'react';
+import {Screenshot} from '@/static/new-ui/components/Screenshot';
+
+describe('"FullScreenshot" component', () => {
+ it('should encode symbols in path', () => {
+ const screenshotComponent = render();
+
+ const image = screenshotComponent.getByRole('img') as HTMLImageElement;
+
+ assert.include(image.src, 'images/%24/path');
+ });
+
+ it('should replace backslashes with slashes for screenshots', () => {
+ const screenshotComponent = render();
+
+ const image = screenshotComponent.getByRole('img') as HTMLImageElement;
+
+ assert.include(image.src, 'images/path');
+ });
+});