diff --git a/src/components/audio-trimmer/audio-selector.css b/src/components/audio-trimmer/audio-selector.css new file mode 100644 index 0000000000000000000000000000000000000000..f17f50e5109784f488cb04530ae82e0f3bc455b1 --- /dev/null +++ b/src/components/audio-trimmer/audio-selector.css @@ -0,0 +1,119 @@ +@import "../../css/colors.css"; + +$border-radius: 4px; +$trim-handle-width: 12px; +$trim-handle-height: 14px; +$stripe-size: 10px; +$hover-scale: 2; + +.absolute { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; + + /* Force the browser to paint separately to avoid composite cost with waveform */ + transform: translateZ(0); +} + +.trim-background { + cursor: pointer; + touch-action: none; +} + +.trim-background-mask { + border: 1px solid $motion-tertiary; + opacity: 0.5; + background: $motion-primary; +} + +.start-trim-background .trim-background-mask { + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; +} + +.end-trim-background .trim-background-mask { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; +} + +.trim-line { + position: absolute; + top: 0; + width: 0px; + height: 100%; + border: 1px solid $motion-tertiary; +} + +.playhead-container { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + overflow: hidden; +} + +.playhead { + /* + Even though playhead is just a line, it is 100% width (the width of the waveform) + so that we can use transform: translateX() using percentages. + */ + width: 100%; + border-left: 1px solid $motion-primary; + border-top: none; + border-bottom: none; + border-right: none; +} + +.start-trim-line { + left: 0; +} + +.end-trim-line { + right: 0; +} + +.trim-handle { + position: absolute; + left: calc(-$trim-handle-width / 2); + width: $trim-handle-width; + height: $trim-handle-height; + filter: hue-rotate(180deg); +} + +.trim-handle img { + position: absolute; + width: $trim-handle-width; + height: $trim-handle-height; + + /* Make sure image dragging isn't triggered */ + user-select: none; + user-drag: none; + -webkit-user-drag: none; /* Autoprefixer doesn't seem to work for this */ + + transition: 0.2s; +} + +.top-trim-handle { + top: -$trim-handle-height; +} + +.bottom-trim-handle { + bottom: -$trim-handle-height; +} + +.top-trim-handle img { + transform: rotate(180deg); +} + +/* Increase handle size when anywhere on draggable area is hovered */ +.trim-background:hover img { + transform: scale($hover-scale); +} + +.trim-background:hover .top-trim-handle img { + transform: rotate(180deg) scale($hover-scale); +} diff --git a/src/components/audio-trimmer/audio-selector.jsx b/src/components/audio-trimmer/audio-selector.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1243edf113f2026924482f63d06145c93bb28e8a --- /dev/null +++ b/src/components/audio-trimmer/audio-selector.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import Box from '../box/box.jsx'; +import styles from './audio-selector.css'; +import handleIcon from './icon--handle.svg'; + +const AudioSelector = props => ( + <div + className={styles.absolute} + ref={props.containerRef} + onMouseDown={props.onNewSelectionMouseDown} + onTouchStart={props.onNewSelectionMouseDown} + > + {props.trimStart === null ? null : ( + <Box + className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)} + style={{ + left: `${props.trimStart * 100}%`, + width: `${100 * (props.trimEnd - props.trimStart)}%` + }} + > + <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> + <Box + className={classNames(styles.trimLine, styles.startTrimLine)} + onMouseDown={props.onTrimStartMouseDown} + onTouchStart={props.onTrimStartMouseDown} + > + <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.startTrimHandle)}> + <img src={handleIcon} /> + </Box> + <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.startTrimHandle)}> + <img src={handleIcon} /> + </Box> + </Box> + <Box + className={classNames(styles.trimLine, styles.endTrimLine)} + onMouseDown={props.onTrimEndMouseDown} + onTouchStart={props.onTrimEndMouseDown} + > + <Box className={classNames(styles.trimHandle, styles.topTrimHandle, styles.endTrimHandle)}> + <img src={handleIcon} /> + </Box> + <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle, styles.endTrimHandle)}> + <img src={handleIcon} /> + </Box> + </Box> + </Box> + )} + {props.playhead ? ( + <div className={styles.playheadContainer}> + <div + className={classNames(styles.trimLine, styles.playhead)} + style={{ + transform: `translateX(${100 * props.playhead}%)` + }} + /> + </div> + ) : null} + </div> +); + +AudioSelector.propTypes = { + containerRef: PropTypes.func, + onNewSelectionMouseDown: PropTypes.func.isRequired, + onTrimEndMouseDown: PropTypes.func.isRequired, + onTrimStartMouseDown: PropTypes.func.isRequired, + playhead: PropTypes.number, + trimEnd: PropTypes.number, + trimStart: PropTypes.number +}; + +export default AudioSelector; diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index f97338e06a55bf75e0bceda2ddc33d8a9be1070b..ce25dc50b2ed84485e423a22f724a28e3c53bcc7 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -8,15 +8,13 @@ import Label from '../forms/label.jsx'; import Input from '../forms/input.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; -import AudioTrimmer from '../../containers/audio-trimmer.jsx'; +import AudioSelector from '../../containers/audio-selector.jsx'; import IconButton from '../icon-button/icon-button.jsx'; import styles from './sound-editor.css'; import playIcon from '../record-modal/icon--play.svg'; import stopIcon from '../record-modal/icon--stop-playback.svg'; -import trimIcon from './icon--trim.svg'; -import trimConfirmIcon from './icon--trim-confirm.svg'; import redoIcon from './icon--redo.svg'; import undoIcon from './icon--undo.svg'; import echoIcon from './icon--echo.svg'; @@ -103,7 +101,11 @@ const messages = defineMessages({ }); const SoundEditor = props => ( - <div className={styles.editorContainer}> + <div + className={styles.editorContainer} + ref={props.setRef} + onMouseDown={props.onContainerClick} + > <div className={styles.row}> <div className={styles.inputGroup}> <Label text={props.intl.formatMessage(messages.sound)}> @@ -161,12 +163,13 @@ const SoundEditor = props => ( height={160} width={600} /> - <AudioTrimmer + <AudioSelector playhead={props.playhead} trimEnd={props.trimEnd} trimStart={props.trimStart} - onSetTrimEnd={props.onSetTrimEnd} - onSetTrimStart={props.onSetTrimStart} + onPlay={props.onPlay} + onSetTrim={props.onSetTrim} + onStop={props.onStop} /> </div> </div> @@ -248,8 +251,8 @@ SoundEditor.propTypes = { chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, intl: intlShape, name: PropTypes.string.isRequired, - onActivateTrim: PropTypes.func, onChangeName: PropTypes.func.isRequired, + onContainerClick: PropTypes.func.isRequired, onEcho: PropTypes.func.isRequired, onFaster: PropTypes.func.isRequired, onLouder: PropTypes.func.isRequired, @@ -257,13 +260,13 @@ SoundEditor.propTypes = { onRedo: PropTypes.func.isRequired, onReverse: PropTypes.func.isRequired, onRobot: PropTypes.func.isRequired, - onSetTrimEnd: PropTypes.func, - onSetTrimStart: PropTypes.func, + onSetTrim: PropTypes.func, onSlower: PropTypes.func.isRequired, onSofter: PropTypes.func.isRequired, onStop: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired, playhead: PropTypes.number, + setRef: PropTypes.func, trimEnd: PropTypes.number, trimStart: PropTypes.number }; diff --git a/src/containers/audio-selector.jsx b/src/containers/audio-selector.jsx new file mode 100644 index 0000000000000000000000000000000000000000..30ef003a96b1edbd9e25cca848e8ad2b5985b25c --- /dev/null +++ b/src/containers/audio-selector.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; +import AudioSelectorComponent from '../components/audio-trimmer/audio-selector.jsx'; +import {getEventXY} from '../lib/touch-utils'; + +const MIN_LENGTH = 0.01; +const MIN_DURATION = 500; + +class AudioSelector extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleNewSelectionMouseDown', + 'handleTrimStartMouseDown', + 'handleTrimEndMouseDown', + 'handleTrimStartMouseMove', + 'handleTrimEndMouseMove', + 'handleTrimStartMouseUp', + 'handleTrimEndMouseUp', + 'storeRef' + ]); + + this.state = { + trimStart: props.trimStart, + trimEnd: props.trimEnd + }; + + this.clickStartTime = 0; + } + componentWillReceiveProps (newProps) { + if (newProps.trimStart === this.props.trimStart) return; + this.setState({ + trimStart: newProps.trimStart, + trimEnd: newProps.trimEnd + }); + } + clearSelection () { + this.props.onSetTrim(null, null); + } + handleNewSelectionMouseDown (e) { + this.initialX = getEventXY(e).x; + const {width, left} = this.containerElement.getBoundingClientRect(); + this.initialTrimEnd = (this.initialX - left) / width; + this.initialTrimStart = this.initialTrimEnd; + this.props.onSetTrim(this.initialTrimStart, this.initialTrimEnd); + + this.clickStartTime = Date.now(); + + window.addEventListener('mousemove', this.handleTrimEndMouseMove); + window.addEventListener('mouseup', this.handleTrimEndMouseUp); + window.addEventListener('touchmove', this.handleTrimEndMouseMove); + window.addEventListener('touchend', this.handleTrimEndMouseUp); + } + handleTrimStartMouseMove (e) { + const containerSize = this.containerElement.getBoundingClientRect().width; + const dx = (getEventXY(e).x - this.initialX) / containerSize; + const newTrim = Math.max(0, Math.min(1, this.initialTrimStart + dx)); + if (newTrim > this.initialTrimEnd) { + this.setState({ + trimStart: this.initialTrimEnd, + trimEnd: newTrim + }); + } else { + this.setState({ + trimStart: newTrim + }); + } + e.preventDefault(); + } + handleTrimEndMouseMove (e) { + const containerSize = this.containerElement.getBoundingClientRect().width; + const dx = (getEventXY(e).x - this.initialX) / containerSize; + const newTrim = Math.min(1, Math.max(0, this.initialTrimEnd + dx)); + if (newTrim < this.initialTrimStart) { + this.setState({ + trimStart: newTrim, + trimEnd: this.initialTrimStart + }); + } else { + this.setState({ + trimEnd: newTrim + }); + } + e.preventDefault(); + } + handleTrimStartMouseUp () { + window.removeEventListener('mousemove', this.handleTrimStartMouseMove); + window.removeEventListener('mouseup', this.handleTrimStartMouseUp); + window.removeEventListener('touchmove', this.handleTrimStartMouseMove); + window.removeEventListener('touchend', this.handleTrimStartMouseUp); + this.props.onSetTrim(this.state.trimStart, this.state.trimEnd); + } + handleTrimEndMouseUp () { + window.removeEventListener('mousemove', this.handleTrimEndMouseMove); + window.removeEventListener('mouseup', this.handleTrimEndMouseUp); + window.removeEventListener('touchmove', this.handleTrimEndMouseMove); + window.removeEventListener('touchend', this.handleTrimEndMouseUp); + // If the selection was made quickly (tooFast) and is small (tooShort), + // deselect instead. This allows click-to-deselect even if you drag + // a little bit by accident. It also allows very quickly making a + // selection, as long as it is above a minimum length. + const tooFast = (Date.now() - this.clickStartTime) < MIN_DURATION; + const tooShort = (this.state.trimEnd - this.state.trimStart) < MIN_LENGTH; + if (tooFast && tooShort) { + this.clearSelection(); + } else { + this.props.onSetTrim(this.state.trimStart, this.state.trimEnd); + } + } + handleTrimStartMouseDown (e) { + this.initialX = getEventXY(e).x; + this.initialTrimStart = this.props.trimStart; + this.initialTrimEnd = this.props.trimEnd; + window.addEventListener('mousemove', this.handleTrimStartMouseMove); + window.addEventListener('mouseup', this.handleTrimStartMouseUp); + window.addEventListener('touchmove', this.handleTrimStartMouseMove); + window.addEventListener('touchend', this.handleTrimStartMouseUp); + e.stopPropagation(); + } + handleTrimEndMouseDown (e) { + this.initialX = getEventXY(e).x; + this.initialTrimEnd = this.props.trimEnd; + this.initialTrimStart = this.props.trimStart; + window.addEventListener('mousemove', this.handleTrimEndMouseMove); + window.addEventListener('mouseup', this.handleTrimEndMouseUp); + window.addEventListener('touchmove', this.handleTrimEndMouseMove); + window.addEventListener('touchend', this.handleTrimEndMouseUp); + e.stopPropagation(); + } + storeRef (el) { + this.containerElement = el; + } + render () { + return ( + <AudioSelectorComponent + containerRef={this.storeRef} + playhead={this.props.playhead} + trimEnd={this.state.trimEnd} + trimStart={this.state.trimStart} + onNewSelectionMouseDown={this.handleNewSelectionMouseDown} + onTrimEndMouseDown={this.handleTrimEndMouseDown} + onTrimStartMouseDown={this.handleTrimStartMouseDown} + /> + ); + } +} + +AudioSelector.propTypes = { + onSetTrim: PropTypes.func, + playhead: PropTypes.number, + trimEnd: PropTypes.number, + trimStart: PropTypes.number +}; + +export default AudioSelector; diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index a3cdaaf674fee46aeddcaaac66fcfbf168deb58a..d3c58f811f4ac816f645f30948e49969265c0786 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -23,13 +23,14 @@ class SoundEditor extends React.Component { 'handlePlay', 'handleStopPlaying', 'handleUpdatePlayhead', - 'handleActivateTrim', - 'handleUpdateTrimEnd', - 'handleUpdateTrimStart', + 'handleDelete', + 'handleUpdateTrim', 'handleEffect', 'handleUndo', 'handleRedo', 'submitNewSamples' + 'handleContainerClick', + 'setRef' ]); this.state = { chunkLevels: computeChunkedRMS(this.props.samples), @@ -40,6 +41,8 @@ class SoundEditor extends React.Component { this.redoStack = []; this.undoStack = []; + + this.ref = null; } componentDidMount () { this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate); @@ -49,6 +52,10 @@ class SoundEditor extends React.Component { this.redoStack = []; this.undoStack = []; this.resetState(newProps.samples, newProps.sampleRate); + this.setState({ + trimStart: null, + trimEnd: null + }); } } componentWillUnmount () { @@ -59,9 +66,7 @@ class SoundEditor extends React.Component { this.audioBufferPlayer = new AudioBufferPlayer(samples, sampleRate); this.setState({ chunkLevels: computeChunkedRMS(samples), - playhead: null, - trimStart: null, - trimEnd: null + playhead: null }); } submitNewSamples (samples, sampleRate, skipUndo) { @@ -106,6 +111,7 @@ class SoundEditor extends React.Component { return false; // Update failed } handlePlay () { + this.audioBufferPlayer.stop(); this.audioBufferPlayer.play( this.state.trimStart || 0, this.state.trimEnd || 1, @@ -125,31 +131,36 @@ class SoundEditor extends React.Component { handleChangeName (name) { this.props.vm.renameSound(this.props.soundIndex, name); } - handleActivateTrim () { - if (this.state.trimStart === null && this.state.trimEnd === null) { - this.setState({trimEnd: 0.95, trimStart: 0.05}); + handleDelete () { + const {samples, sampleRate} = this.copyCurrentBuffer(); + const sampleCount = samples.length; + const startIndex = Math.floor(this.state.trimStart * sampleCount); + const endIndex = Math.floor(this.state.trimEnd * sampleCount); + const firstPart = samples.slice(0, startIndex); + const secondPart = samples.slice(endIndex, sampleCount); + const newLength = firstPart.length + secondPart.length; + let newSamples; + if (newLength === 0) { + newSamples = new Float32Array(1); } else { - const {samples, sampleRate} = this.copyCurrentBuffer(); - const sampleCount = samples.length; - const startIndex = Math.floor(this.state.trimStart * sampleCount); - const endIndex = Math.floor(this.state.trimEnd * sampleCount); - if (endIndex > startIndex) { // Strictly greater to prevent 0 sample sounds - const clippedSamples = samples.slice(startIndex, endIndex); - this.submitNewSamples(clippedSamples, sampleRate); - } else { - // Just clear the trim state, it cannot be completed - this.setState({ - trimStart: null, - trimEnd: null - }); - } + newSamples = new Float32Array(newLength); + newSamples.set(firstPart, 0); + newSamples.set(secondPart, firstPart.length); } + this.submitNewSamples(newSamples, sampleRate); + this.setState({ + trimStart: null, + trimEnd: null + }); } handleUpdateTrimEnd (trimEnd) { this.setState({trimEnd}); } handleUpdateTrimStart (trimStart) { this.setState({trimStart}); + handleUpdateTrim (trimStart, trimEnd) { + this.setState({trimStart, trimEnd}); + this.handleStopPlaying(); } effectFactory (name) { return () => this.handleEffect(name); @@ -184,6 +195,13 @@ class SoundEditor extends React.Component { this.undoStack.push(this.copyCurrentBuffer()); this.submitNewSamples(samples, sampleRate, true); this.handlePlay(); + setRef (element) { + this.ref = element; + } + handleContainerClick (e) { + // If the click is on the sound editor's div (and not any other element), delesect + if (e.target === this.ref) { + this.handleUpdateTrim(null, null); } } render () { @@ -195,10 +213,12 @@ class SoundEditor extends React.Component { chunkLevels={this.state.chunkLevels} name={this.props.name} playhead={this.state.playhead} + setRef={this.setRef} trimEnd={this.state.trimEnd} trimStart={this.state.trimStart} - onActivateTrim={this.handleActivateTrim} onChangeName={this.handleChangeName} + onContainerClick={this.handleContainerClick} + onDelete={this.handleDelete} onEcho={this.effectFactory(effectTypes.ECHO)} onFaster={this.effectFactory(effectTypes.FASTER)} onLouder={this.effectFactory(effectTypes.LOUDER)} @@ -206,8 +226,7 @@ class SoundEditor extends React.Component { onRedo={this.handleRedo} onReverse={this.effectFactory(effectTypes.REVERSE)} onRobot={this.effectFactory(effectTypes.ROBOT)} - onSetTrimEnd={this.handleUpdateTrimEnd} - onSetTrimStart={this.handleUpdateTrimStart} + onSetTrim={this.handleUpdateTrim} onSlower={this.effectFactory(effectTypes.SLOWER)} onSofter={this.effectFactory(effectTypes.SOFTER)} onStop={this.handleStopPlaying}