From efed4d3721674d2a2361be1fadfbdd5a54c014d1 Mon Sep 17 00:00:00 2001 From: Ben Wheeler <wheeler.benjamin@gmail.com> Date: Tue, 28 Jan 2020 14:38:20 -0500 Subject: [PATCH] file input is dynamically added to DOM, not rendered --- src/lib/sb-file-uploader-hoc.jsx | 144 ++++++++++++++++++------------- 1 file changed, 86 insertions(+), 58 deletions(-) diff --git a/src/lib/sb-file-uploader-hoc.jsx b/src/lib/sb-file-uploader-hoc.jsx index 90dd1ff53..78f68646c 100644 --- a/src/lib/sb-file-uploader-hoc.jsx +++ b/src/lib/sb-file-uploader-hoc.jsx @@ -43,47 +43,48 @@ const SBFileUploaderHOC = function (WrappedComponent) { constructor (props) { super(props); bindAll(this, [ + 'createFileObjects', 'getProjectTitleFromFilename', + 'handleFinishedLoadingUpload', 'handleStartSelectingFileUpload', - 'setFileInput', 'handleChange', 'onload', - 'resetFileInput' + 'removeFileObjects' ]); } - componentWillMount () { - this.reader = new FileReader(); - this.reader.onload = this.onload; - this.resetFileInput(); - } componentDidUpdate (prevProps) { if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) { - if (this.fileToUpload && this.reader) { - this.reader.readAsArrayBuffer(this.fileToUpload); - } else { - this.props.cancelFileUpload(this.props.loadingState); - } + this.handleFinishedLoadingUpload(); // cue step 5 below } } componentWillUnmount () { - this.reader = null; - this.resetFileInput(); + this.removeFileObjects(); } - resetFileInput () { - this.fileToUpload = null; - if (this.fileInput) { - this.fileInput.value = null; - } + // step 1: this is where the upload process begins + handleStartSelectingFileUpload () { + this.createFileObjects(); // go to step 2 } - getProjectTitleFromFilename (fileInputFilename) { - if (!fileInputFilename) return ''; - // only parse title with valid scratch project extensions - // (.sb, .sb2, and .sb3) - const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/); - if (!matches) return ''; - return matches[1].substring(0, 100); // truncate project title to max 100 chars + // step 2: create a FileReader and an <input> element, and issue a + // pseudo-click to it. That will open the file chooser dialog. + createFileObjects () { + // redo step 7, in case it got skipped last time and its objects are + // still in memory + this.removeFileObjects(); + // create fileReader + this.fileReader = new FileReader(); + this.fileReader.onload = this.onload; + // create <input> element and add it to DOM + this.inputElement = document.createElement('input'); + this.inputElement.accept = '.sb,.sb2,.sb3'; + this.inputElement.style = 'display: none;'; + this.inputElement.type = 'file'; + this.inputElement.onchange = this.handleChange; // connects to step 3 + document.body.appendChild(this.inputElement); + // simulate a click to open file chooser dialog + this.inputElement.click(); } - // called when user has finished selecting a file to upload + // step 3: user has picked a file using the file chooser dialog. + // We don't actually load the file here, we only decide whether to do so. handleChange (e) { const { intl, @@ -92,14 +93,14 @@ const SBFileUploaderHOC = function (WrappedComponent) { projectChanged, userOwnsProject } = this.props; - const thisFileInput = e.target; if (thisFileInput.files) { // Don't attempt to load if no file was selected this.fileToUpload = thisFileInput.files[0]; // If user owns the project, or user has changed the project, - // we must confirm with the user that they really intend to replace it. - // (If they don't own the project and haven't changed it, no need to confirm.) + // we must confirm with the user that they really intend to + // replace it. (If they don't own the project and haven't + // changed it, no need to confirm.) let uploadAllowed = true; if (userOwnsProject || (projectChanged && isShowingWithoutId)) { uploadAllowed = confirm( // eslint-disable-line no-alert @@ -107,58 +108,80 @@ const SBFileUploaderHOC = function (WrappedComponent) { ); } if (uploadAllowed) { + // cues step 4 this.props.requestProjectUpload(loadingState); } else { - this.resetFileInput(); + // skips ahead to step 7 + this.removeFileObjects(); } this.props.closeFileMenu(); } } - // called when file upload raw data is available in the reader + // step 4 is below, in mapDispatchToProps + + // step 5: called from componentDidUpdate when project state shows + // that project data has finished "uploading" into the browser + handleFinishedLoadingUpload () { + if (this.fileToUpload && this.fileReader) { + // begin to read data from the file. When finished, + // cues step 6 using the reader's onload callback + this.fileReader.readAsArrayBuffer(this.fileToUpload); + } else { + this.props.cancelFileUpload(this.props.loadingState); + // skip ahead to step 7 + this.removeFileObjects(); + } + } + // used in step 6 below + getProjectTitleFromFilename (fileInputFilename) { + if (!fileInputFilename) return ''; + // only parse title with valid scratch project extensions + // (.sb, .sb2, and .sb3) + const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/); + if (!matches) return ''; + return matches[1].substring(0, 100); // truncate project title to max 100 chars + } + // step 6: attached as a handler on our FileReader object; called when + // file upload raw data is available in the reader onload () { - if (this.reader) { + if (this.fileReader) { this.props.onLoadingStarted(); const filename = this.fileToUpload && this.fileToUpload.name; - this.props.vm.loadProject(this.reader.result) + this.props.vm.loadProject(this.fileReader.result) .then(() => { this.props.onLoadingFinished(this.props.loadingState, true); - // Reset the file input after project is loaded - // This is necessary in case the user wants to reload a project if (filename) { const uploadedProjectTitle = this.getProjectTitleFromFilename(filename); this.props.onUpdateProjectTitle(uploadedProjectTitle); } - this.resetFileInput(); }) .catch(error => { log.warn(error); this.props.intl.formatMessage(messages.loadError); // eslint-disable-line no-alert this.props.onLoadingFinished(this.props.loadingState, false); - // Reset the file input after project is loaded - // This is necessary in case the user wants to reload a project - this.resetFileInput(); + }) + .then(() => { + // go back to step 7: whether project loading succeeded + // or failed, reset file objects + this.removeFileObjects(); }); } } - handleStartSelectingFileUpload () { - // open filesystem browsing window - this.fileInput.click(); - } - setFileInput (input) { - this.fileInput = input; + // step 7: remove the <input> element from the DOM and clear reader and + // fileToUpload reference, so those objects can be garbage collected + removeFileObjects () { + if (this.inputElement) { + this.inputElement.value = null; + document.body.removeChild(this.inputElement); + } + this.inputElement = null; + this.fileReader = null; + this.fileToUpload = null; } render () { - const fileInput = ( - <input - accept=".sb,.sb2,.sb3" - ref={this.setFileInput} - style={{display: 'none'}} - type="file" - onChange={this.handleChange} - /> - ); const { /* eslint-disable no-unused-vars */ + cancelFileUpload, closeFileMenu: closeFileMenuProp, isLoadingUpload, isShowingWithoutId, @@ -177,7 +200,6 @@ const SBFileUploaderHOC = function (WrappedComponent) { onStartSelectingFileUpload={this.handleStartSelectingFileUpload} {...componentProps} /> - {fileInput} </React.Fragment> ); } @@ -210,19 +232,25 @@ const SBFileUploaderHOC = function (WrappedComponent) { loadingState: loadingState, projectChanged: state.scratchGui.projectChanged, userOwnsProject: ownProps.authorUsername && user && - (ownProps.authorUsername === user.username) - // vm: state.scratchGui.vm + (ownProps.authorUsername === user.username), + vm: state.scratchGui.vm // NOTE: double check this belongs here }; }; const mapDispatchToProps = (dispatch, ownProps) => ({ cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), closeFileMenu: () => dispatch(closeFileMenu()), + // transition project state from loading to regular, and close + // loading screen and file menu onLoadingFinished: (loadingState, success) => { dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); dispatch(closeLoadingProject()); dispatch(closeFileMenu()); }, + // show project loading screen onLoadingStarted: () => dispatch(openLoadingProject()), + // step 4: transition the project state so we're ready to handle the new + // project data. When this is done, the project state transition will be + // noticed by componentDidUpdate() requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)) }); // Allow incoming props to override redux-provided props. Used to mock in tests. -- GitLab