penguinmod-editor-2 / src /containers /sb3-downloader.jsx
soiz1's picture
Upload 2891 files
6bcb42f verified
import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import {projectTitleInitialState, setProjectTitle} from '../reducers/project-title';
import downloadBlob from '../lib/download-blob';
import {setProjectUnchanged} from '../reducers/project-changed';
import {showStandardAlert, showAlertWithTimeout} from '../reducers/alerts';
import {setFileHandle} from '../reducers/tw';
import FileSystemAPI from '../lib/tw-filesystem-api';
import {getIsShowingProject} from '../reducers/project-state';
import log from '../lib/log';
// from sb-file-uploader-hoc.jsx
const getProjectTitleFromFilename = fileInputFilename => {
if (!fileInputFilename) return '';
// only parse title with valid scratch project extensions
// (.sb, .sb2, .sb3, and .pm)
const matches = fileInputFilename.match(/^(.*)(\.sb[23]?|\.pm|\.pmp)$/);
if (!matches) return '';
return matches[1].substring(0, 100); // truncate project title to max 100 chars
};
/**
* @param {Uint8Array[]} arrays List of byte arrays
* @returns {number} Total length of the arrays
*/
const getLengthOfByteArrays = arrays => {
let length = 0;
for (let i = 0; i < arrays.length; i++) {
length += arrays[i].byteLength;
}
return length;
};
/**
* @param {Uint8Array[]} arrays List of byte arrays
* @returns {Uint8Array} One big array containing all of the little arrays in order.
*/
const concatenateByteArrays = arrays => {
const totalLength = getLengthOfByteArrays(arrays);
const newArray = new Uint8Array(totalLength);
let p = 0;
for (let i = 0; i < arrays.length; i++) {
newArray.set(arrays[i], p);
p += arrays[i].byteLength;
}
return newArray;
};
/**
* Project saver component passes a downloadProject function to its child.
* It expects this child to be a function with the signature
* function (downloadProject, props) {}
* The component can then be used to attach project saving functionality
* to any other component:
*
* <SB3Downloader>{(downloadProject, props) => (
* <MyCoolComponent
* onClick={downloadProject}
* {...props}
* />
* )}</SB3Downloader>
*/
class SB3Downloader extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'downloadProject',
'saveAsNew',
'saveToLastFile',
'saveToLastFileOrNew'
]);
}
startedSaving () {
this.props.onShowSavingAlert();
}
finishedSaving () {
this.props.onProjectUnchanged();
this.props.onShowSaveSuccessAlert();
if (this.props.onSaveFinished) {
this.props.onSaveFinished();
}
}
downloadProject () {
if (!this.props.canSaveProject) {
return;
}
this.startedSaving();
this.props.saveProjectSb3().then(content => {
this.finishedSaving();
downloadBlob(this.props.projectFilename, content);
});
}
async saveAsNew () {
if (!this.props.canSaveProject) {
return;
}
try {
const handle = await FileSystemAPI.showSaveFilePicker(this.props.projectFilename);
await this.saveToHandle(handle);
this.props.onSetFileHandle(handle);
const title = getProjectTitleFromFilename(handle.name);
if (title) {
this.props.onSetProjectTitle(title);
}
} catch (e) {
this.handleSaveError(e);
}
}
async saveToLastFile () {
try {
await this.saveToHandle(this.props.fileHandle);
} catch (e) {
this.handleSaveError(e);
}
}
saveToLastFileOrNew () {
if (this.props.fileHandle) {
return this.saveToLastFile();
}
return this.saveAsNew();
}
async saveToHandle (handle) {
if (!this.props.canSaveProject) {
return;
}
const writable = await handle.createWritable();
this.startedSaving();
await new Promise((resolve, reject) => {
// Projects can be very large, so we'll utilize JSZip's stream API to avoid having the
// entire sb3 in memory at the same time.
const jszipStream = this.props.saveProjectSb3Stream();
const abortController = new AbortController();
jszipStream.on('error', error => {
abortController.abort(error);
});
// JSZip's stream pause() and resume() methods are not necessarily completely no-ops
// if they are already paused or resumed. These also make it easier to add debug
// logging of when we actually pause or resume.
// Note that JSZip will keep sending some data after you ask it to pause.
let jszipStreamRunning = false;
const pauseJSZipStream = () => {
if (jszipStreamRunning) {
jszipStreamRunning = false;
jszipStream.pause();
}
};
const resumeJSZipStream = () => {
if (!jszipStreamRunning) {
jszipStreamRunning = true;
jszipStream.resume();
}
};
// Allow the JSZip stream to run quite a bit ahead of file writing. This helps
// reduce zip stream pauses on systems with high latency storage.
const HIGH_WATER_MARK_BYTES = 1024 * 1024 * 5;
// Minimum size of buffer to pass into write(). Small buffers will be queued and
// written in batches as they reach or exceed this size.
const WRITE_BUFFER_TARGET_SIZE_BYTES = 1024 * 1024;
const zipStream = new ReadableStream({
start: controller => {
jszipStream.on('data', data => {
controller.enqueue(data);
if (controller.desiredSize <= 0) {
pauseJSZipStream();
}
});
jszipStream.on('end', () => {
controller.close();
});
resumeJSZipStream();
},
pull: () => {
resumeJSZipStream();
},
cancel: () => {
pauseJSZipStream();
}
}, new ByteLengthQueuingStrategy({
highWaterMark: HIGH_WATER_MARK_BYTES
}));
const queuedChunks = [];
const fileStream = new WritableStream({
write: chunk => {
queuedChunks.push(chunk);
const currentSize = getLengthOfByteArrays(queuedChunks);
if (currentSize >= WRITE_BUFFER_TARGET_SIZE_BYTES) {
const newBuffer = concatenateByteArrays(queuedChunks);
queuedChunks.length = 0;
return writable.write(newBuffer);
}
// Otherwise wait for more data
},
close: async () => {
// Write the last batch of data.
const lastBuffer = concatenateByteArrays(queuedChunks);
if (lastBuffer.byteLength) {
await writable.write(lastBuffer);
}
// File handle must be closed at the end to actually save the file.
await writable.close();
},
abort: async () => {
await writable.abort();
}
});
zipStream.pipeTo(fileStream, {
signal: abortController.signal
})
.then(() => {
this.finishedSaving();
resolve();
})
.catch(error => {
reject(error);
});
});
}
handleSaveError (e) {
// AbortError can happen when someone cancels the file selector dialog
if (e && e.name === 'AbortError') {
return;
}
log.error(e);
this.props.onShowSaveErrorAlert();
}
render () {
const {
children
} = this.props;
return children(
this.props.className,
this.downloadProject,
FileSystemAPI.available() ? {
available: true,
name: this.props.fileHandle ? this.props.fileHandle.name : null,
saveAsNew: this.saveAsNew,
saveToLastFile: this.saveToLastFile,
saveToLastFileOrNew: this.saveToLastFileOrNew,
smartSave: this.saveToLastFileOrNew
} : {
available: false,
smartSave: this.downloadProject
}
);
}
}
const getProjectFilename = (curTitle, defaultTitle) => {
let filenameTitle = curTitle;
if (!filenameTitle || filenameTitle.length === 0) {
filenameTitle = defaultTitle;
}
return `${filenameTitle.substring(0, 100)}.pmp`;
};
SB3Downloader.propTypes = {
children: PropTypes.func,
className: PropTypes.string,
fileHandle: PropTypes.shape({
name: PropTypes.string
}),
onSaveFinished: PropTypes.func,
projectFilename: PropTypes.string,
saveProjectSb3: PropTypes.func,
saveProjectSb3Stream: PropTypes.func,
canSaveProject: PropTypes.bool,
onSetFileHandle: PropTypes.func,
onSetProjectTitle: PropTypes.func,
onShowSavingAlert: PropTypes.func,
onShowSaveSuccessAlert: PropTypes.func,
onShowSaveErrorAlert: PropTypes.func,
onProjectUnchanged: PropTypes.func
};
SB3Downloader.defaultProps = {
className: ''
};
const mapStateToProps = state => ({
fileHandle: state.scratchGui.tw.fileHandle,
saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm),
saveProjectSb3Stream: state.scratchGui.vm.saveProjectSb3Stream.bind(state.scratchGui.vm),
canSaveProject: getIsShowingProject(state.scratchGui.projectState.loadingState),
projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState)
});
const mapDispatchToProps = dispatch => ({
onSetFileHandle: fileHandle => dispatch(setFileHandle(fileHandle)),
onSetProjectTitle: title => dispatch(setProjectTitle(title)),
onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'),
onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'twSaveToDiskSuccess'),
onShowSaveErrorAlert: () => dispatch(showStandardAlert('savingError')),
onProjectUnchanged: () => dispatch(setProjectUnchanged())
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(SB3Downloader);