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'); + }); +});