Skip to content

Commit

Permalink
B&I: Zoom image using mouse wheel
Browse files Browse the repository at this point in the history
closes MAT-427, MAT-428

flag=rce_buttons_and_icons

test plan:
- Navigate to a RCE instance
- Click Buttons & Icons button.
- In the tray, navigate to images section
- Select a course image
- Wait until the preview is loaded
- Click crop button
> Verify zoom in/out buttons work as expected,
also consider zoom should not
exceed thresholds (1.0 - 2.0)
> Verify mouse wheel zooms in/out the image,
also consider zoom should not
exceed thresholds (1.0 - 2.0)

Change-Id: Iba5de5eddd621d5f794e5030a7838800344deb11
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/284505
Tested-by: Service Cloud Jenkins <[email protected]>
Reviewed-by: Jacob DeWar <[email protected]>
QA-Review: Jacob DeWar <[email protected]>
Product-Review: David Lyons <[email protected]>
  • Loading branch information
Juan Chavez committed Feb 9, 2022
1 parent 32ca331 commit 45296ec
Show file tree
Hide file tree
Showing 18 changed files with 636 additions and 120 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,38 @@
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import React, {useEffect, useReducer} from 'react'
import PropTypes from 'prop-types'
import {Modal} from '@instructure/ui-modal'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {ImageCropper} from './ImageCropper'
import formatMessage from '../../../../../../format-message'
import {cropperSettingsReducer, actions, defaultState} from '../../../reducers/imageCropper'
import {Preview} from './Preview'
import {Controls} from './controls'

