@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
882 lines (802 loc) • 26.7 kB
JavaScript
/** @jsx jsx */
import {Component, createRef} from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import debounce from 'lodash/debounce'
import {Button, CondensedButton, IconButton} from '@instructure/ui-buttons'
import {IconTrashLine, IconUploadLine, IconExpandItemsLine} from '@instructure/ui-icons'
import {Text} from '@instructure/ui-text'
import {Spinner} from '@instructure/ui-spinner'
import {Popover} from '@instructure/ui-popover'
import {jsx} from '@instructure/emotion'
import {FileDrop} from '@instructure/quiz-common/components/FileDrop/index'
import {FormFieldGroup} from '@instructure/quiz-common/components/FormFieldGroup/index'
import {SimpleModal} from '@instructure/quiz-common/components/SimpleModal/index'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
export default class DrawingContainer extends Component {
static displayName = 'DrawingContainer'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
canvasErrors: PropTypes.array,
convertCoordinates: PropTypes.func,
currentType: PropTypes.string,
drawTypes: PropTypes.array,
fileDropErrors: PropTypes.array,
isUploading: PropTypes.bool,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
onDropAccepted: PropTypes.func,
onSetType: PropTypes.func,
onRemoveImage: PropTypes.func,
onRemoveHotspot: PropTypes.func,
typeErrors: PropTypes.array,
url: PropTypes.string,
makeStyles: PropTypes.func,
styles: PropTypes.object,
onOpenShortcutsModal: PropTypes.func,
onCloseShortcutsModal: PropTypes.func,
isShortcutsModalOpen: PropTypes.bool,
hotspots: PropTypes.array,
tempHotspot: PropTypes.object,
currentHotspotId: PropTypes.number,
setTempHotspot: PropTypes.func,
setCanvasRef: PropTypes.func,
canvasRef: PropTypes.object,
multipleHotSpotEnabled: PropTypes.bool,
}
static defaultProps = {
onModalOpen: Function.prototype,
onModalClose: Function.prototype,
canvasErrors: void 0,
convertCoordinates: void 0,
currentType: void 0,
drawTypes: void 0,
fileDropErrors: void 0,
isUploading: void 0,
onDropAccepted: void 0,
onSetType: void 0,
onRemoveImage: void 0,
typeErrors: void 0,
url: void 0,
hotspots: void 0,
tempHotspot: void 0,
multipleHotSpotEnabled: false,
}
state = {
imageWidth: 0,
imageHeight: 0,
isModalOpen: false,
currentPolygonCoordinates: null,
}
drawingContainerRef = createRef()
modalDrawingContainerRef = createRef()
componentDidMount() {
window.addEventListener('resize', this.updateSize)
window.addEventListener('keydown', this.handleKeyPress)
this.updateSize()
this.props.makeStyles({isModal: this.state.isModalOpen})
}
componentDidUpdate(lastProps, lastState) {
if (lastProps.url && this.props.url === void 0) {
this.fileDrop.fileInputEl.focus()
}
if (
lastState.imageWidth !== this.state.imageWidth ||
lastState.imageHeight !== this.state.imageHeight
) {
this.updateSize()
}
this.props.makeStyles({isModal: this.state.isModalOpen})
}
componentWillUnmount() {
window.removeEventListener('resize', this.updateSize)
window.removeEventListener('keydown', this.handleKeyPress)
this.updateSize.cancel()
}
// ===========
// HELPERS
// ===========
currentImageWidth = () => {
return this.state.imageWidth
}
currentImageHeight = () => {
return this.state.imageHeight
}
getSelectedTypeComponent(currentType) {
const type = this.props.drawTypes.find(item => item.type === currentType)
return type?.component
}
updateSize = debounce(() => {
if (this.props.url && this.image) {
// ref element (this.image) is not enough to get the updated image dimensions
const rectObject = ReactDOM.findDOMNode(this.image).getBoundingClientRect()
this.setDimensions(rectObject)
}
}, 50)
setDimensions = rectObject => {
this.setState({
imageWidth: rectObject.width,
imageHeight: rectObject.height,
})
}
transformCoordinates = (coordinates, imageWidth, imageHeight) => {
return coordinates?.map(({x, y}) => ({
x: x * imageWidth,
y: y * imageHeight,
}))
}
updateCoordinates = (coordinates, key, increment, imageWidth, imageHeight, shiftKey, altKey) => {
let updatedCoordinates = [...coordinates]
const [topLeft, bottomRight] = updatedCoordinates
if (shiftKey && altKey) {
switch (key) {
case 'ArrowLeft':
bottomRight.x = Math.max(topLeft.x + increment, bottomRight.x - increment)
break
case 'ArrowRight':
topLeft.x = Math.min(bottomRight.x - increment, topLeft.x + increment)
break
case 'ArrowUp':
bottomRight.y = Math.max(topLeft.y + increment, bottomRight.y - increment)
break
case 'ArrowDown':
topLeft.y = Math.min(bottomRight.y - increment, topLeft.y + increment)
break
default:
break
}
} else if (shiftKey) {
switch (key) {
case 'ArrowUp':
topLeft.y = Math.max(0, topLeft.y - increment)
break
case 'ArrowDown':
bottomRight.y = Math.min(imageHeight, bottomRight.y + increment)
break
case 'ArrowLeft':
topLeft.x = Math.max(0, topLeft.x - increment)
break
case 'ArrowRight':
bottomRight.x = Math.min(imageWidth, bottomRight.x + increment)
break
default:
break
}
} else {
switch (key) {
case 'ArrowUp':
topLeft.y = Math.max(0, topLeft.y - increment)
bottomRight.y = Math.max(0, bottomRight.y - increment)
break
case 'ArrowDown':
topLeft.y = Math.min(imageHeight, topLeft.y + increment)
bottomRight.y = Math.min(imageHeight, bottomRight.y + increment)
break
case 'ArrowLeft':
topLeft.x = Math.max(0, topLeft.x - increment)
bottomRight.x = Math.max(0, bottomRight.x - increment)
break
case 'ArrowRight':
topLeft.x = Math.min(imageWidth, topLeft.x + increment)
bottomRight.x = Math.min(imageWidth, bottomRight.x + increment)
break
default:
break
}
}
return updatedCoordinates
}
isImageUploaded = () => {
return !!this.image
}
isArrowKey = key => {
return ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)
}
isLetterKey = key => {
return [
'b',
'B',
'd',
'D',
'e',
'E',
'f',
'F',
'i',
'I',
'o',
'O',
'p',
'P',
'r',
'R',
].includes(key)
}
getCurrentHotspot = () => {
return this.props.multipleHotSpotEnabled
? this.props.hotspots.find(hotspot => hotspot.id === this.props.currentHotspotId)
: this.props.hotspots[0]
}
isOutsideDrawingContainer = () => {
const activeElement = document.activeElement
return (
!this.drawingContainerRef?.current?.contains(activeElement) &&
!this.modalDrawingContainerRef?.current?.contains(activeElement)
)
}
isBankOrOutcomesModalOpen = () => {
return !!document.querySelector(
'[data-automation="sdk-add-to-bank-modal"], [data-automation="outcomePicker__modal"]',
)
}
// ===========
// HANDLERS
// ============
handleImageLoad = e => {
this.setDimensions({
height: e.target.offsetHeight,
width: e.target.offsetWidth,
})
}
handleSelectDrawOption = type => {
this.props.onSetType(type)
}
handleExpandImage = () => {
if (this.isOutsideDrawingContainer()) {
return
}
this.props.onModalOpen()
this.setState({isModalOpen: true})
}
handleCloseModal = () => {
this.props.onModalClose()
this.setState({isModalOpen: false})
}
handleCanvasRef = node => {
this.canvas = node && node.canvas
}
handleImageRef = node => {
this.image = node
}
handleFileDropRef = node => {
this.fileDrop = node
}
moveOrResizePolygon = (key, shiftKey, altKey) => {
const hotspot = this.getCurrentHotspot()
const coordinates = this.transformCoordinates(
hotspot.coordinates,
this.state.imageWidth,
this.state.imageHeight,
)
const increment = 10
let updatedCoordinates = [...coordinates]
let lastCoordinate = updatedCoordinates[updatedCoordinates.length - 1]
let lastCoordinatesCopy = {...lastCoordinate}
this.canvas = this.canvas || this.props.canvasRef
if (this.props.currentType !== 'polygon' || !this.canvas.isDrawing()) {
return
}
if (altKey && shiftKey) {
switch (key) {
case 'ArrowUp':
lastCoordinatesCopy.y = Math.max(0, lastCoordinatesCopy.y + increment)
break
case 'ArrowDown':
lastCoordinatesCopy.y = Math.min(
this.state.imageHeight,
lastCoordinatesCopy.y - increment,
)
break
case 'ArrowLeft':
lastCoordinatesCopy.x = Math.max(0, lastCoordinatesCopy.x - increment)
break
case 'ArrowRight':
lastCoordinatesCopy.x = Math.min(this.state.imageWidth, lastCoordinatesCopy.x + increment)
break
default:
return
}
if (updatedCoordinates.length > 1) {
const lastIndex = updatedCoordinates.length - 1
const secondLastIndex = lastIndex - 1
if (
(updatedCoordinates[lastIndex].x === updatedCoordinates[secondLastIndex].x &&
updatedCoordinates[lastIndex].y === updatedCoordinates[secondLastIndex].y) ||
(updatedCoordinates[lastIndex].x === updatedCoordinates[0].x &&
updatedCoordinates[lastIndex].y === updatedCoordinates[0].y)
) {
updatedCoordinates.pop()
}
}
updatedCoordinates = updatedCoordinates.slice(0, -1).concat(lastCoordinatesCopy)
this.setState({currentPolygonCoordinates: lastCoordinatesCopy})
} else if (shiftKey) {
switch (key) {
case 'ArrowUp':
lastCoordinatesCopy.y = Math.max(0, lastCoordinatesCopy.y - increment)
break
case 'ArrowDown':
lastCoordinatesCopy.y = Math.min(
this.state.imageHeight,
lastCoordinatesCopy.y + increment,
)
break
case 'ArrowLeft':
lastCoordinatesCopy.x = Math.max(0, lastCoordinatesCopy.x - increment)
break
case 'ArrowRight':
lastCoordinatesCopy.x = Math.min(this.state.imageWidth, lastCoordinatesCopy.x + increment)
break
default:
return
}
this.setState({currentPolygonCoordinates: lastCoordinatesCopy})
updatedCoordinates = updatedCoordinates.slice(0, -1).concat(lastCoordinatesCopy)
} else {
updatedCoordinates = updatedCoordinates.map(coord => {
switch (key) {
case 'ArrowUp':
return {...coord, y: Math.max(0, coord.y - increment)}
case 'ArrowDown':
return {...coord, y: Math.min(this.state.imageHeight, coord.y + increment)}
case 'ArrowLeft':
return {...coord, x: Math.max(0, coord.x - increment)}
case 'ArrowRight':
return {...coord, x: Math.min(this.state.imageWidth, coord.x + increment)}
default:
return coord
}
})
}
let lines = [...updatedCoordinates]
if (lines.length > 2) {
lines.push(lines[0])
}
this.canvas.drawShape(lines)
this.props.convertCoordinates(updatedCoordinates, true, true)
}
calculateCoordinates = (shapeType, imageWidth, imageHeight) => {
const x1 = (imageWidth - 50) / 2
const y1 = (imageHeight - 50) / 2
let coordinates
switch (shapeType) {
case 'square':
coordinates = [
{x: x1, y: y1},
{x: x1 + 50, y: y1 + 50},
]
break
case 'oval':
coordinates = [
{x: imageWidth / 2 - 50, y: imageHeight / 2 - 50},
{x: imageWidth / 2 + 50, y: imageHeight / 2 + 50},
]
break
case 'polygon':
coordinates = [
{x: x1, y: y1},
{x: x1 + 50, y: y1},
]
break
default:
coordinates = []
}
return coordinates
}
addSquareHotspot = () => {
this.props.onSetType('square')
const initialCoordinate = this.calculateCoordinates(
'square',
this.state.imageWidth,
this.state.imageHeight,
)
this.props.convertCoordinates(initialCoordinate, this.props.tempHotspot ? true : false)
}
addOvalHotspot = () => {
this.props.onSetType('oval')
const initialCoordinate = this.calculateCoordinates(
'oval',
this.state.imageWidth,
this.state.imageHeight,
)
this.props.convertCoordinates(initialCoordinate, this.props.tempHotspot ? true : false)
}
drawPolygonHotspot = () => {
if (this.canvas?.isDrawing()) {
return
}
this.props.onSetType('polygon')
const initialCoordinate = this.calculateCoordinates(
'polygon',
this.state.imageWidth,
this.state.imageHeight,
)
const currentHotspot = this.getCurrentHotspot()
let lines = currentHotspot.coordinates.concat(initialCoordinate)
// add initial coordinate at the end of the array to close the polygon
if (lines.length > 2) {
lines = lines.concat([lines[0]])
}
this.props.setCanvasRef(this.canvas)
this.props.convertCoordinates(initialCoordinate, this.props.tempHotspot ? true : false)
this.canvas = this.canvas || this.props.canvasRef
this.canvas?.setState({
isDrawing: true,
})
}
addPolygonPoint = () => {
const {currentType, convertCoordinates} = this.props
const {currentPolygonCoordinates, imageWidth, imageHeight} = this.state
const currentHotspot = this.getCurrentHotspot()
if (
currentType !== 'polygon' ||
!this.canvas.isDrawing() ||
!currentHotspot.coordinates ||
(!currentPolygonCoordinates && currentHotspot.coordinates.length !== 2)
) {
this.setState({currentPolygonCoordinates: null})
return
}
const transformedCoordinates = this.transformCoordinates(
currentHotspot.coordinates,
imageWidth,
imageHeight,
)
let updatedCoordinates
if (currentHotspot.coordinates.length === 2) {
const lastCoordinate = this.transformCoordinates(
[currentHotspot.coordinates[1]],
imageWidth,
imageHeight,
)
updatedCoordinates = transformedCoordinates.concat(lastCoordinate)
} else {
updatedCoordinates = transformedCoordinates.concat(currentPolygonCoordinates)
}
convertCoordinates(updatedCoordinates, true, true)
this.setState({currentPolygonCoordinates: null})
}
OpenShortcutsModal = () => {
if (this.isBankOrOutcomesModalOpen()) {
return
}
this.props.onOpenShortcutsModal()
}
handleCloseShape = () => {
const {currentType} = this.props
const {imageWidth, imageHeight} = this.state
const currentHotspot = this.getCurrentHotspot()
if (currentType !== 'polygon' && !this.canvas.isDrawing()) {
return
}
const transformedCoordinates = this.transformCoordinates(
currentHotspot.coordinates,
imageWidth,
imageHeight,
)
const finalCoordinates = transformedCoordinates.concat(transformedCoordinates[0])
this.canvas?.stopDrawing(finalCoordinates)
}
handleKeyPress = event => {
const {key, shiftKey, altKey} = event
const normalizedKey = this.isLetterKey(key) ? key.toLowerCase() : key
const activeElement = document.activeElement
const hasHotspots = this.props.hotspots.length > 0
const isImageUploaded = this.isImageUploaded()
const controlKeyAction = {
b: () => this.triggerFileUpload(),
i: () => this.props.onOpenShortcutsModal(),
}
const isInteractiveElement = ['INPUT', 'TEXTAREA', 'BUTTON'].includes(activeElement.tagName)
if (isInteractiveElement) {
return
}
if (!isImageUploaded) {
const handleHotkey = controlKeyAction[normalizedKey]
if (handleHotkey) {
event.preventDefault()
handleHotkey()
return
}
}
const imageKeyActions = {
Delete: () => this.deleteSelectedHotspot(),
Backspace: () => this.deleteSelectedHotspot(),
ArrowUp: () => this.moveOrResizeHotspot(normalizedKey, shiftKey, altKey),
ArrowDown: () => this.moveOrResizeHotspot(normalizedKey, shiftKey, altKey),
ArrowLeft: () => this.moveOrResizeHotspot(normalizedKey, shiftKey, altKey),
ArrowRight: () => this.moveOrResizeHotspot(normalizedKey, shiftKey, altKey),
i: () => this.OpenShortcutsModal(),
f: () => this.handleExpandImage(),
r: () => this.addSquareHotspot(),
o: () => this.addOvalHotspot(),
p: () => this.drawPolygonHotspot(),
d: () => this.props.onRemoveImage(),
e: () => this.handleCloseShape(),
Enter: () => this.addPolygonPoint(),
Escape: () => this.handleCloseModal(),
}
if (this.isArrowKey(normalizedKey) && hasHotspots) {
event.preventDefault()
}
const handleHotkey = imageKeyActions[normalizedKey]
if (handleHotkey) {
event.preventDefault()
handleHotkey()
}
}
deleteSelectedHotspot = () => {
this.props.onRemoveHotspot(this.props.currentHotspotId)
}
triggerFileUpload = () => {
this.fileDrop.fileInputEl.click()
}
moveOrResizeHotspot = (key, shiftKey, altKey) => {
const currentHotspot = this.getCurrentHotspot()
if (currentHotspot.coordinates.length === 0) return
if (this.props.currentType === 'polygon') {
this.moveOrResizePolygon(key, shiftKey, altKey)
} else {
const coordinates = this.transformCoordinates(
currentHotspot.coordinates,
this.state.imageWidth,
this.state.imageHeight,
)
const increment = 10 // Move or resize by 10px
const updatedCoordinates = this.updateCoordinates(
coordinates,
key,
increment,
this.state.imageWidth,
this.state.imageHeight,
shiftKey,
altKey,
)
this.props.convertCoordinates(updatedCoordinates, true, true)
}
}
convertCoordinatesForRendering = coordinates => {
return coordinates.map(item => {
const x = item.x * this.state.imageWidth
const y = item.y * this.state.imageHeight
return {x, y}
})
}
// ===========
// RENDER
// ===========
renderCanvas = () => {
const {hotspots, tempHotspot, isUploading} = this.props
if ((!hotspots.length && !tempHotspot) || isUploading) {
return null
}
// Create an array that includes all hotspots and tempHotspot if it exists
const hotspotsToRender = tempHotspot ? [...hotspots, tempHotspot] : hotspots
return hotspotsToRender.map((hotspot, index) => {
const SelectedType = this.getSelectedTypeComponent(hotspot.type)
const coordinates = this.convertCoordinatesForRendering(hotspot.coordinates || [])
return (
<SelectedType
key={hotspot.id || index}
ref={this.handleCanvasRef}
handleSetCoordinates={this.props.convertCoordinates}
coordinates={coordinates}
width={this.state.imageWidth}
height={this.state.imageHeight}
hotspotId={hotspot.id}
/>
)
})
}
renderTypes() {
return (
<div className="mainContainerType" css={this.props.styles.mainContainerType}>
{this.props.drawTypes.map(item => {
const TypeIcon = item.icon
let onButtonClick = () => this.handleSelectDrawOption(item.type)
let color = 'secondary'
let wrapperStyle = this.props.styles.mainContainerTypeUnselected
if (item.type === (this.props.currentType || 'square')) {
onButtonClick = null
color = 'primary-inverse'
wrapperStyle = this.props.styles.mainContainerTypeSelected
}
return (
<Popover
key={item.type}
color="primary-inverse"
onClick={onButtonClick}
shouldSetAriaExpanded={false}
renderTrigger={
<div css={wrapperStyle}>
<IconButton
color={color}
withBackground={false}
withBorder={false}
screenReaderLabel={item.title}
renderIcon={<TypeIcon title={item.title} />}
/>
</div>
}
>
<div css={this.props.styles.popoverContent}>{item.title}</div>
</Popover>
)
})}
</div>
)
}
renderActionButton(onClick, label, Icon, automationData = '') {
return (
<Popover
onClick={onClick}
color="primary-inverse"
renderTrigger={
<IconButton
renderIcon={Icon}
withBackground={false}
withBorder={false}
screenReaderLabel={label}
{...(automationData && {'data-automation': automationData})}
/>
}
>
<div css={this.props.styles.popoverContent}>{label}</div>
</Popover>
)
}
renderActions() {
return (
<div css={this.props.styles.mainContainerActions}>
{this.renderActionButton(this.handleExpandImage, t('Expand Image'), IconExpandItemsLine)}
<div css={this.props.styles.mainContainerActionsRemove}>
{this.renderActionButton(
this.props.onRemoveImage,
t('Remove Image'),
IconTrashLine,
'sdk-remove-image-button',
)}
</div>
</div>
)
}
renderHeaderText(isModal) {
const {styles, currentType, hotspots} = this.props
if (currentType === 'polygon' && hotspots.length > 0) {
return (
<div css={styles.headerText}>
{t.rich('<0>Double click to</0> <1>close shape</1>', [
({children}) => <Text key="1">{children}</Text>,
({children}) => (
<CondensedButton
key="2"
size="large"
margin="0 0 0 x-small"
themeOverride={{
largePadding: styles.headerText.condensedButton.padding,
largeFontSize: styles.headerText.condensedButton.fontSize,
largeHeight: styles.headerText.condensedButton.height,
}}
>
{children}
</CondensedButton>
),
])}
</div>
)
}
return <Text color="primary">{t("Draw the hot spot's shape")}</Text>
}
renderImage() {
return (
<div css={this.props.styles.imageHolder}>
<img
alt={t('Uploaded Image')}
css={this.props.styles.mainContainerContentImage}
onLoad={this.handleImageLoad}
ref={this.handleImageRef}
src={this.props.url}
/>
{!this.props.isUploading ? null : (
<div css={this.props.styles.spinnerWrapper}>
<div css={this.props.styles.spinner}>
<Spinner renderTitle={t('Loading')} size="large" variant="inverse" />
</div>
</div>
)}
{this.renderCanvas()}
</div>
)
}
renderDrawingContainer() {
return (
<div css={this.props.styles.mainContainer} ref={this.drawingContainerRef}>
{this.props.isUploading ? null : (
<div css={this.props.styles.mainContainerHeader}>
<FormFieldGroup
description={<ScreenReaderContent>{t('Hot Spot editor')}</ScreenReaderContent>}
messages={this.props.typeErrors}
>
<div css={this.props.styles.mainContainerHeader}>
<div>{this.renderHeaderText()}</div>
{this.renderTypes()}
</div>
</FormFieldGroup>
{this.renderActions()}
</div>
)}
<div css={this.props.styles.mainContainerContentWrapper}>{this.renderImage()}</div>
</div>
)
}
renderFileDropContent() {
return (
<div css={this.props.styles.fileDropContent}>
<div css={this.props.styles.fileDropContentIcon}>
<IconUploadLine />
</div>
<div css={this.props.styles.fileDropContentLabel}>
{t.rich("Drag n' Drop here or <0>Browse</0>", [
({children}) => (
<div key="1" css={this.props.styles.fileDropContentLabelBrowse}>
{children}
</div>
),
])}
</div>
</div>
)
}
renderFileDrop() {
return (
<div css={this.props.styles.fileDropWrapper}>
<FileDrop
accept="image/*"
renderLabel={this.renderFileDropContent()}
onDropAccepted={this.props.onDropAccepted}
ref={this.handleFileDropRef}
messages={this.props.fileDropErrors}
/>
</div>
)
}
renderModal() {
return (
<SimpleModal
footerContent={
<Button onClick={this.handleCloseModal} color="primary">
{t('Done')}
</Button>
}
isModalOpen={this.state.isModalOpen}
onModalDismiss={this.handleCloseModal}
size="fullscreen"
title={this.renderHeaderText(true)}
label={t('Modal Dialog: Draw a hot spot')}
>
<div css={this.props.styles.modalContent} ref={this.modalDrawingContainerRef}>
<div css={this.props.styles.modalContentTypes}>{this.renderTypes()}</div>
<div css={this.props.styles.modalContentImage}>{this.renderImage()}</div>
</div>
</SimpleModal>
)
}
render() {
if (!this.props.url) {
return <div>{this.renderFileDrop()}</div>
} else if (this.state.isModalOpen === true) {
return <div>{this.renderModal()}</div>
} else {
return (
<FormFieldGroup required messages={this.props.canvasErrors} description={t('Hot Spot')}>
{this.renderDrawingContainer()}
</FormFieldGroup>
)
}
}
}