Selection and delete

This commit is contained in:
Eric Rosenbaum committed 2019-07-09 15:32:29 -04:00
1 parent 0d88c7755b
commit 64562a8a05
5 files changed
+406 -36

No files matched your search

@@ -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);
}
@@ -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;
+13 -10
View File
@@ -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
};
+156
View File
@@ -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;
+45 -26
View File
@@ -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}