@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
350 lines (305 loc) • 10.6 kB
JavaScript
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import isEmpty from 'lodash/fp/isEmpty'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import {Text} from '@instructure/ui-text'
import {Button, CloseButton, CondensedButton, IconButton} from '@instructure/ui-buttons'
import {Flex} from '@instructure/quiz-common/components/Flex/index'
import {IconKeyboardShortcutsLine} from '@instructure/ui-icons'
import {Modal} from '@instructure/ui-modal'
import {Heading} from '@instructure/ui-heading'
import {Table} from '@instructure/ui-table'
import TargetContainer from '../common/TargetContainer'
import t from '@instructure/quiz-i18n/format-message'
import Target from './Target'
/**
---
category: HotSpot
---
HotSpot Take component
```jsx_example
function Example (props) {
const exampleProps = {
itemBody: 'Which of these players is David Ferrer',
interactionData: {
imageUrl: 'https://i.ytimg.com/vi/LgkAebhJ7iE/maxresdefault.jpg'
},
userResponse: {
value: {
x: 0.3,
y: 0.3
}
}
}
return (
<HotSpotTake {...exampleProps} {...props} />
)
}
<SettingsSwitcher locales={LOCALES}>
<TakeStateProvider>
<Example />
</TakeStateProvider>
</SettingsSwitcher>
```
**/
export default class HotSpotTake extends Component {
static propTypes = {
handleResponseUpdate: PropTypes.func.isRequired,
interactionData: PropTypes.shape({
imageUrl: PropTypes.string.isRequired,
hotspotsCount: PropTypes.number,
}).isRequired,
itemBody: PropTypes.string.isRequired,
userResponse: PropTypes.shape({
value: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}),
),
PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}),
]).isRequired,
}),
scoringData: PropTypes.shape({
value: PropTypes.arrayOf(
PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}),
).isRequired,
}),
multipleHotSpotEnabled: PropTypes.bool,
}
constructor(props) {
super(props)
this.state = {
selectedCoordinates: [],
keyboardCoordinates: null,
isSelectingPoint: false,
focusedContainer: null,
shortcutsModalOpen: false,
}
}
componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown)
const value = this.props.userResponse?.value
if (!isEmpty(value)) {
this.setState({selectedCoordinates: Array.isArray(value) ? value : [value]})
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown)
}
isAnyInputFocused() {
const inputs = document.querySelectorAll('input')
return Array.from(inputs).some(input => input === document.activeElement)
}
canAddPoint = () => {
const {selectedCoordinates, keyboardCoordinates} = this.state
const {hotspotsCount} = this.props.interactionData
const totalSelections = selectedCoordinates.length + (keyboardCoordinates ? 1 : 0)
return totalSelections < (hotspotsCount || 1)
}
saveSelectedPoint = () => {
const {selectedCoordinates, keyboardCoordinates} = this.state
const {
multipleHotSpotEnabled,
interactionData: {hotspotsCount},
} = this.props
if (!keyboardCoordinates) return
const updatedCoordinates = [...selectedCoordinates, keyboardCoordinates]
this.setState({
selectedCoordinates: updatedCoordinates,
keyboardCoordinates: null,
isSelectingPoint: false,
})
const response =
!multipleHotSpotEnabled && (!hotspotsCount || hotspotsCount === 1)
? keyboardCoordinates
: updatedCoordinates
this.props.handleResponseUpdate(response)
}
handleKeyDown = event => {
const stepSize = 0.05
const {key} = event
if (!this.state.focusedContainer) return
if (this.isAnyInputFocused()) return
if (key === 's') {
this.startSelectingPoint()
} else if (key === 'd' && this.state.isSelectingPoint) {
this.saveSelectedPoint()
} else if (key === 'Backspace' || key === 'Delete') {
this.handleResetSelections()
} else if (this.isArrowKey(key) && this.state.keyboardCoordinates) {
event.preventDefault()
this.movePosition(key, stepSize)
}
}
startSelectingPoint = () => {
if (!this.canAddPoint()) return
this.setState({
keyboardCoordinates: {x: 0.5, y: 0.5},
isSelectingPoint: true,
})
}
isArrowKey = key => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)
movePosition = (key, stepSize) => {
const {x, y} = this.state.keyboardCoordinates
const offsets = {
ArrowUp: {x, y: Math.max(y - stepSize, 0)},
ArrowDown: {x, y: Math.min(y + stepSize, 1)},
ArrowLeft: {x: Math.max(x - stepSize, 0), y},
ArrowRight: {x: Math.min(x + stepSize, 1), y},
}
this.setState({keyboardCoordinates: offsets[key]})
}
handleSetCoordinates = coordinatesArray => {
if (!this.canAddPoint()) return
const {selectedCoordinates} = this.state
const {
multipleHotSpotEnabled,
interactionData: {hotspotsCount},
} = this.props
const x = coordinatesArray[0].x / this.targetContainer.state.imageWidth
const y = coordinatesArray[0].y / this.targetContainer.state.imageHeight
const updatedCoordinates = [...selectedCoordinates, {x, y}]
this.setState({selectedCoordinates: updatedCoordinates})
const response =
!multipleHotSpotEnabled && (!hotspotsCount || hotspotsCount === 1)
? {x, y}
: updatedCoordinates
this.props.handleResponseUpdate(response)
}
targetContainerRef = node => {
this.targetContainer = node
}
handleResetSelections = () => {
const updatedCoordinates = this.state.selectedCoordinates.slice(0, -1)
this.setState({selectedCoordinates: updatedCoordinates, keyboardCoordinates: null})
this.props.handleResponseUpdate(this.state.selectedCoordinates)
}
handleCloseShortcutsClick = () => {
this.setState({shortcutsModalOpen: false})
}
handleOpenShortcutsClick = () => {
this.setState({shortcutsModalOpen: true})
}
// ===========
// RENDER
// ===========
render() {
const {selectedCoordinates, keyboardCoordinates} = this.state
const hotspots =
selectedCoordinates.length > 0 || keyboardCoordinates
? [
...selectedCoordinates.map(coordinate => ({
coordinates: [coordinate],
drawingType: Target,
})),
...(keyboardCoordinates
? [
{
coordinates: [keyboardCoordinates],
drawingType: Target,
},
]
: []),
]
: [{coordinates: [{}], drawingType: Target}]
const keyboardShortcuts = [
{id: 'select', shortcut: 'S', action: t('Select a point')},
{id: 'save', shortcut: 'D', action: t('Save the position')},
{id: 'delete', shortcut: 'Backspace/Delete', action: t('Clear all selections')},
{id: 'move', shortcut: 'Arrow keys', action: t('Move the point')},
]
return (
<ItemBodyWrapper itemBody={this.props.itemBody}>
<div className="fs-mask">
<TargetContainer
hotspots={hotspots}
handleSetCoordinates={this.handleSetCoordinates}
ref={this.targetContainerRef}
url={this.props.interactionData.imageUrl}
onFocus={() => this.setState({focusedContainer: this.targetContainer})}
onBlur={() => this.setState({focusedContainer: null})}
/>
</div>
<Flex
direction="row"
justifyItems="space-between"
alignItems="center"
gap="small"
margin="x-small 0 0"
>
<Flex.Item>
{this.state.selectedCoordinates.length > 0 && (
<CondensedButton
onClick={this.handleResetSelections}
data-automation="sdk-hotspot-take-clear-selections-button"
>
{t('Clear my selection')}
</CondensedButton>
)}
</Flex.Item>
<Flex.Item>
<IconButton
color="secondary"
screenReaderLabel={t('Show keyboard shortcuts')}
withBackground={false}
withBorder={false}
onClick={this.handleOpenShortcutsClick}
>
<IconKeyboardShortcutsLine />
</IconButton>
<Modal
size="medium"
label={t('Keyboard shortcuts')}
open={this.state.shortcutsModalOpen}
onDismiss={this.handleCloseShortcutsClick}
shouldCloseOnDocumentClick
>
<Modal.Header>
<Heading level="h2">{t('Keyboard shortcuts')}</Heading>
<CloseButton
placement="end"
offset="small"
onClick={this.handleCloseShortcutsClick}
screenReaderLabel={t('Close')}
/>
</Modal.Header>
<Modal.Body>
<Table caption={t('Keyboard shortcuts')}>
<Table.Head>
<Table.Row>
<Table.ColHeader id="shortcut">{t('Shortcut')}</Table.ColHeader>
<Table.ColHeader id="action">{t('Action')}</Table.ColHeader>
</Table.Row>
</Table.Head>
<Table.Body>
{keyboardShortcuts.map(({id, shortcut, action}) => (
<Table.Row key={id}>
<Table.RowHeader>
<Text id={`shortcut-${id}`}>{shortcut}</Text>
</Table.RowHeader>
<Table.Cell>
<Text id={`action-${id}`}>{action}</Text>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Modal.Body>
<Modal.Footer>
<Button onClick={this.handleCloseShortcutsClick}>{t('Close')}</Button>
</Modal.Footer>
</Modal>
</Flex.Item>
</Flex>
</ItemBodyWrapper>
)
}
}