diff --git a/src/components/camera-modal/camera-modal.css b/src/components/camera-modal/camera-modal.css new file mode 100644 index 0000000000000000000000000000000000000000..5f631e41bf5ad9b22675065153f683534c896a7b --- /dev/null +++ b/src/components/camera-modal/camera-modal.css @@ -0,0 +1,153 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +$main-button-size: 2.75rem; + +.modal-content { + width: 552px; +} + +.body { + display: flex; + flex-direction: column; + align-items: center; + background: $ui-white; + padding: 1.5rem 2.25rem; +} + +.camera-feed-container { + display: flex; + justify-content: space-around; + align-items: center; + + background: $ui-primary; + border: 1px solid $ui-black-transparent; + border-radius: 4px; + padding: 3px; + + width: 480px; + height: 360px; + position: relative; + overflow: hidden; +} + +.canvas { + position: absolute; + width: 480px; + height: 360px; +} + +.loading-text { + position: absolute; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: $text-primary-transparent; + font-size: 0.95rem; + font-weight: 500; + text-align: center; +} + +.help-text { + margin: 10px auto 0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: $text-primary-transparent; + font-size: 0.95rem; + font-weight: 500; + text-align: center; +} + +.capture-text { + color: $motion-primary; +} + +.disabled-text { + color: $text-primary; + opacity: 0.25; +} + +.main-button-row { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + margin-top: 15px; + width: 100%; +} + +/* Action Menu */ +.main-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + background: $motion-primary; + outline: none; + border: none; + transition: background-color 0.2s; + + border-radius: 100%; + width: $main-button-size; + height: $main-button-size; + box-shadow: 0 0 0 4px $motion-transparent; +} + +.main-button:hover { + background: $pen-primary; + box-shadow: 0 0 0 6px $motion-transparent; +} + +.main-button:disabled { + background: $text-primary; + border-color: $ui-black-transparent; + box-shadow: none; + opacity: 0.25; +} + +.main-icon { + width: calc($main-button-size - 1rem); + height: calc($main-button-size - 1rem); +} + +.button-row { + font-weight: bolder; + text-align: right; + display: flex; + justify-content: space-between; + margin-top: 20px; + width: 480px; +} + +.button-row button { + padding: 0.75rem 1rem; + border-radius: 0.25rem; + background: $ui-white; + border: 1px solid $ui-black-transparent; + font-weight: 600; + font-size: 0.85rem; + color: $motion-primary; + cursor: pointer; +} + +.button-row button.ok-button { + background: $motion-primary; + border: $motion-primary; + color: $ui-white; +} + +@keyframes flash { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.flash-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: $ui-white; + animation-name: flash; + animation-duration: 0.5s; + animation-fill-mode: forwards; /* Leave at 0 opacity after animation */ +} diff --git a/src/components/camera-modal/camera-modal.jsx b/src/components/camera-modal/camera-modal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f115a6f8a75f1c77286f15365137509313a8783f --- /dev/null +++ b/src/components/camera-modal/camera-modal.jsx @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {defineMessages, injectIntl, intlShape} from 'react-intl'; +import Box from '../box/box.jsx'; +import Modal from '../modal/modal.jsx'; +import styles from './camera-modal.css'; +import backIcon from './icon--back.svg'; +import cameraIcon from '../action-menu/icon--camera.svg'; + +const messages = defineMessages({ + cameraModalTitle: { + defaultMessage: 'Take a Photo', + description: 'Title for prompt to take a picture (to add as a new costume).', + id: 'gui.cameraModal.cameraModalTitle' + }, + loadingCameraMessage: { + defaultMessage: 'Loading Camera...', + description: 'Notification to the user that the camera is loading', + id: 'gui.cameraModal.loadingCameraMessage' + }, + permissionRequest: { + defaultMessage: 'We need your permission to use your camera', + description: 'Notification to the user that the app needs camera access', + id: 'gui.cameraModal.permissionRequest' + }, + retakePhoto: { + defaultMessage: 'Retake Photo', + description: 'A button that allows the user to take the picture again, replacing the old one', + id: 'gui.cameraModal.retakePhoto' + }, + save: { + defaultMessage: 'Save', + description: 'A button that allows the user to save the photo they took as a costume', + id: 'gui.cameraModal.save' + }, + takePhotoButton: { + defaultMessage: 'Take Photo', + description: 'A button to take a photo', + id: 'gui.cameraModal.takePhoto' + }, + loadingCaption: { + defaultMessage: 'Loading...', + description: 'A caption for a disabled button while the video from the camera is still loading', + id: 'gui.cameraModal.loadingCaption' + }, + enableCameraCaption: { + defaultMessage: 'Enable Camera', + description: 'A caption for a disabled button prompting the user to enable camera access', + id: 'gui.cameraModal.enableCameraCaption' + } +}); + +const CameraModal = ({intl, ...props}) => ( + <Modal + className={styles.modalContent} + contentLabel={intl.formatMessage(messages.cameraModalTitle)} + onRequestClose={props.onCancel} + > + <Box className={styles.body}> + <Box className={styles.cameraFeedContainer}> + <div className={styles.loadingText}> + {props.access ? intl.formatMessage(messages.loadingCameraMessage) : + `â†–ï¸ \u00A0${intl.formatMessage(messages.permissionRequest)}`} + </div> + <canvas + className={styles.canvas} + // height and (below) width of the actual image + // double stage dimensions to avoid the need for + // resizing the captured image when importing costume + // to accommodate double resolution bitmaps + height="720" + ref={props.canvasRef} + width="960" + /> + {props.capture ? ( + <div className={styles.flashOverlay} /> + ) : null} + </Box> + {props.capture ? + <Box className={styles.buttonRow}> + <button + className={styles.cancelButton} + key="retake-button" + onClick={props.onBack} + > + <img + draggable={false} + src={backIcon} + /> {intl.formatMessage(messages.retakePhoto)} + </button> + <button + className={styles.okButton} + onClick={props.onSubmit} + > {intl.formatMessage(messages.save)} + </button> + </Box> : + <Box className={styles.mainButtonRow}> + <button + className={styles.mainButton} + disabled={!props.loaded} + key="capture-button" + onClick={props.onCapture} + > + <img + className={styles.mainIcon} + draggable={false} + src={cameraIcon} + /> + </button> + <div className={styles.helpText}> + {props.access ? + <span className={props.loaded ? styles.captureText : styles.disabledText}> + {props.loaded ? + intl.formatMessage(messages.takePhotoButton) : + intl.formatMessage(messages.loadingCaption)} + </span> : + <span className={styles.disabledText}> + {intl.formatMessage(messages.enableCameraCaption)} + </span> + } + </div> + + </Box> + } + </Box> + </Modal> +); + +CameraModal.propTypes = { + access: PropTypes.bool, + canvasRef: PropTypes.func.isRequired, + capture: PropTypes.string, + intl: intlShape.isRequired, + loaded: PropTypes.bool, + onBack: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onCapture: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired +}; + +export default injectIntl(CameraModal); diff --git a/src/components/camera-modal/icon--back.svg b/src/components/camera-modal/icon--back.svg new file mode 100644 index 0000000000000000000000000000000000000000..47d09bc7fad766b50da3ed1cffec190b1e94878c Binary files /dev/null and b/src/components/camera-modal/icon--back.svg differ diff --git a/src/containers/camera-modal.jsx b/src/containers/camera-modal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c55b0ee450797d7d72e93343e7d5df4d6a7ceeda --- /dev/null +++ b/src/containers/camera-modal.jsx @@ -0,0 +1,103 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import CameraModalComponent from '../components/camera-modal/camera-modal.jsx'; +import ModalVideoManager from '../lib/video/modal-video-manager.js'; + +import { + closeCameraCapture +} from '../reducers/modals'; + +class CameraModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleAccess', + 'handleBack', + 'handleCancel', + 'handleCapture', + 'handleLoaded', + 'handleSubmit', + 'setCanvas' + ]); + + this.video = null; + this.videoDevice = null; + + this.state = { + capture: null, + access: false, + loaded: false + }; + } + componentWillUnmount () { + if (this.videoDevice) { + this.videoDevice.disableVideo(); + } + } + handleAccess () { + this.setState({access: true}); + } + handleLoaded () { + this.setState({loaded: true}); + } + handleBack () { + this.setState({capture: null}); + this.videoDevice.clearSnapshot(); + } + handleCapture () { + if (this.state.loaded) { + const capture = this.videoDevice.takeSnapshot(); + this.setState({capture: capture}); + } + } + setCanvas (canvas) { + this.canvas = canvas; + if (this.canvas) { + this.videoDevice = new ModalVideoManager(this.canvas); + this.videoDevice.enableVideo(this.handleAccess, this.handleLoaded); + } + } + handleSubmit () { + if (!this.state.capture) return; + this.props.onNewCostume(this.state.capture); + this.props.onClose(); + } + handleCancel () { + this.props.onClose(); + } + render () { + return ( + <CameraModalComponent + access={this.state.access} + canvasRef={this.setCanvas} + capture={this.state.capture} + loaded={this.state.loaded} + onBack={this.handleBack} + onCancel={this.handleCancel} + onCapture={this.handleCapture} + onSubmit={this.handleSubmit} + /> + ); + } +} + +CameraModal.propTypes = { + onClose: PropTypes.func, + onNewCostume: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = dispatch => ({ + onClose: () => { + dispatch(closeCameraCapture()); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CameraModal); diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 6042ceaaac8003eb03e4594faa2a91eb5b563b77..641d77380d01d254d91b3f95ac3b38234e91dd24 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -8,12 +8,15 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import CostumeLibrary from './costume-library.jsx'; import BackdropLibrary from './backdrop-library.jsx'; +import CameraModal from './camera-modal.jsx'; import {connect} from 'react-redux'; import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; import { + closeCameraCapture, closeCostumeLibrary, closeBackdropLibrary, + openCameraCapture, openCostumeLibrary, openBackdropLibrary } from '../reducers/modals'; @@ -60,7 +63,7 @@ const messages = defineMessages({ id: 'gui.costumeTab.addFileCostume' }, addCameraCostumeMsg: { - defaultMessage: 'Coming Soon', + defaultMessage: 'Camera', description: 'Button to use the camera to create a costume costume in the editor tab', id: 'gui.costumeTab.addCameraCostume' } @@ -79,6 +82,7 @@ class CostumeTab extends React.Component { 'handleSurpriseBackdrop', 'handleFileUploadClick', 'handleCostumeUpload', + 'handleCameraBuffer', 'setFileInput' ]); const { @@ -180,6 +184,10 @@ class CostumeTab extends React.Component { costumeUpload(buffer, fileType, fileName, storage, this.handleNewCostume); }); } + handleCameraBuffer (buffer) { + const storage = this.props.vm.runtime.storage; + costumeUpload(buffer, 'image/png', 'costume1', storage, this.handleNewCostume); + } handleFileUploadClick () { this.fileInput.click(); } @@ -194,11 +202,14 @@ class CostumeTab extends React.Component { render () { const { intl, + onNewCostumeFromCameraClick, onNewLibraryBackdropClick, onNewLibraryCostumeClick, backdropLibraryVisible, + cameraModalVisible, costumeLibraryVisible, onRequestCloseBackdropLibrary, + onRequestCloseCameraModal, onRequestCloseCostumeLibrary, editingTarget, sprites, @@ -234,13 +245,14 @@ class CostumeTab extends React.Component { }, { title: intl.formatMessage(messages.addCameraCostumeMsg), - img: cameraIcon + img: cameraIcon, + onClick: onNewCostumeFromCameraClick }, { title: intl.formatMessage(addFileMessage), img: fileUploadIcon, onClick: this.handleFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg', // coming soon + fileAccept: '.svg, .png, .jpg, .jpeg', fileChange: this.handleCostumeUpload, fileInput: this.setFileInput }, @@ -280,6 +292,12 @@ class CostumeTab extends React.Component { onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} + {cameraModalVisible ? ( + <CameraModal + onClose={onRequestCloseCameraModal} + onNewCostume={this.handleCameraBuffer} + /> + ) : null} </AssetPanel> ); } @@ -287,12 +305,15 @@ class CostumeTab extends React.Component { CostumeTab.propTypes = { backdropLibraryVisible: PropTypes.bool, + cameraModalVisible: PropTypes.bool, costumeLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, intl: intlShape, + onNewCostumeFromCameraClick: PropTypes.func.isRequired, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, onRequestCloseBackdropLibrary: PropTypes.func.isRequired, + onRequestCloseCameraModal: PropTypes.func.isRequired, onRequestCloseCostumeLibrary: PropTypes.func.isRequired, sprites: PropTypes.shape({ id: PropTypes.shape({ @@ -315,6 +336,7 @@ const mapStateToProps = state => ({ editingTarget: state.targets.editingTarget, sprites: state.targets.sprites, stage: state.targets.stage, + cameraModalVisible: state.modals.cameraCapture, costumeLibraryVisible: state.modals.costumeLibrary, backdropLibraryVisible: state.modals.backdropLibrary }); @@ -328,11 +350,17 @@ const mapDispatchToProps = dispatch => ({ e.preventDefault(); dispatch(openCostumeLibrary()); }, + onNewCostumeFromCameraClick: () => { + dispatch(openCameraCapture()); + }, onRequestCloseBackdropLibrary: () => { dispatch(closeBackdropLibrary()); }, onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); + }, + onRequestCloseCameraModal: () => { + dispatch(closeCameraCapture()); } }); diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js index 040a026b258ab50083a740270aceab82d929bcf3..ca4ce8773c113b92ef9fcfb460daeda609242482 100644 --- a/src/lib/file-uploader.js +++ b/src/lib/file-uploader.js @@ -77,7 +77,8 @@ const cacheAsset = function (storage, fileName, assetType, dataFormat, data) { /** * Handles loading a costume or a backdrop using the provided, context-relevant information. - * @param {ArrayBuffer} fileData The costume data to load + * @param {ArrayBuffer | string} fileData The costume data to load (this can be an image url + * iff the image is a bitmap) * @param {string} fileType The MIME type of this file * @param {string} costumeName The user-readable name to use for the costume. * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data diff --git a/src/reducers/modals.js b/src/reducers/modals.js index 481c44f29e520c379ef911be714cbc2690c43e0d..c373ce79610eb53010f5dc45b22ec3f6f3fc0390 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -4,6 +4,7 @@ const OPEN_MODAL = 'scratch-gui/modals/OPEN_MODAL'; const CLOSE_MODAL = 'scratch-gui/modals/CLOSE_MODAL'; const MODAL_BACKDROP_LIBRARY = 'backdropLibrary'; +const MODAL_CAMERA_CAPTURE = 'cameraCapture'; const MODAL_COSTUME_LIBRARY = 'costumeLibrary'; const MODAL_EXTENSION_LIBRARY = 'extensionLibrary'; const MODAL_IMPORT_INFO = 'importInfo'; @@ -18,6 +19,7 @@ const MODAL_TIPS_LIBRARY = 'tipsLibrary'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, + [MODAL_CAMERA_CAPTURE]: false, [MODAL_COSTUME_LIBRARY]: false, [MODAL_EXTENSION_LIBRARY]: false, [MODAL_IMPORT_INFO]: false, @@ -60,6 +62,10 @@ const openBackdropLibrary = function () { analytics.pageview('/libraries/backdrops'); return openModal(MODAL_BACKDROP_LIBRARY); }; +const openCameraCapture = function () { + analytics.pageview('/modals/camera'); + return openModal(MODAL_CAMERA_CAPTURE); +}; const openCostumeLibrary = function () { analytics.pageview('/libraries/costumes'); return openModal(MODAL_COSTUME_LIBRARY); @@ -99,6 +105,9 @@ const openTipsLibrary = function () { const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; +const closeCameraCapture = function () { + return closeModal(MODAL_CAMERA_CAPTURE); +}; const closeCostumeLibrary = function () { return closeModal(MODAL_COSTUME_LIBRARY); }; @@ -129,6 +138,7 @@ const closeTipsLibrary = function () { export { reducer as default, openBackdropLibrary, + openCameraCapture, openCostumeLibrary, openExtensionLibrary, openImportInfo, @@ -139,6 +149,7 @@ export { openSoundRecorder, openTipsLibrary, closeBackdropLibrary, + closeCameraCapture, closeCostumeLibrary, closeExtensionLibrary, closeImportInfo,