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|\.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: * * {(downloadProject, props) => ( * * )} */ 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 { // 外部でも使用できるようにプロパティに保存 this.handle = { name: this.props.projectFilename }; this.title = getProjectTitleFromFilename(this.handle.name); if (this.title) { this.props.onSetProjectTitle(this.title); } await this.saveToBlobAndDownload(); } 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 saveToBlobAndDownload () { if (!this.props.canSaveProject) return; this.startedSaving(); // Blob を生成 const chunks = []; const jszipStream = this.props.saveProjectSb3Stream(); await new Promise((resolve, reject) => { jszipStream.on('data', chunk => chunks.push(chunk)); jszipStream.on('end', resolve); jszipStream.on('error', reject); }); const blob = new Blob(chunks, { type: 'text/plain' }); // ← ここを text/plain に変更 // 拡張子が .txt であることを保証 let filename = this.handle.name; if (!filename.endsWith('.txt')) { filename = filename.replace(/\.[^/.]+$/, '') + '.txt'; } // ダウンロードリンクを作成 const a = document.createElement('a'); const url = URL.createObjectURL(blob); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.finishedSaving(); } 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)}.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);