export const ImageCropperModal = ({open, onClose, image}) => {
const [settings, dispatch] = useReducer(cropperSettingsReducer, defaultState)
useEffect(() => {
dispatch({type: actions.SET_IMAGE, payload: image})
}, [image])

return (
<Modal size="large" open={open} onDismiss={onClose} shouldCloseOnDocumentClick={false}>
<Modal.Header>
<CloseButton placement="end" offset="small" onClick={onClose} screenReaderLabel="Close" />
<Heading>{formatMessage('Crop Image')}</Heading>
</Modal.Header>
<Modal.Body>
<ImageCropper image={image} />
<Flex direction="column" margin="none">
<Flex.Item margin="0 0 small 0">
<Controls settings={settings} dispatch={dispatch} />
</Flex.Item>
<Flex.Item>
<Preview settings={settings} dispatch={dispatch} />
</Flex.Item>
</Flex>
</Modal.Body>
<Modal.Footer>
<Button onClick={onClose} margin="0 x-small 0 0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@

import React, {useRef, useEffect} from 'react'
import PropTypes from 'prop-types'
import formatMessage from '../../../../../../format-message'
import {ImageCropperSettingsPropTypes} from './propTypes'
import {buildSvg} from './svg'
import {PREVIEW_WIDTH, PREVIEW_HEIGHT} from './constants'
import formatMessage from '../../../../../../format-message'
import {useMouseWheel} from './useMouseWheel'

/**
* Remove the node contents and append the svg element.
Expand All @@ -33,12 +35,15 @@ function replaceSvg(svg, node) {
node.appendChild(svg)
}

const ImageCropperPreview = ({image, shape}) => {
const wrapper = useRef(null)
export const Preview = ({settings, dispatch}) => {
const shapeRef = useRef(null)
const {image, shape, scaleRatio} = settings
const [tempScaleRatio, onWheelCallback] = useMouseWheel(scaleRatio, dispatch)

useEffect(() => {
const svg = buildSvg(shape)
replaceSvg(svg, wrapper.current)
}, [shape])
replaceSvg(svg, shapeRef.current)
})

return (
<div
Expand All @@ -50,6 +55,7 @@ const ImageCropperPreview = ({image, shape}) => {
left: 0,
overflow: 'hidden'
}}
onWheel={onWheelCallback}
>
<img
src={image}
Expand All @@ -62,7 +68,7 @@ const ImageCropperPreview = ({image, shape}) => {
width: '100%',
objectFit: 'contain',
textAlign: 'center',
cursor: 'move'
transform: `scale(${tempScaleRatio})`
}}
/>
<div
Expand All @@ -72,15 +78,13 @@ const ImageCropperPreview = ({image, shape}) => {
top: 0,
left: 0
}}
ref={wrapper}
ref={shapeRef}
/>
</div>
)
}

ImageCropperPreview.propTypes = {
image: PropTypes.string.isRequired,
shape: PropTypes.string.isRequired
Preview.propTypes = {
settings: ImageCropperSettingsPropTypes.isRequired,
dispatch: PropTypes.func.isRequired
}

export default ImageCropperPreview

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react'
import {fireEvent, render, waitFor} from '@testing-library/react'

import {Preview} from '../Preview'

describe('Preview', () => {
let settings, dispatch

beforeEach(() => {
settings = {
image: 'https://www.fillmurray.com/640/480',
shape: 'square',
scaleRatio: 1
}
dispatch = jest.fn()
})

describe('renders', () => {
it('the image', () => {
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
expect(container.querySelector('img')).toBeInTheDocument()
})

it('the image with src', () => {
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
expect(container.querySelector('img')).toMatchInlineSnapshot(`
<img
alt="Image to crop"
src="https://www.fillmurray.com/640/480"
style="position: absolute; top: 0px; left: 0px; height: 100%; width: 100%; object-fit: contain; text-align: center; transform: scale(1);"
/>
`)
})

it('the crop shape container', () => {
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
expect(container.querySelector('#cropShapeContainer')).toBeInTheDocument()
})
})

it('changes the crop shape', () => {
const {container, rerender} = render(<Preview settings={settings} dispatch={dispatch} />)
const svgContainer = container.querySelector('#cropShapeContainer')
const squareContent = svgContainer.innerHTML
settings.shape = 'octagon'
rerender(<Preview settings={settings} dispatch={dispatch} />)
const octagonContent = svgContainer.innerHTML
expect(squareContent).not.toEqual(octagonContent)
})

describe('calls dispatch using wheel', () => {
it('when zoom in', async () => {
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
const event = {deltaY: -25}
fireEvent.wheel(container.firstChild, event)
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith({type: 'SetScaleRatio', payload: 1.125})
})
})

it('when zoom out', async () => {
settings.scaleRatio = 2
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
const event = {deltaY: 25}
fireEvent.wheel(container.firstChild, event)
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith({type: 'SetScaleRatio', payload: 1.875})
})
})
})

describe('sets scale style using wheel', () => {
it('when zoom in', async () => {
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
const event = {deltaY: -25}
fireEvent.wheel(container.firstChild, event)
await waitFor(() => {
const img = container.querySelector('img')
expect(img.style.transform).toEqual('scale(1.125)')
})
})

it('when zoom out', async () => {
settings.scaleRatio = 2
const {container} = render(<Preview settings={settings} dispatch={dispatch} />)
const event = {deltaY: 25}
fireEvent.wheel(container.firstChild, event)
await waitFor(() => {
const img = container.querySelector('img')
expect(img.style.transform).toEqual('scale(1.875)')
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ export const PREVIEW_WIDTH = 942
export const PREVIEW_HEIGHT = 350
export const SHAPE_CONTAINER_LENGTH = 350
export const GLUE_WIDTH = 296
export const MIN_SCALE_RATIO = 1.0
export const MAX_SCALE_RATIO = 2.0
export const BUTTON_SCALE_STEP = 0.1
export const WHEEL_SCALE_STEP = 0.005
export const WHEEL_EVENT_DELAY = 100

export const DEFAULT_CROPPER_SETTINGS = {
image: null,
shape: 'square',
scaleRatio: MIN_SCALE_RATIO
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
Expand All @@ -16,12 +16,12 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, {useState} from 'react'
import PropTypes from 'prop-types'
import {SimpleSelect} from '@instructure/ui-simple-select'
import React from 'react'
import {Flex} from '@instructure/ui-flex'
import ImageCropperPreview from './ImageCropperPreview'
import formatMessage from '../../../../../../format-message'
import formatMessage from '../../../../../../../format-message'
import {SimpleSelect} from '@instructure/ui-simple-select'
import PropTypes from 'prop-types'
import {ZoomControls} from './ZoomControls'

const SHAPE_OPTIONS = [
{id: 'square', label: formatMessage('Square')},
Expand All @@ -34,32 +34,32 @@ const SHAPE_OPTIONS = [
{id: 'star', label: formatMessage('Star')}
]

export const ImageCropper = ({image}) => {
const [selectedShape, setSelectedShape] = useState('square')
export const ShapeControls = ({shape, onChange}) => {
return (
<Flex direction="column" margin="none">
<Flex.Item margin="none none small" overflowY="hidden">
<SimpleSelect
isInline
assistiveText={formatMessage('Select crop shape')}
value={selectedShape}
onChange={(event, {id}) => setSelectedShape(id)}
renderLabel={null}
>
{SHAPE_OPTIONS.map(option => (
<SimpleSelect.Option key={option.id} id={option.id} value={option.id}>
{option.label}
</SimpleSelect.Option>
))}
</SimpleSelect>
</Flex.Item>
<Flex.Item>
<ImageCropperPreview image={image} shape={selectedShape} />
</Flex.Item>
</Flex>
<Flex.Item margin="0 small 0 0">
<SimpleSelect
isInline
assistiveText={formatMessage('Select crop shape')}
value={shape}
onChange={(event, {id}) => onChange(id)}
renderLabel={null}
>
{SHAPE_OPTIONS.map(option => (
<SimpleSelect.Option key={option.id} id={option.id} value={option.id}>
{option.label}
</SimpleSelect.Option>
))}
</SimpleSelect>
</Flex.Item>
)
}

ImageCropper.propTypes = {
image: PropTypes.string
ShapeControls.propTypes = {
shape: PropTypes.string,
onChange: PropTypes.func
}

ZoomControls.defaultProps = {
shape: 'square',
onChange: () => {}
}
Loading

0 comments on commit 45296ec

Please sign in to comment.