UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

350 lines (305 loc) • 10.6 kB
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> ) } }