UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

882 lines (802 loc) • 26.7 kB
/** @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' @withStyleOverrides(generateStyle, generateComponentTheme) 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> ) } } }