Spaces:
Runtime error
Runtime error
| 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|\.s4s.txt)$/); | |
| 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)}.s4s.txt`; | |
| }; | |
| 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); | |