diff --git a/packages/lib-classifier/src/components/Classifier/Classifier.spec.js b/packages/lib-classifier/src/components/Classifier/Classifier.spec.js index 4b5504c810..fad211cb17 100644 --- a/packages/lib-classifier/src/components/Classifier/Classifier.spec.js +++ b/packages/lib-classifier/src/components/Classifier/Classifier.spec.js @@ -63,7 +63,7 @@ describe('Components > Classifier', function () { } describe('while the subject is loading', function () { - let subjectImage, tabPanel, taskAnswers, taskTab, tutorialTab, workflow + let subjectImagePlaceholder, tabPanel, taskAnswers, taskTab, tutorialTab, workflow before(function () { sinon.replace(window, 'Image', MockSlowImage) @@ -82,7 +82,7 @@ describe('Components > Classifier', function () { ) taskTab = screen.getByRole('tab', { name: 'TaskArea.task'}) tutorialTab = screen.getByRole('tab', { name: 'TaskArea.tutorial'}) - subjectImage = screen.getByRole('img', { name: `Subject ${subject.id}` }) + subjectImagePlaceholder = screen.getByTestId('placeholder-svg') tabPanel = screen.getByRole('tabpanel', { name: '1 Tab Contents'}) const task = workflowSnapshot.tasks.T0 const getAnswerInput = answer => within(tabPanel).getByRole('radio', { name: answer.label }) @@ -101,8 +101,8 @@ describe('Components > Classifier', function () { expect(tutorialTab).to.be.ok() }) - it('should have a subject image', function () { - expect(subjectImage).to.be.ok() + it('should have a placeholder', function () { + expect(subjectImagePlaceholder).to.be.ok() }) describe('task answers', function () { diff --git a/packages/lib-classifier/src/components/Classifier/components/ImageToolbar/ImageToolbar.js b/packages/lib-classifier/src/components/Classifier/components/ImageToolbar/ImageToolbar.js index 7ad9869b12..a86edf3d24 100644 --- a/packages/lib-classifier/src/components/Classifier/components/ImageToolbar/ImageToolbar.js +++ b/packages/lib-classifier/src/components/Classifier/components/ImageToolbar/ImageToolbar.js @@ -23,7 +23,7 @@ function storeMapper(classifierStore) { // Generalized ...props here are css rules from the page layout function ImageToolbar (props) { const { hasAnnotateTask, rotation } = useStores(storeMapper) - const { onKeyZoom } = useKeyZoom(rotation) + const { onKeyZoom } = useKeyZoom({ rotation }) return ( Layouts > Centered', function () { - it('should render a subject and a task', function () { + it('should render a subject and a task', async function () { const DefaultStory = composeStory(Default, Meta) render() - expect(screen.getByLabelText('Subject 1')).exists() // img aria-label from SVGImage - expect(screen.getByText(mockTasks.init.strings.question)).exists() // task question paragraph + + // Mock the loading state transition + Default.store.subjectViewer.onSubjectReady() + + await waitFor(() => expect(screen.getByLabelText('Subject 1')).exists()) // img aria-label from SVGImage + await waitFor(() => expect(screen.getByText(mockTasks.init.strings.question)).exists()) // task question paragraph }) }) diff --git a/packages/lib-classifier/src/components/Classifier/components/Layout/components/MaxWidth/MaxWidth.spec.js b/packages/lib-classifier/src/components/Classifier/components/Layout/components/MaxWidth/MaxWidth.spec.js index 57b42ebc85..b0d1385161 100644 --- a/packages/lib-classifier/src/components/Classifier/components/Layout/components/MaxWidth/MaxWidth.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/Layout/components/MaxWidth/MaxWidth.spec.js @@ -1,13 +1,18 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { composeStory } from '@storybook/react' + import Meta, { Default, mockTasks } from './MaxWidth.stories.js' describe('Component > Layouts > MaxWidth', function () { - it('should render a subject and a task', function () { + it('should render a subject and a task', async function () { const DefaultStory = composeStory(Default, Meta) render() - expect(screen.getByLabelText('Subject 1')).exists() // img aria-label from SVGImage - expect(screen.getByText(mockTasks.init.strings.question)).exists() // task question paragraph + + // Mock the loading state transition + Default.store.subjectViewer.onSubjectReady() + + await waitFor(() => expect(screen.getByLabelText('Subject 1')).exists()) // img aria-label from SVGImage + await waitFor(() => expect(screen.getByText(mockTasks.init.strings.question)).exists()) // task question paragraph }) }) diff --git a/packages/lib-classifier/src/components/Classifier/components/Layout/components/NoMaxWidth/NoMaxWidth.spec.js b/packages/lib-classifier/src/components/Classifier/components/Layout/components/NoMaxWidth/NoMaxWidth.spec.js index c97a793424..d560e6bce1 100644 --- a/packages/lib-classifier/src/components/Classifier/components/Layout/components/NoMaxWidth/NoMaxWidth.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/Layout/components/NoMaxWidth/NoMaxWidth.spec.js @@ -1,12 +1,16 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { composeStory } from '@storybook/react' import Meta, { Default, mockTasks } from './NoMaxWidth.stories.js' describe('Component > Layouts > NoMaxWidth', function () { - it('should render a subject and a task', function () { + it('should render a subject and a task', async function () { const DefaultStory = composeStory(Default, Meta) render() - expect(screen.getByLabelText('Subject 1')).exists() // img aria-label from SVGImage - expect(screen.getByText(mockTasks.init.strings.question)).exists() // task question paragraph + + // Mock the loading state transition + Default.store.subjectViewer.onSubjectReady() + + await waitFor(() => expect(screen.getByLabelText('Subject 1')).exists()) // img aria-label from SVGImage + await waitFor(() => expect(screen.getByText(mockTasks.init.strings.question)).exists()) // task question paragraph }) }) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/README.md b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/README.md index 87749b3782..0a418eecae 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/README.md +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/README.md @@ -36,12 +36,11 @@ A subject viewer is a component designed to render the media of a subject and an ### Pan and Zoom -Currently, we have four implementations of pan and zoom functionality. An open [discussion](https://github.com/zooniverse/front-end-monorepo/discussions/2427) is on Github about how we support this overall and suggests consolidating where we can to use the VisX implementation so we can better support configuration and zoom to point since the VisX implementation already does. The documentation here will just be summarizing the types and where they are used. +Currently, we have three implementations of pan and zoom functionality. An open [discussion](https://github.com/zooniverse/front-end-monorepo/discussions/2427) is on Github about how we support this overall. The documentation here will just be summarizing the types and where they are used. -- [`VisXZoom`](components/SVGComponents/VisXZoom) - An extension of the VisX library's pan and zoom functional component. Uses a transformation matrix, boolean control for zoom and pan individual to turn the functions on and off as needed, has configurability for zoom direction, minimum zoom, maximum zoom, zoom in value, and zoom out value, and supports zoom to point. Currently implemented with the `ScatterPlotViewer`, the scatter plots used by the `VariableStarViewer`, and the scatter plot used by `DataImageViewer`. It uses the [`ZoomEventLayer`](components/SVGComponents/ZoomEventLayer) which is a transparent SVG rectangle that has event listeners for double click, on wheel, on key down, on mouse down, on mouse up, on mouse enter, and on mouse leave. Subject viewers using this should set as props the width, height, left and top points of the SVG area to render, as well as, the child subject viewer component to render, a zoom configuration object, and optionally a custom [constrain](https://airbnb.io/visx/docs/zoom#Zoom_constrain) function enabling the ability to constrain pan and zoom directionality and/or pan dimensions. An example of this implementation is used with the [`ZoomingScatterPlot`](components/ScatterPlotViewer/ZoomingScatterPlot). The zoom configuration can be set in the workflow configuration [`subject_viewer_config`](https://github.com/zooniverse/front-end-monorepo/blob/master/docs/arch/adr-27.md) object or in the JSON of a JSON subject. (NOTE: TODO is to finish building out support for configuration being set on the workflow configuration object). -- [`SVGPanZoom`](components/SVGComponents/SVGPanZoom) - A port of the PFE pan and zoom functionality being used with the `SingleImageViewer` and subsequently the `MultiFrameViewer` and anywhere else the `SingleImageViewer` may be used. It does scale transformations on the SVG view box. +- [`VisXZoom`](components/SVGComponents/VisXZoom) - An extension of the VisX library's pan and zoom functional component. Uses a transformation matrix, boolean control for zoom and pan individual to turn the functions on and off as needed, has configurability for zoom direction, minimum zoom, maximum zoom, zoom in value, and zoom out value, and supports zoom to point. Currently implemented with the `SingleImageViewer`, the single image viewer is used by `DataImageViewer`, `FlipbookViewer`, `ImageAndTextViewer`, `MultiFrameViewer`, and `SeparateFramesViewer`, as well as the `ScatterPlotViewer`, the scatter plots used by the `VariableStarViewer`, and the scatter plot used by `DataImageViewer`. It uses the [`ZoomEventLayer`](components/SVGComponents/ZoomEventLayer) which is a transparent SVG rectangle that has event listeners for double click, on wheel, on key down, on mouse down, on mouse up, on mouse enter, and on mouse leave. Subject viewers using this should set as props the width, height, left and top points of the SVG area to render, as well as, the child subject viewer component to render, a zoom configuration object, and optionally a custom [constrain](https://airbnb.io/visx/docs/zoom#Zoom_constrain) function enabling the ability to constrain pan and zoom directionality and/or pan dimensions. An example of this implementation is used with the [`ZoomingScatterPlot`](components/ScatterPlotViewer/ZoomingScatterPlot). The zoom configuration can be set in the workflow configuration [`subject_viewer_config`](https://github.com/zooniverse/front-end-monorepo/blob/master/docs/arch/adr-27.md) object or in the JSON of a JSON subject. - [`D3`](components/LightCurveViewer) - Since the TESS light curve viewer's render and functionality is transferred to d3, the pan and zoom functionality is directly implemented using d3 and is coupled to the project requirements. This cannot be reused with other subject viewers. -- [`SubjectGroupViewer`](components/SubjectGroupViewer) - This uses a modified version of `SVGPanZoom`, but applies the scale transformation of the view box simultaneously to all subjects in the grid. +- [`SubjectGroupViewer`](components/SubjectGroupViewer) - It does scale transformations on the SVG view box simultaneously to all subjects in the grid. ### Drawing Interaction diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/DataImageViewer/DataImageViewer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/DataImageViewer/DataImageViewer.js index 8f26a57cd0..4ad1df6558 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/DataImageViewer/DataImageViewer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/DataImageViewer/DataImageViewer.js @@ -125,14 +125,10 @@ const DataImageViewer = forwardRef(function DataImageViewer({ disableImageZoom() : () => setAllowPanZoom('image')} diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewer.js index 05d644a9ed..b3b2c215bb 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewer.js @@ -1,13 +1,12 @@ -import { useEffect, useState } from 'react'; import { Box } from 'grommet' import PropTypes from 'prop-types' +import { useEffect, useState } from 'react' + +import { useKeyZoom, useSubjectImage } from '@hooks' import locationValidator from '../../helpers/locationValidator' -import { useSubjectImage } from '@hooks' -import SingleImageViewer from '../SingleImageViewer/SingleImageViewer.js' -import SVGImage from '../SVGComponents/SVGImage' -import SVGPanZoom from '../SVGComponents/SVGPanZoom' +import SingleImageViewer from '../SingleImageViewer/SingleImageViewer' import FlipbookControls from './components' const DEFAULT_HANDLER = () => true @@ -19,38 +18,17 @@ const FlipbookViewer = ({ flipbookAutoplay = false, invert = false, limitSubjectHeight = false, - move, + move = false, onError = DEFAULT_HANDLER, - onKeyDown = DEFAULT_HANDLER, onReady = DEFAULT_HANDLER, playIterations, - rotation, + rotation = 0, setOnPan = DEFAULT_HANDLER, setOnZoom = DEFAULT_HANDLER, subject }) => { const [currentFrame, setCurrentFrame] = useState(defaultFrame) const [playing, setPlaying] = useState(false) - const [dragMove, setDragMove] = useState() - /** This initializes an image element from the subject's defaultFrame src url. - * We do this so the SVGPanZoom has dimensions of the subject image. - * We're assuming all frames in one subject have the same dimensions. */ - const defaultFrameLocation = subject ? subject.locations[defaultFrame] : null - const { img, error, loading, subjectImage } = useSubjectImage({ - src: defaultFrameLocation.url, - onReady, - onError - }) - const { - naturalHeight = 600, - naturalWidth = 800 - } = img - - const viewerLocation = subject?.locations ? subject.locations[currentFrame] : '' - - useEffect(() => { - enableRotation() - }, [img.src]) useEffect(() => { const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') @@ -59,65 +37,44 @@ const FlipbookViewer = ({ } }, []) + const defaultLocationUrl = subject?.locations[defaultFrame]?.url + const { img, error, loading, subjectImage } = useSubjectImage({ + src: defaultLocationUrl, + onError, + onReady + }) + const { + naturalHeight = 600, + naturalWidth = 800 + } = img + const onPlayPause = () => { setPlaying(!playing) } - const handleSpaceBar = (event) => { - if (event.key === ' ') { - event.preventDefault() - onPlayPause() - } else { - onKeyDown(event) - } - } - - const setOnDrag = (callback) => { - setDragMove(() => callback) - } + const { onKeyZoom } = useKeyZoom({ customKeyMappings: { ' ': onPlayPause } }) - const onDrag = (event, difference) => { - dragMove?.(event, difference) - } + const imageLocationUrl = subject?.locations[currentFrame]?.url return ( - - - - - - - + src={imageLocationUrl} + subject={subject} + /> FlipbookViewer', function () { it('should play or pause via keyboard when image is focused', async function () { const user = userEvent.setup({ delay: null }) - const { container, getByLabelText } = render() - const imageSVG = container.querySelector('svg') + const { container, getByLabelText, getByTestId } = render() + const imageSVGZoomLayer = getByTestId('zoom-layer') - imageSVG.focus() + imageSVGZoomLayer.focus() await user.keyboard(' ') const pauseButton = getByLabelText('SubjectViewer.VideoController.pause') diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewerContainer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewerContainer.js index ec3dcee155..5fe6855d89 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewerContainer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/FlipbookViewer/FlipbookViewerContainer.js @@ -3,7 +3,7 @@ import asyncStates from '@zooniverse/async-states' import PropTypes from 'prop-types' import { observer } from 'mobx-react' -import { useKeyZoom, useStores } from '@hooks' +import { useStores } from '@hooks' import locationValidator from '../../helpers/locationValidator' import FlipbookViewer from './FlipbookViewer' import SeparateFramesViewer from '../SeparateFramesViewer/SeparateFramesViewer' @@ -64,20 +64,15 @@ function FlipbookViewerContainer({ setOnZoom } = useStores(storeMapper) - const { onKeyZoom } = useKeyZoom(rotation) - - useEffect( - function preloadImages() { - subject?.locations?.forEach(({ url }) => { - if (url) { - const { Image } = window - const img = new Image() - img.src = url - } - }) - }, - [subject?.locations] - ) + useEffect(function preloadImages() { + subject?.locations?.forEach(({ url }) => { + if (url) { + const { Image } = window + const img = new Image() + img.src = url + } + }) + }, [subject?.locations]) if (loadingState === asyncStates.error || !subject?.locations) { return
Something went wrong.
@@ -88,6 +83,7 @@ function FlipbookViewerContainer({ {separateFramesView ? ( true function ImageAndTextViewerContainer ({ - dimensions = defaultDimensions, - enableRotation = DEFAULT_HANDLER, + dimensions = DEFAULT_DIMENSIONS, frame = 0, loadingState = asyncStates.initialized, onError = DEFAULT_HANDLER, @@ -24,10 +22,6 @@ function ImageAndTextViewerContainer ({ setFrame = DEFAULT_HANDLER, subject }) { - useEffect(function onMount() { - enableRotation() - }, []) - function handleFrameChange (newFrame) { setFrame(newFrame) } @@ -78,6 +72,8 @@ ImageAndTextViewerContainer.propTypes = { ), frame: PropTypes.number, loadingState: PropTypes.string, + onError: PropTypes.func, + onReady: PropTypes.func, setFrame: PropTypes.func, subject: PropTypes.shape({ locations: PropTypes.arrayOf(locationValidator) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/InteractionLayer/InteractionLayerContainer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/InteractionLayer/InteractionLayerContainer.js index ec9168b967..e81c52a870 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/InteractionLayer/InteractionLayerContainer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/InteractionLayer/InteractionLayerContainer.js @@ -68,7 +68,6 @@ export function InteractionLayerContainer({ shownMarks = SHOWN_MARKS.ALL, subject, taskKey = '', - viewBox, width, played, duration @@ -99,7 +98,6 @@ export function InteractionLayerContainer({ setActiveMark={setActiveMark} scale={scale} subject={subject} - viewBox={viewBox} width={width} /> )} diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.js index f476b59f7b..5eb52c9920 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.js @@ -1,27 +1,19 @@ import asyncStates from '@zooniverse/async-states' import { Box } from 'grommet' import PropTypes from 'prop-types' -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { withStores } from '@helpers' -import { useKeyZoom, useSubjectImage } from '@hooks' import locationValidator from '../../helpers/locationValidator' -import SingleImageViewer from '../SingleImageViewer/SingleImageViewer' -import SVGImage from '../SVGComponents/SVGImage' -import SVGPanZoom from '../SVGComponents/SVGPanZoom' +import SingleImageViewer from '../SingleImageViewer' import FrameCarousel from './FrameCarousel' function storeMapper(store) { const { - enableRotation, frame, - invert, - move, - rotation, - setFrame, - setOnPan, - setOnZoom + resetView, + setFrame } = store.subjectViewer const { activeStepTasks } = store.workflowSteps @@ -33,75 +25,36 @@ function storeMapper(store) { activeTool } = activeInteractionTask || {} - const { - limit_subject_height: limitSubjectHeight - } = store.workflows?.active?.configuration - return { activeTool, - enableRotation, frame, - invert, - limitSubjectHeight, - move, - rotation, - setFrame, - setOnPan, - setOnZoom + resetView, + setFrame } } -const defaultTool = { +const DEFAULT_HANDLER = () => true + +const DEFAULT_TOOL = { validate: () => {} } function MultiFrameViewerContainer({ - activeTool = defaultTool, + activeTool = DEFAULT_TOOL, enableInteractionLayer = false, - enableRotation = () => null, frame = 0, - invert = false, - limitSubjectHeight = false, loadingState = asyncStates.initialized, - move, - onError = () => true, - onReady = () => true, - rotation, - setFrame = () => true, - setOnPan = () => true, - setOnZoom = () => true, + onError = DEFAULT_HANDLER, + onReady = DEFAULT_HANDLER, + resetView = DEFAULT_HANDLER, + setFrame = DEFAULT_HANDLER, subject }) { - const { onKeyZoom } = useKeyZoom(rotation) - const [dragMove, setDragMove] = useState() - // TODO: replace this with a better function to parse the image location from a subject. - const imageLocation = subject ? subject.locations[frame] : null - const { img, error, loading, subjectImage } = useSubjectImage({ - src: imageLocation?.url, - onReady, - onError - }) - const { - naturalHeight = 600, - naturalWidth = 800 - } = img - - useEffect(function onMount() { - enableRotation() - }, []) - useEffect(function onFrameChange() { activeTool?.validate() + resetView() }, [frame]) - function setOnDrag(callback) { - setDragMove(() => callback) - } - - function onDrag(event, difference) { - dragMove?.(event, difference) - } - if (loadingState === asyncStates.error) { return (
Something went wrong.
@@ -109,7 +62,6 @@ function MultiFrameViewerContainer({ } if (loadingState !== asyncStates.initialized) { - const subjectID = subject?.id || 'unknown' return ( - - - - - + ) } @@ -165,16 +89,12 @@ MultiFrameViewerContainer.propTypes = { validate: PropTypes.func }), enableInteractionLayer: PropTypes.bool, - enableRotation: PropTypes.func, frame: PropTypes.number, - invert: PropTypes.bool, - limitSubjectHeight: PropTypes.bool, loadingState: PropTypes.string, onError: PropTypes.func, onReady: PropTypes.func, + resetView: PropTypes.func, setFrame: PropTypes.func, - setOnPan: PropTypes.func, - setOnZoom: PropTypes.func, subject: PropTypes.shape({ locations: PropTypes.arrayOf(locationValidator) }).isRequired diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.spec.js index 225b0edfae..b763e1bd67 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/MultiFrameViewer/MultiFrameViewerContainer.spec.js @@ -141,15 +141,6 @@ describe('Component > MultiFrameViewerContainer', function () { expect(image.prop('href')).to.equal('https://some.domain/image.jpg') }) - describe('with dragging enabled', function () { - it('should render a draggable image', function () { - wrapper.setProps({ move: true }) - const image = wrapper.find(DraggableImage) - expect(image).to.have.lengthOf(1) - expect(image.prop('href')).to.equal('https://some.domain/image.jpg') - }) - }) - describe('with invalid marks', function () { // mock an active transcription task tool: const activeTool = TranscriptionLineTool.create({ diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.js deleted file mode 100644 index 62573b8902..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.js +++ /dev/null @@ -1,156 +0,0 @@ -import PropTypes from 'prop-types' -import { cloneElement, useEffect, useRef, useState } from 'react' -import styled from 'styled-components' - -const FullWidthDiv = styled.div` - width: 100%; -` - -function imageScale(imgRef, naturalWidth) { - const { width: clientWidth, height: clientHeight } = imgRef?.current - ? imgRef.current.getBoundingClientRect() - : {} - const scale = clientWidth / naturalWidth - return !Number.isNaN(scale) ? scale : 1 -} - -const DEFAULT_HANDLER = () => true -function SVGPanZoom({ - imgRef = null, - children, - limitSubjectHeight = false, - maxZoom = 2, - minZoom = 1, - naturalHeight, - naturalWidth, - setOnDrag = DEFAULT_HANDLER, - setOnPan = DEFAULT_HANDLER, - setOnZoom = DEFAULT_HANDLER, - src, - zooming = true -}) { - const defaultViewBox = { - x: 0, - y: 0, - height: naturalHeight, - width: naturalWidth - } - const zoom = useRef(1) - const [viewBox, setViewBox] = useState(defaultViewBox) - - function enableZoom() { - setOnDrag(onDrag) - setOnPan(onPan) - setOnZoom(onZoom) - } - - function disableZoom() { - setOnDrag(DEFAULT_HANDLER) - setOnPan(DEFAULT_HANDLER) - setOnZoom(DEFAULT_HANDLER) - } - - useEffect(() => { - if (zooming) { - enableZoom() - return disableZoom - } - }, [zooming, src]) - - useEffect(() => { - setViewBox({ - x: 0, - y: 0, - height: naturalHeight, - width: naturalWidth - }) - zoom.current = 1 - }, [naturalWidth, naturalHeight]) - - function scaleViewBox(scale) { - const viewBoxScale = 1 / scale - const width = parseInt(naturalWidth * viewBoxScale, 10) - const height = parseInt(naturalHeight * viewBoxScale, 10) - setViewBox((prevViewBox) => { - const xCentre = prevViewBox.x + prevViewBox.width / 2 - const yCentre = prevViewBox.y + prevViewBox.height / 2 - const x = xCentre - width / 2 - const y = yCentre - height / 2 - return { x, y, width, height } - }) - } - - function onDrag(event, difference) { - setViewBox((prevViewBox) => { - const newViewBox = { ...prevViewBox } - newViewBox.x -= difference.x * .9 - newViewBox.y -= difference.y * .9 - return newViewBox - }) - } - - function onPan(dx, dy) { - setViewBox((prevViewBox) => { - const newViewBox = { ...prevViewBox } - newViewBox.x += dx * 10 - newViewBox.y += dy * 10 - return newViewBox - }) - } - - function onZoom(type) { - switch (type) { - case 'zoomin': { - const prevZoom = zoom.current - zoom.current = Math.min(prevZoom + 0.1, maxZoom) - scaleViewBox(zoom.current) - return - } - case 'zoomout': { - const prevZoom = zoom.current - zoom.current = Math.max(prevZoom - 0.1, minZoom) - scaleViewBox(zoom.current) - return - } - case 'zoomto': { - setViewBox({ - x: 0, - y: 0, - height: naturalHeight, - width: naturalWidth - }) - zoom.current = 1 - return - } - } - } - - const { x, y, width, height } = viewBox - const scale = imageScale(imgRef, naturalWidth) - - return ( - - {cloneElement(children, { - scale, - viewBox: `${x} ${y} ${width} ${height}`, - svgMaxHeight: limitSubjectHeight ? `min(${naturalHeight}px, 90vh)` : null - })} - - ) -} - -SVGPanZoom.propTypes = { - children: PropTypes.node.isRequired, - limitSubjectHeight: PropTypes.bool, - maxZoom: PropTypes.number, - minZoom: PropTypes.number, - naturalHeight: PropTypes.number.isRequired, - naturalWidth: PropTypes.number.isRequired, - setOnDrag: PropTypes.func, - setOnPan: PropTypes.func, - setOnZoom: PropTypes.func, - src: PropTypes.string.isRequired, - zooming: PropTypes.bool -} - -export default SVGPanZoom diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.spec.js deleted file mode 100644 index 289f5a2bff..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/SVGPanZoom.spec.js +++ /dev/null @@ -1,253 +0,0 @@ -import { render, waitFor } from '@testing-library/react' -import sinon from 'sinon' -import SVGPanZoom from './SVGPanZoom' - -describe('Components > SVGPanZoom', function () { - let wrapper - let onDrag - let onPan - let onZoom - const img = { - getBoundingClientRect() { - return { - height: 100, - width: 200 - } - } - } - const src = 'https://example.com/image.png' - - beforeEach(function () { - wrapper = render( - { onDrag = callback }} - setOnPan={callback => { onPan = callback }} - setOnZoom={callback => { onZoom = callback }} - src={src} - > - - - ) - }) - - it('should enable zoom in', async function () { - onZoom('zoomin', 1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('18.5 9.5 363 181') - }) - }) - - it('should enable zoom out', async function () { - onZoom('zoomin', 1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('18.5 9.5 363 181') - }) - onZoom('zoomout', -1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 400 200') - }) - }) - - describe('panning', function () { - describe('left', function () { - it('should move the viewbox left', async function () { - let viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 400 200') - onPan(-1, 0) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('-10 0 400 200') - }) - }) - }) - - describe('right', function () { - it('should move the viewbox right', async function () { - let viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 400 200') - onPan(1, 0) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('10 0 400 200') - }) - }) - }) - describe('up', function () { - it('should move the viewbox up', async function () { - let viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 400 200') - onPan(0, -1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 -10 400 200') - }) - }) - }) - describe('down', function () { - it('should move the viewbox down', async function () { - let viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 400 200') - onPan(0, 1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 10 400 200') - }) - }) - }) - }) - - it('should should pan horizontally on drag', async function () { - onDrag({}, { x: -15, y: 0 }) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('13.5 0 400 200') - }) - }) - - it('should should pan vertically on drag', async function () { - onDrag({}, { x: 0, y: -15 }) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 13.5 400 200') - }) - }) - - it('should reset pan with new src', async function () { - onPan(-1, 0) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('-10 0 400 200') - }) - - wrapper.rerender( - { onDrag = callback }} - setOnPan={callback => { onPan = callback }} - setOnZoom={callback => { onZoom = callback }} - src='https://static.zooniverse.org/fem-assets/subject-placeholder.jpg' - > - - - ) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('0 0 200 400') - }) - }) - - it('should reset zoom with new src', async function () { - onZoom('zoomin', 1) - await waitFor(() => { - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - expect(viewBox).to.equal('18.5 9.5 363 181') - }) - - wrapper.rerender( - { onDrag = callback }} - setOnPan={callback => { onPan = callback }} - setOnZoom={callback => { onZoom = callback }} - src='https://static.zooniverse.org/fem-assets/subject-placeholder.jpg' - > - - - ) - const viewBox = document.querySelector('svg[viewBox]')?.getAttribute('viewBox') - await waitFor(() => expect(viewBox).to.equal('0 0 200 400')) - }) - - describe('when zooming function is controlled by prop', function () { - let setOnDragSpy, setOnPanSpy, setOnZoomSpy, wrapper - beforeEach(function () { - setOnDragSpy = sinon.spy() - setOnPanSpy = sinon.spy() - setOnZoomSpy = sinon.spy() - wrapper = render( - - - - ) - }) - - it('should not register onZoom, onPan, onDrag on mount', function () { - expect(setOnDragSpy).to.not.have.been.called() - expect(setOnPanSpy).to.not.have.been.called() - expect(setOnZoomSpy).to.not.have.been.called() - }) - - it('should register the handlers when zooming is set to true', function () { - wrapper.rerender( - - - - ) - expect(setOnDragSpy).to.have.been.called() - expect(setOnPanSpy).to.have.been.called() - expect(setOnZoomSpy).to.have.been.called() - }) - - it('should unregister the handler when zooming is set to false', function () { - wrapper.rerender( - - - - ) - wrapper.rerender( - - - - ) - expect(setOnDragSpy).to.have.been.calledTwice() - expect(setOnPanSpy).to.have.been.calledTwice() - expect(setOnZoomSpy).to.have.been.calledTwice() - }) - }) -}) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/index.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/index.js deleted file mode 100644 index 6722fad709..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/SVGPanZoom/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SVGPanZoom' diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/VisXZoom/VisXZoom.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/VisXZoom/VisXZoom.js index 59ac15a379..6688d551ec 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/VisXZoom/VisXZoom.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SVGComponents/VisXZoom/VisXZoom.js @@ -32,6 +32,7 @@ function VisXZoom({ ...props }) { const { onKeyZoom } = useKeyZoom() + useEffect(function setCallbacks() { setOnPan(handleToolbarPan) setOnZoom(handleToolbarZoom) @@ -102,16 +103,18 @@ function VisXZoom({ function onPointerEnter() { if (zooming) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth document.body.style.overflow = 'hidden' + document.body.style.paddingRight = `${scrollbarWidth}px` } } function onPointerLeave() { if (zooming) { document.body.style.overflow = '' + document.body.style.paddingRight = '' } - if (!zoom.isDragging && !panning) return - zoom.dragEnd() + if (!zoom.isDragging && !panning) return zoom.dragEnd() } function onWheel(event) { @@ -122,7 +125,6 @@ function VisXZoom({ } } - const ZoomingComponent = zoomingComponent return ( props.panning ? + ${props => props.$panning ? css`cursor: move;` : css`cursor: inherit;`} overscroll-behavior: none; @@ -10,9 +10,9 @@ const StyledRect = styled.rect` &:focus { ${props => css` - outline-color: ${props.focusColor}; - border: solid thick ${props.focusColor}; - box-shadow: 0 0 4px 4px ${props.focusColor}; + outline-color: ${props.$focusColor}; + border: solid thick ${props.$focusColor}; + box-shadow: 0 0 4px 4px ${props.$focusColor}; ` } } @@ -42,7 +42,7 @@ function ZoomEventLayer ({ {subject.locations?.map((location, index) => ( ))} @@ -108,6 +111,8 @@ export default observer(SeparateFramesViewer) SeparateFramesViewer.propTypes = { /** Passed from Subject Viewer Store */ enableInteractionLayer: PropTypes.bool, + /** Passed from Subject Viewer Store */ + enableRotation: PropTypes.func, /** @zooniverse/async-states */ loadingState: PropTypes.string, /** Passed from SubjectViewer and called if `useSubjectImage()` hook fails. */ diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SeparateFramesViewer/components/SeparateFrame/SeparateFrame.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SeparateFramesViewer/components/SeparateFrame/SeparateFrame.js index b51874a96e..1a7dc0e03b 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SeparateFramesViewer/components/SeparateFrame/SeparateFrame.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SeparateFramesViewer/components/SeparateFrame/SeparateFrame.js @@ -1,11 +1,8 @@ -import { useEffect, useState } from 'react' import { Box } from 'grommet' import PropTypes from 'prop-types' -import { useStores } from '@hooks' - -import useSubjectImage from '@hooks/useSubjectImage.js' -import SingleImageViewer from '../../../SingleImageViewer/SingleImageViewer.js' -import SVGImage from '../../../SVGComponents/SVGImage' +import { useState } from 'react' +import { useKeyZoom, useStores, useSubjectImage } from '@hooks' +import SingleImageViewer from '../../../SingleImageViewer/SingleImageViewer' import { AnnotateButton, InvertButton, @@ -19,7 +16,9 @@ import { const DEFAULT_HANDLER = () => true function storeMapper(classifierStore) { - return { hasAnnotateTask: classifierStore.subjectViewer.hasAnnotateTask } + return { + hasAnnotateTask: classifierStore.subjectViewer.hasAnnotateTask + } } const SeparateFrame = ({ @@ -29,77 +28,35 @@ const SeparateFrame = ({ frameUrl = '', limitSubjectHeight = false, onError = DEFAULT_HANDLER, - onReady = DEFAULT_HANDLER + onReady = DEFAULT_HANDLER, + subject }) => { - const { img, error, loading, subjectImage } = useSubjectImage({ - src: frameUrl, - onReady, - onError - }) - - const { naturalHeight = 600, naturalWidth = 800, src: frameSrc } = img - - const maxZoom = 5 - const minZoom = 0.1 - - const defaultViewBox = { - x: 0, - y: 0, - height: naturalHeight, - width: naturalWidth - } - - /** State Variables */ - const [invert, setInvert] = useState(false) const [rotation, setRotation] = useState(0) const [separateFrameAnnotate, setSeparateFrameAnnotate] = useState(true) const [separateFrameMove, setSeparateFrameMove] = useState(false) - const [viewBox, setViewBox] = useState(defaultViewBox) - const [zoom, setZoom] = useState(1) - /** Effects */ - - useEffect(() => { - if (frameSrc) { - enableRotation() - setViewBox(defaultViewBox) - setZoom(1) - } - }, [frameSrc]) - - useEffect(() => { - const newViewBox = scaledViewBox(zoom) - setViewBox(newViewBox) - }, [zoom]) + const { onKeyZoom } = useKeyZoom() + + const { img, error, loading, subjectImage } = useSubjectImage({ + src: frameUrl, + onError, + onReady + }) + const { + naturalHeight = 600, + naturalWidth = 800 + } = img - /** Move/Zoom functions */ + let onPan + let onZoom - const imageScale = img => { - const { width: clientWidth } = img ? img.getBoundingClientRect() : {} - const scale = clientWidth / naturalWidth - return !Number.isNaN(scale) ? scale : 1 + function setOnPan(fn) { + onPan = fn } - const scale = imageScale(subjectImage.current) // For images with an InteractionLayer - const scaledViewBox = scale => { - const viewBoxScale = 1 / scale - const xCentre = viewBox.x + viewBox.width / 2 - const yCentre = viewBox.y + viewBox.height / 2 - const width = parseInt(naturalWidth * viewBoxScale, 10) - const height = parseInt(naturalHeight * viewBoxScale, 10) - const x = xCentre - width / 2 - const y = yCentre - height / 2 - return { x, y, width, height } - } - - const onDrag = (event, difference) => { - setViewBox(prevViewBox => { - const newViewBox = { ...prevViewBox } - newViewBox.x -= difference.x - newViewBox.y -= difference.y - return newViewBox - }) + function setOnZoom(fn) { + onZoom = fn } /** Image Toolbar functions */ @@ -113,124 +70,52 @@ const SeparateFrame = ({ setSeparateFrameAnnotate(false) } - /** NOTE: This is to disable the annotate button if there are no annotate tasks */ const { hasAnnotateTask } = useStores(storeMapper) if (!hasAnnotateTask && separateFrameAnnotate) { - separateFrameEnableMove(); + separateFrameEnableMove() } - const separateFrameZoomIn = () => { - setZoom(prevZoom => Math.min(prevZoom + 0.1, maxZoom)) + const separateFrameRotate = () => { + setRotation(prevRotation => prevRotation - 90) } - const separateFrameZoomOut = () => { - setZoom(prevZoom => Math.max(prevZoom - 0.1, minZoom)) + const separateFrameInvert = () => { + setInvert(prev => !prev) } - const separateFrameRotate = () => { - const newRotation = rotation - 90 - setRotation(newRotation) + const separateFrameZoomIn = () => { + onZoom('zoomin', 1) } - const separateFrameInvert = () => { - setInvert(!invert) + const separateFrameZoomOut = () => { + onZoom('zoomout', -1) } const separateFrameResetView = () => { setRotation(0) setInvert(false) - setZoom(1) - setViewBox({ - x: 0, - y: 0, - width: naturalWidth, - height: naturalHeight - }) - } - - /** Panning with Keyboard */ - - const onPan = (dx, dy) => { - setViewBox(prevViewBox => { - const newViewBox = { ...prevViewBox } - newViewBox.x += dx * 10 - newViewBox.y += dy * 10 - return newViewBox - }) - } - - const onKeyDown = e => { - const ALLOWED_TAGS = ['svg', 'button', 'g', 'rect'] - const htmlTag = e.target?.tagName.toLowerCase() - - if (ALLOWED_TAGS.includes(htmlTag)) { - switch (e.key) { - case '+': - case '=': { - separateFrameZoomIn() - return true - } - case '-': - case '_': { - separateFrameZoomOut() - return true - } - case 'ArrowRight': { - e.preventDefault() - onPan(1, 0) - return false - } - case 'ArrowLeft': { - e.preventDefault() - onPan(-1, 0) - return false - } - case 'ArrowUp': { - e.preventDefault() - onPan(0, -1) - return false - } - case 'ArrowDown': { - e.preventDefault() - onPan(0, 1) - return false - } - default: { - return true - } - } - } + onZoom('zoomto', 1.0) } - /** Frame Component */ - - const { x, y, width, height } = scaledViewBox(zoom) - return ( - - + move={separateFrameMove} + naturalHeight={naturalHeight} + naturalWidth={naturalWidth} + onKeyDown={onKeyZoom} + rotation={rotation} + setOnPan={setOnPan} + setOnZoom={setOnZoom} + src={img.src} + subject={subject} + /> - { hasAnnotateTask && + {hasAnnotateTask && true + +function SingleImageCanvas({ + children, + enableInteractionLayer = false, + frame = 0, + imgRef, + invert = false, + move = false, + naturalHeight, + naturalWidth, + onKeyDown = DEFAULT_HANDLER, + rotation = 0, + src, + subject, + transform, // per VisXZoom + transformMatrix // per VisXZoom +}) { + const canvasLayer = useRef() + const zoomLayer = useRef() // used for scale calculation per getBoundingClientRect inconsistency between Firefox and Chrome/Safari + const [scale, setScale] = useState(1) + + const handleResize = useCallback(() => { + const zoom = zoomLayer.current + if (!zoom) return + + const width = zoom.getBoundingClientRect().width + + if (width > 0 && naturalWidth > 0) { + setScale(width / naturalWidth) + } + }, [naturalWidth]) + + useEffect(() => { + const zoom = zoomLayer.current + if (!zoom) return + + const mutationObserver = new MutationObserver(handleResize) + mutationObserver.observe(zoom, { + attributes: true, + attributeFilter: ['transform'] + }) + + handleResize() + + return () => { + mutationObserver.disconnect() + } + }, [handleResize]) + + const rotationTransform = rotation ? `rotate(${rotation} ${naturalWidth / 2} ${naturalHeight / 2})` : '' + + return ( + + + + + + {children} + {enableInteractionLayer && ( + + )} + + + + + ) +} + +SingleImageCanvas.propTypes = { + enableInteractionLayer: bool, + frame: number, + imgRef: shape({ + current: shape({ + naturalHeight: number, + naturalWidth: number, + src: string + }) + }), + invert: bool, + move: bool, + naturalHeight: number, + naturalWidth: number, + onKeyDown: func, + rotation: number, + src: string, + subject: shape({ + locations: arrayOf(shape({ + url: string + })) + }), + transform: string, + transformMatrix: shape({ + scaleX: number, + translateX: number, + translateY: number + }) +} + +export default SingleImageCanvas diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageCanvas.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageCanvas.spec.js new file mode 100644 index 0000000000..c9b14c9a74 --- /dev/null +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageCanvas.spec.js @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' + +const SUBJECT_IMAGE_URL = 'https://panoptes-uploads.zooniverse.org/production/subject_location/11f98201-1c3f-44d5-965b-e00373daeb18.jpeg' + +import SingleImageCanvas from './SingleImageCanvas' + +describe('Component > SingleImageCanvas', function () { + it('should render without crashing', function () { + render( + + ) + expect(screen.getByRole('img')).to.exist() + }) + + it('should apply the rotation transform', function () { + render( + + ) + const rotationGroup = screen.getByTestId('single-image-canvas-rotation-transform-group') + expect(rotationGroup.getAttribute('transform')).to.equal('rotate(45 50 50)') + }) + + it('should apply the zoom transform', function () { + render( + + ) + const zoomGroup = screen.getByTestId('single-image-canvas-visxzoom-transform-group') + expect(zoomGroup.getAttribute('transform')).to.equal('matrix(2 0 0 2 0 0)') + }) +}) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.js index 5cf940f6af..54c844b615 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.js @@ -1,45 +1,62 @@ import { Box } from 'grommet' -import PropTypes from 'prop-types' -import { useRef } from 'react' -import styled, { css } from 'styled-components' +import { arrayOf, bool, func, number, shape, string } from 'prop-types' +import { useEffect } from 'react' -import SVGContext from '@plugins/drawingTools/shared/SVGContext' -import InteractionLayer from '../InteractionLayer' import ZoomControlButton from '../ZoomControlButton' -import locationValidator from '../../helpers/locationValidator' -const PlaceholderSVG = styled.svg` - background: no-repeat center / cover url('https://static.zooniverse.org/www.zooniverse.org/assets/fe-project-subject-placeholder-800x600.png'); - touch-action: pinch-zoom; - max-width: ${props => props.$maxWidth || '100%'}; - ${props => props.$maxHeight && css`max-height: ${props.$maxHeight};`} -` -const SVGImageCanvas = styled.svg` - overflow: visible; -` +import VisXZoom from '../SVGComponents/VisXZoom' + +import SingleImageCanvas from './SingleImageCanvas' + +const DEFAULT_HANDLER = () => true +const DEFAULT_ZOOM_CONFIG = { + direction: 'both', + maxZoom: 10, + minZoom: 0.1, + zoomInValue: 1.2, + zoomOutValue: 0.8 +} + function SingleImageViewer({ - children, - enableInteractionLayer = false, + enableInteractionLayer = true, + enableRotation = DEFAULT_HANDLER, frame = 0, - height, - limitSubjectHeight = false, - onKeyDown = () => true, - rotate = 0, - scale = 1, - svgMaxHeight = null, + imgRef, + invert = false, + move = false, + naturalHeight, + naturalWidth, + onKeyDown = DEFAULT_HANDLER, + panning = true, + rotation = 0, + setOnPan = DEFAULT_HANDLER, + setOnZoom = DEFAULT_HANDLER, + src, subject, - title = {}, - viewBox, - width, + title = undefined, zoomControlFn = null, - zooming = false + zooming = true }) { - const canvasLayer = useRef() - const canvas = canvasLayer.current - const transform = `rotate(${rotate} ${width / 2} ${height / 2})` + useEffect(function onMount() { + enableRotation() + }, []) + + const singleImageCanvasProps = { + enableInteractionLayer, + frame, + imgRef, + invert, + move, + naturalHeight, + naturalWidth, + onKeyDown, + rotation, + src, + subject + } return ( - + <> {zoomControlFn && ( )} - - {title?.id && title?.text && ( - {title.text} - )} - - - {children} - {enableInteractionLayer && ( - - )} - - - + {title?.id && title?.text && ( + {title.text} + )} + + + - + ) } SingleImageViewer.propTypes = { - /** Index of the Frame. Initially inherits from parent Viewer or overwritten in Viewer with SubjectViewerStore */ - frame: PropTypes.number, - /** Passed from container */ - enableInteractionLayer: PropTypes.bool, - /** Calculated by useSubjectImage() */ - height: PropTypes.number.isRequired, - /** Stored in subject viewer store */ - rotate: PropTypes.number, - /** Calculated in SVGPanZoom component */ - scale: PropTypes.number, - /** Calculated in SVGPanZoom component */ - svgMaxHeight: PropTypes.string, - /** Passed from container */ - subject: PropTypes.shape({ - locations: PropTypes.arrayOf(locationValidator) + enableInteractionLayer: bool, + enableRotation: func, + frame: number, + imgRef: shape({ + current: shape({ + naturalHeight: number, + naturalWidth: number, + src: string + }) + }), + invert: bool, + limitSubjectHeight: bool, + move: bool, + naturalHeight: number, + naturalWidth: number, + onKeyDown: func, + panning: bool, + rotation: number, + setOnPan: func, + setOnZoom: func, + src: string, + subject: shape({ + locations: arrayOf(shape({ + url: string + })) }), - title: PropTypes.shape({ - id: PropTypes.string, - text: PropTypes.string + title: shape({ + id: string, + text: string }), - /** Calculated in SVGPanZoom component */ - viewBox: PropTypes.string, - /** Calculated by useSubjectImage() */ - width: PropTypes.number.isRequired, - /** Stored in subject viewer store */ - zoomControlFn: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - /** Passed from container */ - zooming: PropTypes.bool + zoomControlFn: func, + zooming: bool } export default SingleImageViewer diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.spec.js index d047a00fc2..2ea9477e0c 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.spec.js @@ -1,46 +1,46 @@ -import { shallow } from 'enzyme' +import { composeStory } from '@storybook/react' +import { render, screen } from '@testing-library/react' -import SingleImageViewer from './SingleImageViewer' -import InteractionLayer from '../InteractionLayer' +const SUBJECT_IMAGE_URL = 'https://panoptes-uploads.zooniverse.org/production/subject_location/11f98201-1c3f-44d5-965b-e00373daeb18.jpeg' -let wrapper +import Meta, { Default, Error, Loading, PanAndZoom } from './SingleImageViewer.stories' describe('Component > SingleImageViewer', function () { - beforeEach(function () { - wrapper = shallow() - }) + describe('with a successful subject location request', function () { + const DefaultStory = composeStory(Default, Meta) - it('should render without crashing', function () { - expect(wrapper).to.be.ok() - }) + it('should render the image', function () { + render() + const image = screen.getByRole('img') + expect(image).to.exist() + }) - it('should be upright', function () { - const transform = wrapper.find('g[transform]').prop('transform') - expect(transform).to.have.string('rotate(0 50 100)') + describe('with title', function () { + it('should render the title', function () { + render() + const title = screen.getByText('Subject 1234') + expect(title).to.exist() + }) + }) }) - describe('with a rotation angle', function () { - beforeEach(function () { - wrapper.setProps({ rotate: -90 }) - }) + describe('with an error from the subject location request', function () { + const ErrorStory = composeStory(Error, Meta) - it('should be rotated', function () { - const transform = wrapper.find('g[transform]').prop('transform') - expect(transform).to.have.string('rotate(-90 50 100)') + it('should render an error message', function () { + render() + const error = screen.getByText('Something went wrong.') + expect(error).to.exist() }) }) - describe('with interaction layer', function () { - it('should default to not render the InteractionLayer', function () { - expect(wrapper.find(InteractionLayer)).to.have.lengthOf(0) - }) + describe('while loading the subject location', function () { + const LoadingStory = composeStory(Loading, Meta) - it('should be possible to disable the render of the InteractionLayer by prop', function () { - wrapper.setProps({ enableInteractionLayer: false }) - expect(wrapper.find(InteractionLayer)).to.have.lengthOf(0) - wrapper.setProps({ enableInteractionLayer: true }) - expect(wrapper.find(InteractionLayer)).to.have.lengthOf(1) - wrapper.setProps({ enableInteractionLayer: false }) + it('should render a placeholder', function () { + render() + const placeholder = screen.getByTestId('placeholder-svg') + expect(placeholder).to.exist() }) }) }) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.stories.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.stories.js index 49984a2056..009b7c3ff7 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.stories.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewer.stories.js @@ -1,51 +1,78 @@ +import asyncStates from '@zooniverse/async-states' import { Box } from 'grommet' import { Provider } from 'mobx-react' -import SubjectViewerStore from '@store/SubjectViewerStore' -import SingleImageViewer from '@viewers/components/SingleImageViewer' -import asyncStates from '@zooniverse/async-states' + +import ImageToolbar from '../../../ImageToolbar' import mockStore from '@test/mockStore' import { SubjectFactory, WorkflowFactory } from '@test/factories' +import SingleImageViewerContainer from './SingleImageViewerContainer' + const subject = SubjectFactory.build({ locations: [{ 'image/jpeg': 'https://panoptes-uploads.zooniverse.org/production/subject_location/11f98201-1c3f-44d5-965b-e00373daeb18.jpeg' }] }) -const workflow = WorkflowFactory.build('workflow', { +const workflow = WorkflowFactory.build({ configuration: { - limit_subject_height: true + invert_subject: true } }) +const store = mockStore({ subject, workflow }) + +const ViewerContext = ({ store, children }) => { + return {children} +} + export default { title: 'Subject Viewers / SingleImageViewer', - component: SingleImageViewer + component: SingleImageViewerContainer } export function Default() { return ( - + - - + + ) +} + +export function PanAndZoom() { + return ( + + + + + + ) } -Default.store = mockStore({ subject }) -export function LimitSubjectHeight() { +export function Error() { return ( - + - + + + + ) +} + +export function Loading() { + return ( + + + - + ) } -LimitSubjectHeight.store = mockStore({ subject, workflow }) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.js deleted file mode 100644 index 3ce241d952..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.js +++ /dev/null @@ -1,37 +0,0 @@ -import { withStores } from '@helpers' -import SingleImageViewerContainer from './SingleImageViewerContainer' - -function storeMapper(store) { - const { - subjects: { - active: subject - }, - subjectViewer: { - enableRotation, - frame, - invert, - move, - rotation, - setOnZoom, - setOnPan - } - } = store - - const { - limit_subject_height: limitSubjectHeight - } = store.workflows?.active?.configuration - - return { - enableRotation, - frame, - invert, - limitSubjectHeight, - move, - rotation, - setOnZoom, - setOnPan, - subject - } -} - -export default withStores(SingleImageViewerContainer, storeMapper) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.spec.js deleted file mode 100644 index 09450a547e..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerConnector.spec.js +++ /dev/null @@ -1,68 +0,0 @@ -import { shallow } from 'enzyme' -import React from 'react'; -import sinon from 'sinon' -import SingleImageViewerConnector from './SingleImageViewerConnector' -import SingleImageViewerContainer from './SingleImageViewerContainer' - -const mockStore = { - classifierStore: { - subjects: { - active: { id: '1' } - }, - subjectViewer: { - enableRotation: sinon.spy(), - move: false, - rotation: 0, - setOnPan: sinon.spy(), - setOnZoom: sinon.spy() - }, - workflows: { - active: { - configuration: {} - } - } - } -} - -describe('SingleImageViewerConnector', function () { - let wrapper, useContextMock, containerProps - before(function () { - useContextMock = sinon.stub(React, 'useContext').callsFake(() => mockStore) - wrapper = shallow( - - ) - containerProps = wrapper.find(SingleImageViewerContainer).props() - }) - - after(function () { - useContextMock.restore() - }) - - it('should render without crashing', function () { - expect(wrapper).to.be.ok() - }) - - it('should pass the active subject as a prop', function () { - expect(containerProps.subject).to.deep.equal(mockStore.classifierStore.subjects.active) - }) - - it('should pass the enableRotation function', function () { - expect(containerProps.enableRotation).to.deep.equal(mockStore.classifierStore.subjectViewer.enableRotation) - }) - - it('should pass the move boolean', function () { - expect(containerProps.move).to.deep.equal(mockStore.classifierStore.subjectViewer.move) - }) - - it('should pass the rotation boolean', function () { - expect(containerProps.rotation).to.deep.equal(mockStore.classifierStore.subjectViewer.rotation) - }) - - it('should pass the setOnPan function', function () { - expect(containerProps.setOnPan).to.deep.equal(mockStore.classifierStore.subjectViewer.setOnPan) - }) - - it('should pass the setOnZoom function', function () { - expect(containerProps.setOnZoom).to.deep.equal(mockStore.classifierStore.subjectViewer.setOnZoom) - }) -}) \ No newline at end of file diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.js index 674573859a..1fc52a6288 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.js @@ -1,137 +1,147 @@ import asyncStates from '@zooniverse/async-states' -import PropTypes from 'prop-types' -import { useEffect, useState } from 'react' +import { observer } from 'mobx-react' +import { bool, func, shape, string } from 'prop-types' -import { useKeyZoom, useSubjectImage } from '@hooks' +import { useKeyZoom, useStores, useSubjectImage } from '@hooks' -import locationValidator from '../../helpers/locationValidator' -import SVGImage from '../SVGComponents/SVGImage' -import SVGPanZoom from '../SVGComponents/SVGPanZoom' +import PlaceholderSVG from './components/PlaceholderSVG' import SingleImageViewer from './SingleImageViewer' +function storeMapper(classifierStore) { + const { + subjects: { + active: subject + }, + subjectViewer: { + enableRotation, + frame, + invert, + move, + rotation, + setOnZoom, + setOnPan + }, + workflows: { + active: { + configuration: { + limit_subject_height: limitSubjectHeight + } + } + } + } = classifierStore + + return { + enableRotation, + frame, + invert, + limitSubjectHeight, + move, + rotation, + setOnZoom, + setOnPan, + subject + } +} + const DEFAULT_HANDLER = () => true function SingleImageViewerContainer({ enableInteractionLayer = true, - enableRotation = DEFAULT_HANDLER, - frame = 0, - invert = false, - limitSubjectHeight = false, + imageLocation = null, loadingState = asyncStates.initialized, - move = false, onError = DEFAULT_HANDLER, onReady = DEFAULT_HANDLER, - rotation = 0, - setOnPan = DEFAULT_HANDLER, - setOnZoom = DEFAULT_HANDLER, - subject, - title = {}, - zoomControlFn, + title = undefined, + zoomControlFn = null, zooming = true }) { - const { onKeyZoom } = useKeyZoom(rotation) - const [dragMove, setDragMove] = useState() + const { + enableRotation, + frame, + invert, + limitSubjectHeight, + move, + rotation, + setOnZoom, + setOnPan, + subject + } = useStores(storeMapper) + + const { onKeyZoom } = useKeyZoom() + // TODO: replace this with a better function to parse the image location from a subject. - const imageLocation = subject ? subject.locations[frame] : null + + // if imageLocation is provided, use it, otherwise use the subject's location per subjectViewer store frame + + const imageLocationUrl = imageLocation?.url ? imageLocation.url : subject?.locations[frame]?.url + const { img, error, loading, subjectImage } = useSubjectImage({ - src: imageLocation?.url, - onReady, - onError + src: imageLocationUrl, + onError, + onReady }) const { naturalHeight = 600, naturalWidth = 800 } = img - useEffect(function onMount() { - enableRotation() - }, []) - - function setOnDrag(callback) { - setDragMove(() => callback) - } - - function onDrag(event, difference) { - dragMove?.(event, difference) + if (loadingState === asyncStates.loading) { + return ( + + ) } if (loadingState === asyncStates.error) { return
Something went wrong.
} - const enableDrawing = - loadingState === asyncStates.success && enableInteractionLayer - - if (loadingState !== asyncStates.initialized) { - const subjectID = subject?.id || 'unknown' - + if (loadingState === asyncStates.success) { return ( - - - - - + subject={subject} + title={title} + zoomControlFn={zoomControlFn} + zooming={zooming} + /> ) } + return null } SingleImageViewerContainer.propTypes = { - enableInteractionLayer: PropTypes.bool, - enableRotation: PropTypes.func, - frame: PropTypes.number, - invert: PropTypes.bool, - limitSubjectHeight: PropTypes.bool, - loadingState: PropTypes.string, - move: PropTypes.bool, - onError: PropTypes.func, - onReady: PropTypes.func, - rotation: PropTypes.number, - setOnPan: PropTypes.func, - setOnZoom: PropTypes.func, - subject: PropTypes.shape({ - locations: PropTypes.arrayOf(locationValidator) + enableInteractionLayer: bool, + imageLocation: shape({ + url: string }), - title: PropTypes.shape({ - id: PropTypes.string, - text: PropTypes.string + loadingState: string, + onError: func, + onReady: func, + title: shape({ + id: string, + text: string }), - zoomControlFn: PropTypes.func, - zooming: PropTypes.bool + zoomControlFn: func, + zooming: bool } -export default SingleImageViewerContainer +export default observer(SingleImageViewerContainer) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.spec.js deleted file mode 100644 index ff11876cc0..0000000000 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/SingleImageViewerContainer.spec.js +++ /dev/null @@ -1,199 +0,0 @@ -import { mount } from 'enzyme' -import { Provider } from 'mobx-react' -import { Factory } from 'rosie' -import sinon from 'sinon' -import asyncStates from '@zooniverse/async-states' - -import SubjectType from '@store/SubjectStore/SubjectType' -import mockStore from '@test/mockStore' -import { DraggableImage } from '../SVGComponents/SVGImage' -import SingleImageViewer from './SingleImageViewer' -import SingleImageViewerContainer from './SingleImageViewerContainer' - -describe('Component > SingleImageViewerContainer', function () { - let wrapper - const height = 200 - const width = 400 - const DELAY = 100 - const HTMLImgError = { - message: 'The HTML img did not load' - } - - // mock an image that loads after a delay of 0.1s - class ValidImage { - constructor () { - this.naturalHeight = height - this.naturalWidth = width - setTimeout(() => this.onload(), DELAY) - } - } - - // mock an image that errors after a delay of 0.1s - class InvalidImage { - constructor () { - this.naturalHeight = height - this.naturalWidth = width - setTimeout(() => this.onerror(HTMLImgError), DELAY) - } - } - - describe('without a subject', function () { - const onError = sinon.stub() - - before(function () { - wrapper = mount( - , - { - wrappingComponent: Provider, - wrappingComponentProps: { classifierStore: mockStore() } - } - ) - }) - - it('should render without crashing', function () { - expect(wrapper).to.be.ok() - }) - - it('should render null', function () { - expect(wrapper.html()).to.be.null() - }) - }) - - describe('with a valid subject', function () { - let imageWrapper - const onReady = sinon.stub() - const onError = sinon.stub() - - before(function (done) { - sinon.replace(window, 'Image', ValidImage) - onReady.callsFake(() => { - imageWrapper = wrapper.find(SingleImageViewer) - done() - }) - onError.callsFake(() => { - imageWrapper = wrapper.find(SingleImageViewer) - done() - }) - const subjectSnapshot = Factory.build('subject', { - id: 'test', - locations: [ - { 'image/jpeg': 'https://some.domain/image.jpg' } - ], - metadata: { - default_frame: "0" - } - }) - const subject = SubjectType.create(subjectSnapshot) - const classifierStore = mockStore({ subject: subjectSnapshot }) - wrapper = mount( - , - { - wrappingComponent: Provider, - wrappingComponentProps: { classifierStore } - } - ) - }) - - after(function () { - sinon.restore() - }) - - it('should render without crashing', function () { - expect(wrapper).to.be.ok() - }) - - it('should record the original image dimensions on load', function () { - const expectedEvent = { - target: { - clientHeight: 0, - clientWidth: 0, - naturalHeight: height, - naturalWidth: width - } - } - expect(onReady).to.have.been.calledOnceWith(expectedEvent) - expect(onError).to.not.have.been.called() - }) - - it('should pass the original image dimensions to the SVG image', function () { - const { height, width } = wrapper.find(SingleImageViewer).props() - expect(height).to.equal(height) - expect(width).to.equal(width) - }) - - it('should render an svg image', function () { - wrapper.update() - const image = wrapper.find('image') - expect(image).to.have.lengthOf(1) - expect(image.prop('href')).to.equal('https://some.domain/image.jpg') - }) - - describe('with dragging enabled', function () { - it('should render a draggable image', function () { - wrapper.setProps({ move: true }) - const image = wrapper.find(DraggableImage) - expect(image).to.have.lengthOf(1) - expect(image.prop('href')).to.equal('https://some.domain/image.jpg') - }) - }) - }) - - describe('with an invalid subject', function () { - let imageWrapper - const onReady = sinon.stub() - const onError = sinon.stub() - - before(function (done) { - sinon.replace(window, 'Image', InvalidImage) - sinon.stub(console, 'error') - onReady.callsFake(() => { - imageWrapper = wrapper.find(SingleImageViewer) - done() - }) - onError.callsFake(() => { - imageWrapper = wrapper.find(SingleImageViewer) - done() - }) - const subjectSnapshot = Factory.build('subject', { - id: 'test', - locations: [ - { 'image/jpeg': 'https://some.domain/image.jpg' } - ] - }) - const subject = SubjectType.create(subjectSnapshot) - wrapper = mount( - , - { - wrappingComponent: Provider, - wrappingComponentProps: { classifierStore: mockStore({ subject }) } - } - ) - }) - - after(function () { - sinon.restore() - }) - - it('should render without crashing', function () { - expect(wrapper).to.be.ok() - }) - - it('should log an error from an invalid image', function () { - expect(onError.withArgs(HTMLImgError)).to.have.been.calledOnce() - }) - - it('should not call onReady', function () { - expect(onReady).to.not.have.been.called() - }) - }) -}) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.js new file mode 100644 index 0000000000..f235ee82c8 --- /dev/null +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.js @@ -0,0 +1,38 @@ +import { string } from 'prop-types' +import { forwardRef } from 'react' +import styled, { css } from 'styled-components' + +const StyledPlaceholderSVG = styled.svg` + background: no-repeat center / cover url('https://static.zooniverse.org/www.zooniverse.org/assets/fe-project-subject-placeholder-800x600.png'); + touch-action: pinch-zoom; + max-width: ${props => props.$maxWidth}; + ${props => props.$maxHeight && css`max-height: ${props.$maxHeight};`} +` + +const PlaceholderSVG = forwardRef(function PlaceholderSVG({ + maxWidth = '100%', + maxHeight, + viewBox = '0 0 800 600' +}, ref) { + return ( + + ) +}) + +PlaceholderSVG.displayName = 'PlaceholderSVG' + +PlaceholderSVG.propTypes = { + maxWidth: string, + maxHeight: string, + viewBox: string +} + +export default PlaceholderSVG diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.spec.js new file mode 100644 index 0000000000..fd998c512e --- /dev/null +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.spec.js @@ -0,0 +1,37 @@ +import { composeStory } from '@storybook/react' +import { render, screen } from '@testing-library/react' +import Meta, { Default, CustomMaxWidthAndHeight, CustomViewBox } from './PlaceholderSVG.stories' + +describe('Component > SingleImageViewer > PlaceholderSVG', function () { + + it('should have default props', function () { + const DefaultStory = composeStory(Default, Meta) + render() + const svg = screen.getByTestId('placeholder-svg') + expect(svg.getAttribute('viewBox')).to.equal('0 0 800 600') + expect(svg).to.have.style('max-width', '100%') + }) + + it('should apply custom maxWidth and maxHeight', function () { + const CustomMaxWidthAndHeightStory = composeStory(CustomMaxWidthAndHeight, Meta) + render() + const svg = screen.getByTestId('placeholder-svg') + expect(svg).to.have.style('max-width', '500px') + expect(svg).to.have.style('max-height', '400px') + }) + + it('should apply custom viewBox', function () { + const CustomViewBoxStory = composeStory(CustomViewBox, Meta) + render() + const svg = screen.getByTestId('placeholder-svg') + expect(svg.getAttribute('viewBox')).to.equal('0 0 400 300') + }) + + it('should have expected accessibility attributes', function () { + const DefaultStory = composeStory(Default, Meta) + render() + const svg = screen.getByTestId('placeholder-svg') + expect(svg.getAttribute('focusable')).to.exist() + expect(svg.getAttribute('tabindex')).to.equal('0') + }) +}) diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.stories.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.stories.js new file mode 100644 index 0000000000..661c565dda --- /dev/null +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/components/PlaceholderSVG.stories.js @@ -0,0 +1,37 @@ +import { Box } from 'grommet' + +import PlaceholderSVG from './PlaceholderSVG' + +export default { + title: 'Subject Viewers / SingleImageViewer / PlaceholderSVG', + component: PlaceholderSVG +} + +export function Default() { + return ( + + + + ) +} + +export function CustomMaxWidthAndHeight() { + return ( + + + + ) +} + +export function CustomViewBox() { + return ( + + + + ) +} diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/index.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/index.js index 0efe7720f7..396bc04fae 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/index.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/components/SingleImageViewer/index.js @@ -1,3 +1,3 @@ -export { default } from './SingleImageViewerConnector' +export { default } from './SingleImageViewerContainer' export { default as SingleImageViewerContainer } from './SingleImageViewerContainer' -export { default as SingleImageViewer } from './SingleImageViewer' \ No newline at end of file +export { default as SingleImageViewer } from './SingleImageViewer' diff --git a/packages/lib-classifier/src/hooks/useKeyZoom.js b/packages/lib-classifier/src/hooks/useKeyZoom.js index 7dcc8bbe35..b12d4e3bb3 100644 --- a/packages/lib-classifier/src/hooks/useKeyZoom.js +++ b/packages/lib-classifier/src/hooks/useKeyZoom.js @@ -24,7 +24,7 @@ function storeMapper(classifierStore) { } } -export default function useKeyZoom(rotate=0) { +export default function useKeyZoom({ rotate = 0, customKeyMappings = {} } = {}) { const { panLeft, panRight, @@ -38,7 +38,8 @@ export default function useKeyZoom(rotate=0) { '+': zoomIn, '=': zoomIn, '-': zoomOut, - '_': zoomOut + '_': zoomOut, + ...customKeyMappings } if (rotation === 0) { @@ -78,5 +79,5 @@ export default function useKeyZoom(rotate=0) { return true } - return { onKeyZoom } + return { onKeyZoom } } diff --git a/packages/lib-classifier/src/hooks/useKeyZoom.spec.js b/packages/lib-classifier/src/hooks/useKeyZoom.spec.js index 0fb635fa90..28de7e1c99 100644 --- a/packages/lib-classifier/src/hooks/useKeyZoom.spec.js +++ b/packages/lib-classifier/src/hooks/useKeyZoom.spec.js @@ -185,5 +185,70 @@ describe('Hooks > useKeyZoom', function () { expect(onPan).to.have.not.been.called() expect(onZoom).to.have.not.been.called() }) + + describe('with custom key mappings', function () { + let wrappedComponent + const customMap = sinon.stub() + + const customKeyMappings = { + ' ': customMap + } + + function WithCustomMap(props) { + const { onKeyZoom } = useKeyZoom({ customKeyMappings }) + return ( + + ) + } + + beforeEach(function () { + const subjectViewer = SubjectViewerStore.create({}) + const classifierStore = { + subjectViewer + } + subjectViewer.setOnPan(onPan) + subjectViewer.setOnZoom(onZoom) + render( + + + + ) + wrappedComponent = document.getElementById('testStub') + }) + + afterEach(function () { + onPan.resetHistory() + onZoom.resetHistory() + customMap.resetHistory() + }) + + it('should call custom map', async function () { + const user = userEvent.setup() + wrappedComponent.focus() + await user.keyboard(`{ }`) + expect(customMap).to.have.been.calledOnce() + }) + + it('should call useKeyZoom zoom mappings', async function () { + const user = userEvent.setup() + wrappedComponent.focus() + await user.keyboard(`{=}`) + expect(onZoom).to.have.been.calledOnce() + }) + + it('should call useKeyZoom pan mappings', async function () { + const user = userEvent.setup() + wrappedComponent.focus() + await user.keyboard(`{ArrowRight}`) + expect(onPan).to.have.been.calledOnce() + }) + }) }) }) diff --git a/packages/lib-classifier/src/hooks/useSubjectText.js b/packages/lib-classifier/src/hooks/useSubjectText.js index 802f0b5a3c..0c842ff12e 100644 --- a/packages/lib-classifier/src/hooks/useSubjectText.js +++ b/packages/lib-classifier/src/hooks/useSubjectText.js @@ -42,9 +42,13 @@ export default function useSubjectText({ const [error, setError] = useState(null) useEffect(function onSubjectChange() { + let isMounted = true + function onLoad(rawData) { - setData(rawData) - onReady() + if (isMounted) { + setData(rawData) + onReady() + } } async function handleSubject() { @@ -52,14 +56,20 @@ export default function useSubjectText({ const rawData = await requestData(subject) if (rawData) onLoad(rawData) } catch (error) { - setError(error) - onError(error) + if (isMounted) { + setError(error) + onError(error) + } } } if (subject) { handleSubject() } + + return () => { + isMounted = false + } }, [subject, onReady, onError]) const loading = !data && !error diff --git a/packages/lib-classifier/src/store/SubjectViewerStore/SubjectViewerStore.js b/packages/lib-classifier/src/store/SubjectViewerStore/SubjectViewerStore.js index d1660fbe89..3eb73a291a 100644 --- a/packages/lib-classifier/src/store/SubjectViewerStore/SubjectViewerStore.js +++ b/packages/lib-classifier/src/store/SubjectViewerStore/SubjectViewerStore.js @@ -1,5 +1,5 @@ import asyncStates from '@zooniverse/async-states' -import { autorun, reaction } from 'mobx' +import { autorun } from 'mobx' import { addDisposer, getRoot, isValidReference, tryReference, types } from 'mobx-state-tree' const SubjectViewer = types