Spaces:
Running
Running
File size: 10,968 Bytes
6bcb42f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 |
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);
|