import bindAll from 'lodash.bindall';
import React from 'react';
import PropTypes from 'prop-types';
import {intlShape, injectIntl} from 'react-intl';
import {connect} from 'react-redux';
import log from '../lib/log';
import sharedMessages from './shared-messages';
import {setFileHandle, setProjectError} from '../reducers/tw';
import {
LoadingStates,
getIsLoadingUpload,
getIsShowingWithoutId,
onLoadedProject,
requestProjectUpload,
getIsShowingProject
} from '../reducers/project-state';
import {setProjectTitle} from '../reducers/project-title';
import {
openLoadingProject,
closeLoadingProject,
openInvalidProjectModal
} from '../reducers/modals';
import {
closeFileMenu
} from '../reducers/menus';
/**
* Higher Order Component to provide behavior for loading local project files into editor.
* @param {React.Component} WrappedComponent the component to add project file loading functionality to
* @returns {React.Component} WrappedComponent with project file loading functionality added
*
*
*
*
*/
const SBFileUploaderHOC = function (WrappedComponent) {
class SBFileUploaderComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'createFileObjects',
'getProjectTitleFromFilename',
'handleFinishedLoadingUpload',
'handleStartSelectingFileUpload',
'handleChange',
'onload',
'removeFileObjects'
]);
// tw: We have multiple instances of this HOC alive at a time. This flag fixes issues that arise from that.
this.expectingFileUploadFinish = false;
}
componentDidUpdate (prevProps) {
if (this.props.isLoadingUpload && !prevProps.isLoadingUpload && this.expectingFileUploadFinish) {
this.handleFinishedLoadingUpload(); // cue step 5 below
}
}
componentWillUnmount () {
this.removeFileObjects();
}
// step 1: this is where the upload process begins
handleStartSelectingFileUpload () {
this.expectingFileUploadFinish = true;
this.createFileObjects(); // go to step 2
}
// step 2: create a FileReader and an 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;
// tw: Use FS API when available
if (this.props.showOpenFilePicker) {
(async () => {
try {
const [handle] = await this.props.showOpenFilePicker({
multiple: false,
types: [
{
description: 'Scratch Project',
accept: {
// Chrome on Android tracks the MIME type of files that get downloaded and
// then actually enforces that the type must match in showOpenFilePicker()
// and does not allow the user to override the filter. As Scratch projects have
// no well-defined and well-adopted MIME types, we can't assume anything about
// what MIME type they are saved with, so we have to use the most broad MIME
// type here. Otherwise some users just won't be able to load files for no
// fault of their own.
'*/*': ['.sb', '.sb2', '.sb3']
}
}
]
});
const file = await handle.getFile();
this.handleChange({
target: {
files: [file],
handle: handle
}
});
} catch (err) {
// If the user aborted it, that's not an error.
if (err && err.name === 'AbortError') {
return;
}
log.error(err);
}
})();
} else {
// create 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();
}
}
// 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,
isShowingWithoutId,
loadingState,
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.)
let uploadAllowed = true;
if (userOwnsProject || (projectChanged && isShowingWithoutId)) {
uploadAllowed = confirm( // eslint-disable-line no-alert
intl.formatMessage(sharedMessages.replaceProjectWarning)
);
}
if (uploadAllowed) {
// Don't update file handle until after confirming replace.
const handle = thisFileInput.handle;
if (handle) {
if (this.fileToUpload.name.endsWith('.sb3')) {
this.props.onSetFileHandle(handle);
} else {
this.props.onSetFileHandle(null);
}
}
// cues step 4
this.props.requestProjectUpload(loadingState);
} else {
// skips ahead to step 7
this.removeFileObjects();
}
this.props.closeFileMenu();
}
}
// 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 () {
this.expectingFileUploadFinish = false;
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.fileReader) {
this.props.onLoadingStarted();
const filename = this.fileToUpload && this.fileToUpload.name;
let loadingSuccess = false;
// tw: stop when loading new project
this.props.vm.quit();
this.props.vm.loadProject(this.fileReader.result)
.then(() => {
if (filename) {
const uploadedProjectTitle = this.getProjectTitleFromFilename(filename);
this.props.onSetProjectTitle(uploadedProjectTitle);
}
this.props.vm.renderer.draw();
loadingSuccess = true;
})
.catch(error => {
log.error(error);
this.props.onLoadingFailed(error);
})
.then(() => {
this.props.onLoadingFinished(this.props.loadingState, loadingSuccess);
// go back to step 7: whether project loading succeeded
// or failed, reset file objects
this.removeFileObjects();
});
}
}
// step 7: remove the 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 {
/* eslint-disable no-unused-vars */
cancelFileUpload,
closeFileMenu: closeFileMenuProp,
isLoadingUpload,
isShowingWithoutId,
loadingState,
onLoadingFailed,
onLoadingFinished,
onLoadingStarted,
onSetFileHandle,
onSetProjectTitle,
projectChanged,
requestProjectUpload: requestProjectUploadProp,
userOwnsProject,
/* eslint-enable no-unused-vars */
...componentProps
} = this.props;
return (
);
}
}
SBFileUploaderComponent.propTypes = {
canSave: PropTypes.bool,
cancelFileUpload: PropTypes.func,
closeFileMenu: PropTypes.func,
intl: intlShape.isRequired,
isLoadingUpload: PropTypes.bool,
isShowingProject: PropTypes.bool,
isShowingWithoutId: PropTypes.bool,
loadingState: PropTypes.oneOf(LoadingStates),
onLoadingFailed: PropTypes.func,
onLoadingFinished: PropTypes.func,
onLoadingStarted: PropTypes.func,
onSetProjectTitle: PropTypes.func,
projectChanged: PropTypes.bool,
requestProjectUpload: PropTypes.func,
showOpenFilePicker: PropTypes.func,
userOwnsProject: PropTypes.bool,
vm: PropTypes.shape({
loadProject: PropTypes.func,
quit: PropTypes.func,
renderer: PropTypes.shape({
draw: PropTypes.func
})
}),
onSetFileHandle: PropTypes.func
};
SBFileUploaderComponent.defaultProps = {
showOpenFilePicker: typeof showOpenFilePicker === 'function' && !navigator.userAgent.includes('Android') ?
window.showOpenFilePicker.bind(window) :
null
};
const mapStateToProps = (state, ownProps) => {
const loadingState = state.scratchGui.projectState.loadingState;
const user = state.session && state.session.session && state.session.session.user;
return {
isLoadingUpload: getIsLoadingUpload(loadingState),
isShowingProject: getIsShowingProject(loadingState),
isShowingWithoutId: getIsShowingWithoutId(loadingState),
loadingState: loadingState,
projectChanged: state.scratchGui.projectChanged,
userOwnsProject: ownProps.authorUsername && user &&
(ownProps.authorUsername === user.username),
vm: state.scratchGui.vm
};
};
const mapDispatchToProps = (dispatch, ownProps) => ({
cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)),
closeFileMenu: () => dispatch(closeFileMenu()),
onLoadingFailed: error => {
dispatch(setProjectError(error));
dispatch(openInvalidProjectModal());
},
// 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()),
onSetProjectTitle: title => dispatch(setProjectTitle(title)),
// 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)),
onSetFileHandle: fileHandle => dispatch(setFileHandle(fileHandle))
});
// Allow incoming props to override redux-provided props. Used to mock in tests.
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
{}, stateProps, dispatchProps, ownProps
);
return injectIntl(connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(SBFileUploaderComponent));
};
export {
SBFileUploaderHOC as default
};