diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 8c21cddae..2595dd309 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -81,6 +81,7 @@ import { closeErrorsMenu } from '../../reducers/menus'; import {setFileHandle} from '../../reducers/tw.js'; +import {getFileHandleByName} from '../../lib/recent-files-manager'; import collectMetadata from '../../lib/collect-metadata'; @@ -228,7 +229,8 @@ class MenuBar extends React.Component { 'handleKeyPress', 'handleRestoreOption', 'getSaveToComputerHandler', - 'restoreOptionMessage' + 'restoreOptionMessage', + 'handleClickRecentFile' ]); } componentDidMount () { @@ -360,6 +362,71 @@ class MenuBar extends React.Component { } }; } + async handleClickRecentFile (fileName) { + try { + // Close the file menu + this.props.onRequestCloseFile(); + + // Get the file handle from IndexedDB + const fileHandle = await getFileHandleByName(fileName); + if (!fileHandle) { + console.error('File handle not found for:', fileName); + return; + } + + // Request permission to read the file + const permission = await fileHandle.queryPermission({mode: 'read'}); + if (permission !== 'granted') { + const newPermission = await fileHandle.requestPermission({mode: 'read'}); + if (newPermission !== 'granted') { + console.error('Permission denied to read file:', fileName); + return; + } + } + + // Read the file + const file = await fileHandle.getFile(); + const reader = new FileReader(); + + reader.onload = () => { + // Trigger the file upload with the file content + if (this.props.onStartSelectingFileUpload) { + // Create a synthetic file input event + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.files = dataTransfer.files; + + // Store the handle for future saves + if (this.props.onSetFileHandle) { + this.props.onSetFileHandle(fileHandle); + } + + // Trigger the upload + this.props.onStartSelectingFileUpload(); + // Simulate the file selection + setTimeout(() => { + const realFileInput = document.querySelector('input[type="file"]'); + if (realFileInput) { + const dt = new DataTransfer(); + dt.items.add(file); + realFileInput.files = dt.files; + realFileInput.dispatchEvent(new Event('change', {bubbles: true})); + } + }, 100); + } + }; + + reader.onerror = error => { + console.error('Error reading file:', error); + }; + + reader.readAsArrayBuffer(file); + } catch (error) { + console.error('Error opening recent file:', error); + } + } restoreOptionMessage (deletedItem) { switch (deletedItem) { case 'Sprite': @@ -723,6 +790,25 @@ class MenuBar extends React.Component { )} + {this.props.autoOpenEnabled && this.props.recentFiles && this.props.recentFiles.length > 0 && ( + + + + + {this.props.recentFiles.slice(0, 5).map((file, index) => ( + this.handleClickRecentFile(file.name)} + > + {file.name} + + ))} + + )} {this.props.onClickPackager && ( { mode1920: isTimeTravel1920(state), mode1990: isTimeTravel1990(state), mode2020: isTimeTravel2020(state), - modeNow: isTimeTravelNow(state) + modeNow: isTimeTravelNow(state), + recentFiles: state.scratchGui.tw.recentFiles || [], + autoOpenEnabled: state.scratchGui.tw.autoOpenEnabled || false }; }; @@ -1254,7 +1348,8 @@ const mapDispatchToProps = dispatch => ({ onClickSave: () => dispatch(manualUpdateProject()), onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), onSeeCommunity: () => dispatch(setPlayer(true)), - onSetTimeTravelMode: mode => dispatch(setTimeTravel(mode)) + onSetTimeTravelMode: mode => dispatch(setTimeTravel(mode)), + onSetFileHandle: handle => dispatch(setFileHandle(handle)) }); export default compose( diff --git a/src/components/tw-settings-modal/settings-modal.jsx b/src/components/tw-settings-modal/settings-modal.jsx index 52b63348f..991de8440 100644 --- a/src/components/tw-settings-modal/settings-modal.jsx +++ b/src/components/tw-settings-modal/settings-modal.jsx @@ -325,6 +325,27 @@ const DisableCompiler = props => ( /> ); +const AutoOpen = props => ( + + } + help={ + + } + /> +); + const CustomStageSize = ({ customStageSizeEnabled, stageWidth, @@ -499,6 +520,17 @@ const SettingsModalComponent = props => ( value={props.disableCompiler} onChange={props.onDisableCompilerChange} /> +
+ +
+ {!props.isEmbedded && ( { + this.props.onAddRecentFile(recentFiles); + }).catch(err => { + console.error('Failed to add recent file:', err); + }); const title = getProjectTitleFromFilename(handle.name); if (title) { this.props.onSetProjectTitle(title); @@ -291,7 +298,8 @@ SB3Downloader.propTypes = { onShowSaveSuccessAlert: PropTypes.func, onShowSaveErrorAlert: PropTypes.func, onProjectUnchanged: PropTypes.func, - showSaveFilePicker: PropTypes.func + showSaveFilePicker: PropTypes.func, + onAddRecentFile: PropTypes.func }; SB3Downloader.defaultProps = { className: '', @@ -312,7 +320,8 @@ const mapDispatchToProps = dispatch => ({ onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'), onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'twSaveToDiskSuccess'), onShowSaveErrorAlert: () => dispatch(showStandardAlert('savingError')), - onProjectUnchanged: () => dispatch(setProjectUnchanged()) + onProjectUnchanged: () => dispatch(setProjectUnchanged()), + onAddRecentFile: recentFiles => dispatch(addRecentFile(recentFiles)) }); export default connect( diff --git a/src/containers/tw-settings-modal.jsx b/src/containers/tw-settings-modal.jsx index a7c3bf042..55c6c8ff3 100644 --- a/src/containers/tw-settings-modal.jsx +++ b/src/containers/tw-settings-modal.jsx @@ -6,6 +6,8 @@ import {connect} from 'react-redux'; import {closeSettingsModal} from '../reducers/modals'; import SettingsModalComponent from '../components/tw-settings-modal/settings-modal.jsx'; import {defaultStageSize} from '../reducers/custom-stage-size'; +import {setAutoOpenEnabled} from '../reducers/tw'; +import {saveAutoOpenSetting} from '../lib/recent-files-manager'; const messages = defineMessages({ newFramerate: { @@ -30,7 +32,8 @@ class UsernameModal extends React.Component { 'handleStageWidthChange', 'handleStageHeightChange', 'handleDisableCompilerChange', - 'handleStoreProjectOptions' + 'handleStoreProjectOptions', + 'handleAutoOpenChange' ]); } handleFramerateChange (e) { @@ -85,6 +88,9 @@ class UsernameModal extends React.Component { handleStoreProjectOptions () { this.props.vm.storeProjectOptions(); } + handleAutoOpenChange (e) { + this.props.onAutoOpenChange(e.target.checked); + } render () { const { /* eslint-disable no-unused-vars */ @@ -114,6 +120,7 @@ class UsernameModal extends React.Component { this.props.customStageSize.height !== defaultStageSize.height } onStoreProjectOptions={this.handleStoreProjectOptions} + onAutoOpenChange={this.handleAutoOpenChange} {...props} /> ); @@ -123,6 +130,7 @@ class UsernameModal extends React.Component { UsernameModal.propTypes = { intl: intlShape, onClose: PropTypes.func, + onAutoOpenChange: PropTypes.func, vm: PropTypes.shape({ renderer: PropTypes.shape({ setUseHighQualityRender: PropTypes.func @@ -146,7 +154,8 @@ UsernameModal.propTypes = { width: PropTypes.number, height: PropTypes.number }), - disableCompiler: PropTypes.bool + disableCompiler: PropTypes.bool, + autoOpenEnabled: PropTypes.bool }; const mapStateToProps = state => ({ @@ -160,11 +169,16 @@ const mapStateToProps = state => ({ removeLimits: !state.scratchGui.tw.runtimeOptions.miscLimits, warpTimer: state.scratchGui.tw.compilerOptions.warpTimer, customStageSize: state.scratchGui.customStageSize, - disableCompiler: !state.scratchGui.tw.compilerOptions.enabled + disableCompiler: !state.scratchGui.tw.compilerOptions.enabled, + autoOpenEnabled: state.scratchGui.tw.autoOpenEnabled }); const mapDispatchToProps = dispatch => ({ - onClose: () => dispatch(closeSettingsModal()) + onClose: () => dispatch(closeSettingsModal()), + onAutoOpenChange: enabled => { + dispatch(setAutoOpenEnabled(enabled)); + saveAutoOpenSetting(enabled); + } }); export default injectIntl(connect( diff --git a/src/lib/auto-open-hoc.jsx b/src/lib/auto-open-hoc.jsx new file mode 100644 index 000000000..63d78b588 --- /dev/null +++ b/src/lib/auto-open-hoc.jsx @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import {addRecentFile, setAutoOpenEnabled} from '../reducers/tw'; +import {loadRecentFilesMetadata, loadAutoOpenSetting} from '../lib/recent-files-manager'; + +/** + * HOC to handle auto-opening of recent files on startup + * @param {React.Component} WrappedComponent component to wrap + * @returns {React.Component} wrapped component with auto-open functionality + */ +const AutoOpenHOC = function (WrappedComponent) { + class AutoOpenComponent extends React.Component { + async componentDidMount () { + // Load settings from localStorage and IndexedDB on mount + const autoOpenEnabled = loadAutoOpenSetting(); + const recentFiles = await loadRecentFilesMetadata(); + + this.props.onSetAutoOpenEnabled(autoOpenEnabled); + this.props.onSetRecentFiles(recentFiles); + } + + render () { + const { + onSetAutoOpenEnabled, + onSetRecentFiles, + ...componentProps + } = this.props; + + return ( + + ); + } + } + + AutoOpenComponent.propTypes = { + recentFiles: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + timestamp: PropTypes.number + })), + onSetAutoOpenEnabled: PropTypes.func.isRequired, + onSetRecentFiles: PropTypes.func.isRequired + }; + + const mapStateToProps = state => ({ + recentFiles: state.scratchGui.tw.recentFiles, + autoOpenEnabled: state.scratchGui.tw.autoOpenEnabled + }); + + const mapDispatchToProps = dispatch => ({ + onSetAutoOpenEnabled: enabled => dispatch(setAutoOpenEnabled(enabled)), + onSetRecentFiles: files => dispatch(addRecentFile(files)) + }); + + return connect( + mapStateToProps, + mapDispatchToProps + )(AutoOpenComponent); +}; + +export default AutoOpenHOC; diff --git a/src/lib/recent-files-manager.js b/src/lib/recent-files-manager.js new file mode 100644 index 000000000..a3470d979 --- /dev/null +++ b/src/lib/recent-files-manager.js @@ -0,0 +1,202 @@ +/** + * Manages recent files using IndexedDB and File System Access API + * IndexedDB is used to store FileSystemFileHandle objects which cannot be serialized to localStorage + */ + +const DB_NAME = 'omniblocks-recent-files'; +const DB_VERSION = 1; +const STORE_NAME = 'fileHandles'; +const AUTO_OPEN_KEY = 'tw-auto-open-enabled'; +const MAX_RECENT_FILES = 5; + +/** + * Initialize IndexedDB database + * @returns {Promise} Database instance + */ +const initDB = () => { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = event => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const objectStore = db.createObjectStore(STORE_NAME, {keyPath: 'id', autoIncrement: true}); + objectStore.createIndex('timestamp', 'timestamp', {unique: false}); + objectStore.createIndex('name', 'name', {unique: false}); + } + }; + }); +}; + +/** + * Load recent files from IndexedDB + * @returns {Promise} Array of recent file objects with metadata and handles + */ +export const loadRecentFiles = async () => { + try { + const db = await initDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const objectStore = transaction.objectStore(STORE_NAME); + const index = objectStore.index('timestamp'); + const request = index.openCursor(null, 'prev'); // Sort by timestamp descending + + const files = []; + request.onsuccess = event => { + const cursor = event.target.result; + if (cursor && files.length < MAX_RECENT_FILES) { + files.push({ + id: cursor.value.id, + name: cursor.value.name, + timestamp: cursor.value.timestamp, + handle: cursor.value.handle + }); + cursor.continue(); + } else { + resolve(files); + } + }; + request.onerror = () => reject(request.error); + }); + } catch (e) { + console.error('Failed to load recent files from IndexedDB:', e); + return []; + } +}; + +/** + * Load recent files metadata only (without handles) - for initial display + * @returns {Promise} Array of recent file metadata + */ +export const loadRecentFilesMetadata = async () => { + try { + const files = await loadRecentFiles(); + return files.map(({name, timestamp}) => ({name, timestamp})); + } catch (e) { + console.error('Failed to load recent files metadata:', e); + return []; + } +}; + +/** + * Add a file to recent files list in IndexedDB + * @param {FileSystemFileHandle} fileHandle The file handle from File System Access API + * @returns {Promise} Updated array of recent files metadata + */ +export const addRecentFile = async fileHandle => { + try { + const db = await initDB(); + + // First, check if file with same name exists and remove it + const existing = await loadRecentFiles(); + const duplicateId = existing.find(f => f.name === fileHandle.name)?.id; + + if (duplicateId) { + await new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + const request = objectStore.delete(duplicateId); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // Add new file + await new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + const request = objectStore.add({ + name: fileHandle.name, + timestamp: Date.now(), + handle: fileHandle + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + // Clean up old entries if we exceed MAX_RECENT_FILES + const allFiles = await loadRecentFiles(); + if (allFiles.length > MAX_RECENT_FILES) { + const toDelete = allFiles.slice(MAX_RECENT_FILES); + await Promise.all(toDelete.map(file => + new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + const request = objectStore.delete(file.id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }) + )); + } + + // Return metadata only for Redux state + return await loadRecentFilesMetadata(); + } catch (e) { + console.error('Failed to add recent file to IndexedDB:', e); + return []; + } +}; + +/** + * Get file handle by name from IndexedDB + * @param {string} fileName The name of the file to retrieve + * @returns {Promise} The file handle or null if not found + */ +export const getFileHandleByName = async fileName => { + try { + const files = await loadRecentFiles(); + const file = files.find(f => f.name === fileName); + return file ? file.handle : null; + } catch (e) { + console.error('Failed to get file handle:', e); + return null; + } +}; + +/** + * Load auto-open setting from localStorage + * @returns {boolean} Whether auto-open is enabled + */ +export const loadAutoOpenSetting = () => { + try { + const stored = localStorage.getItem(AUTO_OPEN_KEY); + return stored === 'true'; + } catch (e) { + console.error('Failed to load auto-open setting:', e); + } + return false; +}; + +/** + * Save auto-open setting to localStorage + * @param {boolean} enabled Whether auto-open should be enabled + */ +export const saveAutoOpenSetting = enabled => { + try { + localStorage.setItem(AUTO_OPEN_KEY, String(enabled)); + } catch (e) { + console.error('Failed to save auto-open setting:', e); + } +}; + +/** + * Clear all recent files from IndexedDB + * @returns {Promise} + */ +export const clearRecentFiles = async () => { + try { + const db = await initDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const objectStore = transaction.objectStore(STORE_NAME); + const request = objectStore.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (e) { + console.error('Failed to clear recent files:', e); + } +}; diff --git a/src/lib/sb-file-uploader-hoc.jsx b/src/lib/sb-file-uploader-hoc.jsx index 2c54204c7..8e8e790db 100644 --- a/src/lib/sb-file-uploader-hoc.jsx +++ b/src/lib/sb-file-uploader-hoc.jsx @@ -5,7 +5,8 @@ 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 {setFileHandle, setProjectError, addRecentFile as addRecentFileAction} from '../reducers/tw'; +import {addRecentFile as addToRecentFiles} from '../lib/recent-files-manager'; import { LoadingStates, @@ -147,6 +148,12 @@ const SBFileUploaderHOC = function (WrappedComponent) { if (handle) { if (this.fileToUpload.name.endsWith('.sb3')) { this.props.onSetFileHandle(handle); + // Add to recent files when opening (async) + addToRecentFiles(handle).then(recentFiles => { + this.props.onAddRecentFile(recentFiles); + }).catch(err => { + console.error('Failed to add recent file:', err); + }); } else { this.props.onSetFileHandle(null); } @@ -281,7 +288,8 @@ const SBFileUploaderHOC = function (WrappedComponent) { draw: PropTypes.func }) }), - onSetFileHandle: PropTypes.func + onSetFileHandle: PropTypes.func, + onAddRecentFile: PropTypes.func }; SBFileUploaderComponent.defaultProps = { showOpenFilePicker: typeof showOpenFilePicker === 'function' ? window.showOpenFilePicker.bind(window) : null @@ -321,7 +329,8 @@ const SBFileUploaderHOC = function (WrappedComponent) { // 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)) + onSetFileHandle: fileHandle => dispatch(setFileHandle(fileHandle)), + onAddRecentFile: recentFiles => dispatch(addRecentFileAction(recentFiles)) }); // Allow incoming props to override redux-provided props. Used to mock in tests. const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( diff --git a/src/reducers/tw.js b/src/reducers/tw.js index 332709b52..32370d8aa 100644 --- a/src/reducers/tw.js +++ b/src/reducers/tw.js @@ -17,6 +17,8 @@ const SET_HAS_CLOUD_VARIABLES = 'tw/SET_HAS_CLOUD_VARIABLES'; const SET_CLOUD_HOST = 'tw/SET_CLOUD_HOST'; const SET_PLATFORM_MISMATCH_DETAILS = 'tw/SET_PLATFORM_MISMATCH_DETAILS'; const SET_PROJECT_ERROR = 'tw/SET_PROJECT_ERROR'; +const ADD_RECENT_FILE = 'tw/ADD_RECENT_FILE'; +const SET_AUTO_OPEN_ENABLED = 'tw/SET_AUTO_OPEN_ENABLED'; export const initialState = { framerate: 30, @@ -52,7 +54,9 @@ export const initialState = { platform: null, callback: null }, - projectError: null + projectError: null, + recentFiles: [], + autoOpenEnabled: false }; const reducer = function (state, action) { @@ -140,6 +144,14 @@ const reducer = function (state, action) { return Object.assign({}, state, { projectError: action.projectError }); + case ADD_RECENT_FILE: + return Object.assign({}, state, { + recentFiles: action.recentFiles + }); + case SET_AUTO_OPEN_ENABLED: + return Object.assign({}, state, { + autoOpenEnabled: action.autoOpenEnabled + }); default: return state; } @@ -278,6 +290,20 @@ const setProjectError = function (projectError) { }; }; +const addRecentFile = function (recentFiles) { + return { + type: ADD_RECENT_FILE, + recentFiles + }; +}; + +const setAutoOpenEnabled = function (autoOpenEnabled) { + return { + type: SET_AUTO_OPEN_ENABLED, + autoOpenEnabled + }; +}; + export { reducer as default, initialState as twInitialState, @@ -299,5 +325,7 @@ export { setHasCloudVariables, setCloudHost, setPlatformMismatchDetails, - setProjectError + setProjectError, + addRecentFile, + setAutoOpenEnabled };