From 0a489ea2ea23d841bc2a94231fa318752ba7a38f Mon Sep 17 00:00:00 2001 From: Eric Rosenbaum Date: Tue, 9 Jul 2019 14:41:52 -0400 Subject: [PATCH] Add copy, paste, copy to new and delete buttons --- .../sound-editor/icon--copy-to-new.svg | 18 +++ src/components/sound-editor/icon--copy.svg | 32 ++++ src/components/sound-editor/icon--delete.svg | 18 +++ src/components/sound-editor/icon--lounder.svg | 17 --- src/components/sound-editor/icon--paste.svg | 18 +++ src/components/sound-editor/sound-editor.css | 19 ++- src/components/sound-editor/sound-editor.jsx | 46 ++++-- src/containers/sound-editor.jsx | 137 +++++++++++++++++- 8 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 src/components/sound-editor/icon--copy-to-new.svg create mode 100644 src/components/sound-editor/icon--copy.svg create mode 100644 src/components/sound-editor/icon--delete.svg delete mode 100644 src/components/sound-editor/icon--lounder.svg create mode 100644 src/components/sound-editor/icon--paste.svg diff --git a/src/components/sound-editor/icon--copy-to-new.svg b/src/components/sound-editor/icon--copy-to-new.svg new file mode 100644 index 000000000..8bfc76ac5 --- /dev/null +++ b/src/components/sound-editor/icon--copy-to-new.svg @@ -0,0 +1,18 @@ + + + + Sound/General/Copy to New + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/sound-editor/icon--copy.svg b/src/components/sound-editor/icon--copy.svg new file mode 100644 index 000000000..4b902ad12 --- /dev/null +++ b/src/components/sound-editor/icon--copy.svg @@ -0,0 +1,32 @@ + + + + Sound/General/Copy + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/sound-editor/icon--delete.svg b/src/components/sound-editor/icon--delete.svg new file mode 100644 index 000000000..8f662201e --- /dev/null +++ b/src/components/sound-editor/icon--delete.svg @@ -0,0 +1,18 @@ + + + + Sound/General/Trim + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/sound-editor/icon--lounder.svg b/src/components/sound-editor/icon--lounder.svg deleted file mode 100644 index a1022b4b8..000000000 --- a/src/components/sound-editor/icon--lounder.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - icon--lounder - Created with Sketch. - - - - - - - - - - - - \ No newline at end of file diff --git a/src/components/sound-editor/icon--paste.svg b/src/components/sound-editor/icon--paste.svg new file mode 100644 index 000000000..5d8bd9063 --- /dev/null +++ b/src/components/sound-editor/icon--paste.svg @@ -0,0 +1,18 @@ + + + + Sound/General/Paste + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index 279875fc8..406a26430 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -118,6 +118,7 @@ $border-radius: 0.25rem; color: $text-primary; font-size: 0.625rem; user-select: none; + } [dir="ltr"] .trim-button { @@ -130,7 +131,6 @@ $border-radius: 0.25rem; .trim-button > img { width: 1.25rem; - margin-bottom: -0.375rem; } .effect-button { @@ -150,6 +150,23 @@ $border-radius: 0.25rem; margin-bottom: -0.375rem; } +.tool-button { + flex-basis: 60px; + color: $text-primary; + font-size: 0.625rem; + user-select: none; +} + +.tool-button + .tool-button { + margin: 0; +} + +.tool-button img { + width: 4rem; + height: 1.25rem; + margin-bottom: -0.375rem; +} + /* mirror the louder/softer speaker icons when rtl */ [dir="rtl"] .effect-button:nth-of-type(6) img { transform: scaleX(-1); diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index cbd731f14..8306229eb 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -30,6 +30,11 @@ import fadeOutIcon from './icon--fade-out.svg'; import fadeInIcon from './icon--fade-in.svg'; import muteIcon from './icon--mute.svg'; +import deleteIcon from './icon--delete.svg'; +import copyIcon from './icon--copy.svg'; +import pasteIcon from './icon--paste.svg'; +import copyToNewIcon from './icon--copy-to-new.svg'; + const BufferedInput = BufferedInputHOC(Input); const messages = defineMessages({ @@ -163,17 +168,33 @@ const SoundEditor = props => ( +
+ + + +
- ) : ( - - )} - onClick={props.onActivateTrim} + className={styles.toolButton} + disabled={props.trimStart === null} + img={deleteIcon} + title={'Delete'} + onClick={props.onDelete} />
@@ -284,6 +305,7 @@ const SoundEditor = props => ( ); SoundEditor.propTypes = { + canPaste: PropTypes.bool.isRequired, canRedo: PropTypes.bool.isRequired, canUndo: PropTypes.bool.isRequired, chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, @@ -292,12 +314,16 @@ SoundEditor.propTypes = { onActivateTrim: PropTypes.func.isRequired, onChangeName: PropTypes.func.isRequired, onContainerClick: PropTypes.func.isRequired, + onCopy: PropTypes.func.isRequired, + onCopyToNew: PropTypes.func.isRequired, + onDelete: PropTypes.func, onEcho: PropTypes.func.isRequired, onFadeIn: PropTypes.func.isRequired, onFadeOut: PropTypes.func.isRequired, onFaster: PropTypes.func.isRequired, onLouder: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, + onPaste: PropTypes.func.isRequired, onPlay: PropTypes.func.isRequired, onRedo: PropTypes.func.isRequired, onReverse: PropTypes.func.isRequired, diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index d841b0869..253bf8cfb 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -2,6 +2,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import WavEncoder from 'wav-encoder'; +import VM from 'scratch-vm'; import {connect} from 'react-redux'; @@ -17,7 +18,9 @@ class SoundEditor extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'copy', 'copyCurrentBuffer', + 'handleCopyToNew', 'handleStoppedPlaying', 'handleChangeName', 'handlePlay', @@ -29,10 +32,14 @@ class SoundEditor extends React.Component { 'handleUndo', 'handleRedo', 'submitNewSamples', + 'handleCopy', + 'handlePaste', + 'paste', 'handleContainerClick', 'setRef' ]); this.state = { + copyBuffer: null, chunkLevels: computeChunkedRMS(this.props.samples), playhead: null, // null is not playing, [0 -> 1] is playing percent trimStart: null, @@ -213,6 +220,127 @@ class SoundEditor extends React.Component { this.setState({trimStart: trimStart, trimEnd: trimEnd}, this.handlePlay); } } + handleCopy () { + this.copy(); + } + copy (callback) { + const trimStart = this.state.trimStart === null ? 0.0 : this.state.trimStart; + const trimEnd = this.state.trimEnd === null ? 1.0 : this.state.trimEnd; + + const trimStartSamples = trimStart * this.props.samples.length; + const trimEndSamples = trimEnd * this.props.samples.length; + + const newCopyBuffer = this.copyCurrentBuffer(); + newCopyBuffer.samples = newCopyBuffer.samples.slice(trimStartSamples, trimEndSamples); + + this.setState({ + copyBuffer: newCopyBuffer + }, callback); + } + handleCopyToNew () { + this.copy(this.copyToNew); + } + copyToNew () { + WavEncoder.encode({ + sampleRate: this.state.copyBuffer.sampleRate, + channelData: [this.state.copyBuffer.samples] + }).then(wavBuffer => { + const vmSound = { + format: '', + dataFormat: 'wav', + rate: this.state.copyBuffer.sampleRate, + sampleCount: this.state.copyBuffer.samples.length + }; + + // Create an asset from the encoded .wav and get resulting md5 + const storage = this.props.vm.runtime.storage; + vmSound.asset = storage.createAsset( + storage.AssetType.Sound, + storage.DataFormat.WAV, + new Uint8Array(wavBuffer), + null, + true // generate md5 + ); + vmSound.assetId = vmSound.asset.assetId; + + // update vmSound object with md5 property + vmSound.md5 = `${vmSound.assetId}.${vmSound.dataFormat}`; + // The VM will update the sound name to a fresh name + vmSound.name = this.props.name; + + this.props.vm.addSound(vmSound); + }); + } + resampleBufferToRate (buffer, newRate) { + return new Promise(resolve => { + if (window.OfflineAudioContext) { + const sampleRateRatio = newRate / buffer.sampleRate; + const newLength = sampleRateRatio * buffer.samples.length; + const offlineContext = new window.OfflineAudioContext(1, newLength, newRate); + const source = offlineContext.createBufferSource(); + const audioBuffer = this.audioContext.createBuffer(1, buffer.samples.length, buffer.sampleRate); + audioBuffer.getChannelData(0).set(buffer.samples); + source.buffer = audioBuffer; + source.connect(offlineContext.destination); + source.start(); + offlineContext.startRendering(); + offlineContext.oncomplete = ({renderedBuffer}) => { + resolve({ + samples: renderedBuffer.getChannelData(0), + sampleRate: newRate + }); + }; + } + }); + } + paste () { + // If there's no selection, paste at the end of the sound + if (this.state.trimStart === null) { + const newLength = this.props.samples.length + this.state.copyBuffer.samples.length; + const newSamples = new Float32Array(newLength); + newSamples.set(this.props.samples, 0); + newSamples.set(this.state.copyBuffer.samples, this.props.samples.length); + this.submitNewSamples(newSamples, this.props.sampleRate, false); + } else { + // else replace the selection with the pasted sound + const trimStartSamples = this.state.trimStart * this.props.samples.length; + const trimEndSamples = this.state.trimEnd * this.props.samples.length; + const firstPart = this.props.samples.slice(0, trimStartSamples); + const lastPart = this.props.samples.slice(trimEndSamples); + const newLength = firstPart.length + this.state.copyBuffer.samples.length + lastPart.length; + const newSamples = new Float32Array(newLength); + newSamples.set(firstPart, 0); + newSamples.set(this.state.copyBuffer.samples, firstPart.length); + newSamples.set(lastPart, firstPart.length + this.state.copyBuffer.samples.length); + + const trimStartSeconds = trimStartSamples / this.props.sampleRate; + const trimEndSeconds = trimStartSeconds + + (this.state.copyBuffer.samples.length / this.state.copyBuffer.sampleRate); + const newDurationSeconds = newSamples.length / this.state.copyBuffer.sampleRate; + const adjustedTrimStart = trimStartSeconds / newDurationSeconds; + const adjustedTrimEnd = trimEndSeconds / newDurationSeconds; + this.setState({ + trimStart: adjustedTrimStart, + trimEnd: adjustedTrimEnd + }); + + this.submitNewSamples(newSamples, this.props.sampleRate, false); + } + + this.handlePlay(); + } + handlePaste () { + if (!this.state.copyBuffer) return; + if (this.state.copyBuffer.sampleRate === this.props.sampleRate) { + this.paste(); + } else { + this.resampleBufferToRate(this.state.copyBuffer, this.props.sampleRate).then(buffer => { + this.setState({ + copyBuffer: buffer + }, this.paste); + }); + } + } setRef (element) { this.ref = element; } @@ -226,6 +354,7 @@ class SoundEditor extends React.Component { const {effectTypes} = AudioEffects; return ( 0} canUndo={this.undoStack.length > 0} chunkLevels={this.state.chunkLevels} @@ -237,6 +366,8 @@ class SoundEditor extends React.Component { onActivateTrim={this.handleDelete} onChangeName={this.handleChangeName} onContainerClick={this.handleContainerClick} + onCopy={this.handleCopy} + onCopyToNew={this.handleCopyToNew} onDelete={this.handleDelete} onEcho={this.effectFactory(effectTypes.ECHO)} onFadeIn={this.effectFactory(effectTypes.FADEIN)} @@ -244,6 +375,7 @@ class SoundEditor extends React.Component { onFaster={this.effectFactory(effectTypes.FASTER)} onLouder={this.effectFactory(effectTypes.LOUDER)} onMute={this.effectFactory(effectTypes.MUTE)} + onPaste={this.handlePaste} onPlay={this.handlePlay} onRedo={this.handleRedo} onReverse={this.effectFactory(effectTypes.REVERSE)} @@ -264,10 +396,7 @@ SoundEditor.propTypes = { samples: PropTypes.instanceOf(Float32Array), soundId: PropTypes.string, soundIndex: PropTypes.number, - vm: PropTypes.shape({ - updateSoundBuffer: PropTypes.func, - renameSound: PropTypes.func - }) + vm: PropTypes.instanceOf(VM).isRequired }; const mapStateToProps = (state, {soundIndex}) => {