diff --git a/src/components/mic-indicator/mic-indicator.css b/src/components/mic-indicator/mic-indicator.css new file mode 100644 index 0000000000000000000000000000000000000000..9fb3970297b19fbc9c4db523bc9bbe42674ed682 --- /dev/null +++ b/src/components/mic-indicator/mic-indicator.css @@ -0,0 +1,10 @@ +@keyframes popIn { + from {transform: scale(0.5)} + to {transform: scale(1)} +} + +.mic-img { + margin: 10px; + transform-origin: center; + animation: popIn 0.1s ease-in-out; +} diff --git a/src/components/mic-indicator/mic-indicator.jsx b/src/components/mic-indicator/mic-indicator.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe0eb602b531e5c33de0cd2a696ab5a5dc907962 --- /dev/null +++ b/src/components/mic-indicator/mic-indicator.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './mic-indicator.css'; +import micIcon from './mic-indicator.svg'; +import {stageSizeToTransform} from '../../lib/screen-utils'; + +const MicIndicatorComponent = props => ( + <div + className={props.className} + style={stageSizeToTransform(props.stageSize)} + > + <img + className={styles.micImg} + src={micIcon} + /> + </div> +); + +MicIndicatorComponent.propTypes = { + className: PropTypes.string, + stageSize: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number, + widthDefault: PropTypes.number, + heightDefault: PropTypes.number + }).isRequired +}; + +export default MicIndicatorComponent; diff --git a/src/components/mic-indicator/mic-indicator.svg b/src/components/mic-indicator/mic-indicator.svg new file mode 100644 index 0000000000000000000000000000000000000000..78726389d13b811a8b7f31bbfa32a98df118a932 Binary files /dev/null and b/src/components/mic-indicator/mic-indicator.svg differ diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index 5cb11424113808e07f9c8573a9b2626ec3a838d5..f7b02080fdf71aeadbf673bf53a9fa23990d6760 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -4,20 +4,10 @@ import Box from '../box/box.jsx'; import Monitor from '../../containers/monitor.jsx'; import PropTypes from 'prop-types'; import {OrderedMap} from 'immutable'; +import {stageSizeToTransform} from '../../lib/screen-utils'; import styles from './monitor-list.css'; -const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => { - const scaleX = width / widthDefault; - const scaleY = height / heightDefault; - if (scaleX === 1 && scaleY === 1) { - // Do not set a transform if the scale is 1 because - // it messes up `position: fixed` elements like the context menu. - return; - } - return {transform: `scale(${scaleX},${scaleY})`}; -}; - const MonitorList = props => ( <Box // Use static `monitor-overlay` class for bounds of draggables diff --git a/src/components/question/question.css b/src/components/question/question.css index 4550df75cdbe1b3ad51b2e5c45e54f47d1de7627..a0f8116ed1a1d9c85957d1167ee792f1ee8515f4 100644 --- a/src/components/question/question.css +++ b/src/components/question/question.css @@ -1,13 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; -.question-wrapper { - position: absolute; - bottom: 0; - left: 0; - width: 100%; -} - .question-container { margin: $space; border: 1px solid $ui-black-transparent; diff --git a/src/components/question/question.jsx b/src/components/question/question.jsx index c57fb085d37de1f42a75f3a709c05d0f313f76ac..373f7cb919cd86fdbab96665457fdec29cb5fb4e 100644 --- a/src/components/question/question.jsx +++ b/src/components/question/question.jsx @@ -7,13 +7,14 @@ import enterIcon from './icon--enter.svg'; const QuestionComponent = props => { const { answer, + className, question, onChange, onClick, onKeyPress } = props; return ( - <div className={styles.questionWrapper}> + <div className={className}> <div className={styles.questionContainer}> {question ? ( <div className={styles.questionLabel}>{question}</div> @@ -43,6 +44,7 @@ const QuestionComponent = props => { QuestionComponent.propTypes = { answer: PropTypes.string, + className: PropTypes.string, onChange: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired, onKeyPress: PropTypes.func.isRequired, diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index 7ce4d55d9f6a953541564e6e12b51fc1fed9be78..98a0c42a715d8aace5fc8294dfc5d9f895226dd2 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -74,10 +74,6 @@ border: none; } -.question-wrapper { - position: absolute; -} - /* adjust monitors when stage is standard size: shift them down and right to compensate for the stage's border */ .stage-wrapper .monitor-wrapper { @@ -93,7 +89,7 @@ to adjust for the border using a different method */ padding-bottom: calc($stage-full-screen-stage-padding + $stage-full-screen-border-width); } -.monitor-wrapper, .color-picker-wrapper, .queston-wrapper { +.monitor-wrapper, .color-picker-wrapper { position: absolute; top: 0; left: 0; @@ -110,3 +106,24 @@ to adjust for the border using a different method */ z-index: $z-index-dragging-sprite; filter: drop-shadow(5px 5px 5px $ui-black-transparent); } + +.stage-bottom-wrapper { + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-end; + top: 0; + overflow: hidden; + pointer-events: none; +} + +.mic-indicator { + transform-origin: bottom right; + z-index: $z-index-stage-indicator; + pointer-events: none; + align-self: flex-end; +} + +.question-wrapper { + pointer-events: auto; +} diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index d8c0d07749bec8eb360820988b5e360318513e71..9e1f3454dd1686be6e745dd5fb8035ec97b16b41 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -7,6 +7,7 @@ import DOMElementRenderer from '../../containers/dom-element-renderer.jsx'; import Loupe from '../loupe/loupe.jsx'; import MonitorList from '../../containers/monitor-list.jsx'; import Question from '../../containers/question.jsx'; +import MicIndicator from '../mic-indicator/mic-indicator.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; import {getStageDimensions} from '../../lib/screen-utils.js'; import styles from './stage.css'; @@ -18,6 +19,7 @@ const StageComponent = props => { isColorPicking, isFullScreen, colorInfo, + micIndicator, question, stageSize, useEditorDragStyle, @@ -66,13 +68,22 @@ const StageComponent = props => { <Loupe colorInfo={colorInfo} /> </Box> ) : null} - {question === null ? null : ( - <div - className={classNames( - styles.stageOverlayContent, - styles.stageOverlayContentBorderOverride - )} - > + <div + className={styles.stageBottomWrapper} + style={{ + width: stageDimensions.width, + height: stageDimensions.height, + left: '50%', + marginLeft: stageDimensions.width * -0.5 + }} + > + {micIndicator ? ( + <MicIndicator + className={styles.micIndicator} + stageSize={stageDimensions} + /> + ) : null} + {question === null ? null : ( <div className={styles.questionWrapper} style={{width: stageDimensions.width}} @@ -82,8 +93,8 @@ const StageComponent = props => { onQuestionAnswered={onQuestionAnswered} /> </div> - </div> - )} + )} + </div> <canvas className={styles.draggingSprite} height={0} diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 0aa3f2a4b64ef883e29cf0b47ff0a4f1d8d93cd3..dc2b87043956be682e4dcb68dc4e6a9657f9bcf3 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -75,7 +75,8 @@ class Stage extends React.Component { this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo || this.props.isFullScreen !== nextProps.isFullScreen || - this.state.question !== nextState.question; + this.state.question !== nextState.question || + this.props.micIndicator !== nextProps.micIndicator; } componentDidUpdate (prevProps) { if (this.props.isColorPicking && !prevProps.isColorPicking) { @@ -402,6 +403,7 @@ Stage.defaultProps = { const mapStateToProps = state => ({ isColorPicking: state.scratchGui.colorPicker.active, isFullScreen: state.scratchGui.mode.isFullScreen, + micIndicator: state.scratchGui.micIndicator, // Do not use editor drag style in fullscreen or player mode. useEditorDragStyle: !(state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isPlayerOnly) }); diff --git a/src/css/z-index.css b/src/css/z-index.css index 87cc2cd01a70bc8cc35a88f6019b6744040a26e3..06e2015658b563f88c84a901a35f36cad91c8fcb 100644 --- a/src/css/z-index.css +++ b/src/css/z-index.css @@ -8,6 +8,7 @@ $z-index-extension-button: 50; /* Force extension button above the ScratchBlocks $z-index-menu-bar: 50; /* blocklyToolboxDiv is 40 */ $z-index-monitor: 100; +$z-index-stage-indicator: 110; $z-index-add-button: 120; $z-index-tooltip: 130; /* tooltips should go over add buttons if they overlap */ diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js index afef90d84c845119d881c11eef4e27c4f3a0b2ae..d612017841291b990e3fb1fa6af539f99afee1f5 100644 --- a/src/lib/screen-utils.js +++ b/src/lib/screen-utils.js @@ -72,7 +72,29 @@ const getStageDimensions = (stageSize, isFullScreen) => { return stageDimensions; }; +/** + * Take a pair of sizes for the stage (a target height and width and a default height and width), + * calculate the ratio between them, and return a CSS transform to scale to that ratio. + * @param {object} sizeInfo An object containing dimensions of the target and default stage sizes. + * @param {number} sizeInfo.width The target width + * @param {number} sizeInfo.height The target height + * @param {number} sizeInfo.widthDefault The default width + * @param {number} sizeInfo.heightDefault The default height + * @returns {object} the CSS transform + */ +const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => { + const scaleX = width / widthDefault; + const scaleY = height / heightDefault; + if (scaleX === 1 && scaleY === 1) { + // Do not set a transform if the scale is 1 because + // it messes up `position: fixed` elements like the context menu. + return; + } + return {transform: `scale(${scaleX},${scaleY})`}; +}; + export { getStageDimensions, - resolveStageSize + resolveStageSize, + stageSizeToTransform }; diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx index 68b2753ab4c153c83f76ed79d3246b126b4fe969..6b8d1777792c8e68790a6f5b5d0859e21952088c 100644 --- a/src/lib/vm-listener-hoc.jsx +++ b/src/lib/vm-listener-hoc.jsx @@ -10,6 +10,7 @@ import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; import {setRunningState, setTurboState} from '../reducers/vm-status'; import {showAlert} from '../reducers/alerts'; +import {updateMicIndicator} from '../reducers/mic-indicator'; /* * Higher Order Component to manage events emitted by the VM @@ -38,6 +39,8 @@ const vmListenerHOC = function (WrappedComponent) { this.props.vm.on('PROJECT_RUN_START', this.props.onProjectRunStart); this.props.vm.on('PROJECT_RUN_STOP', this.props.onProjectRunStop); this.props.vm.on('PERIPHERAL_ERROR', this.props.onShowAlert); + this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate); + } componentDidMount () { if (this.props.attachKeyboardEvents) { @@ -89,6 +92,7 @@ const vmListenerHOC = function (WrappedComponent) { onBlockDragUpdate, onKeyDown, onKeyUp, + onMicListeningUpdate, onMonitorsUpdate, onTargetsUpdate, onProjectRunStart, @@ -107,6 +111,7 @@ const vmListenerHOC = function (WrappedComponent) { onBlockDragUpdate: PropTypes.func.isRequired, onKeyDown: PropTypes.func, onKeyUp: PropTypes.func, + onMicListeningUpdate: PropTypes.func.isRequired, onMonitorsUpdate: PropTypes.func.isRequired, onProjectRunStart: PropTypes.func.isRequired, onProjectRunStop: PropTypes.func.isRequired, @@ -141,6 +146,9 @@ const vmListenerHOC = function (WrappedComponent) { onTurboModeOff: () => dispatch(setTurboState(false)), onShowAlert: data => { dispatch(showAlert(data)); + }, + onMicListeningUpdate: listening => { + dispatch(updateMicIndicator(listening)); } }); return connect( diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 4d6cea9c0ea151d29bf92945ae10d61e59c8dbf8..bce112a1c327c6e6a2761cbbeb6e63f29dbf87da 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -8,6 +8,7 @@ import blockDragReducer, {blockDragInitialState} from './block-drag'; import editorTabReducer, {editorTabInitialState} from './editor-tab'; import hoveredTargetReducer, {hoveredTargetInitialState} from './hovered-target'; import menuReducer, {menuInitialState} from './menus'; +import micIndicatorReducer, {micIndicatorInitialState} from './mic-indicator'; import modalReducer, {modalsInitialState} from './modals'; import modeReducer, {modeInitialState} from './mode'; import monitorReducer, {monitorsInitialState} from './monitors'; @@ -38,6 +39,7 @@ const guiInitialState = { hoveredTarget: hoveredTargetInitialState, stageSize: stageSizeInitialState, menus: menuInitialState, + micIndicator: micIndicatorInitialState, modals: modalsInitialState, monitors: monitorsInitialState, monitorLayout: monitorLayoutInitialState, @@ -104,6 +106,7 @@ const guiReducer = combineReducers({ hoveredTarget: hoveredTargetReducer, stageSize: stageSizeReducer, menus: menuReducer, + micIndicator: micIndicatorReducer, modals: modalReducer, monitors: monitorReducer, monitorLayout: monitorLayoutReducer, diff --git a/src/reducers/mic-indicator.js b/src/reducers/mic-indicator.js new file mode 100644 index 0000000000000000000000000000000000000000..2548abbb41e785c086036b7e18e48b18f9e3542f --- /dev/null +++ b/src/reducers/mic-indicator.js @@ -0,0 +1,26 @@ +const UPDATE = 'scratch-gui/mic-indicator/UPDATE'; + +const initialState = false; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case UPDATE: + return action.visible; + default: + return state; + } +}; + +const updateMicIndicator = function (visible) { + return { + type: UPDATE, + visible: visible + }; +}; + +export { + reducer as default, + initialState as micIndicatorInitialState, + updateMicIndicator +};