@@ -72,6 +72,7 @@ import UnsavedChangesIndicator from '../UnsavedChangesIndicator';
7272import { EditorContainer , EditorHolder } from './MobileEditor' ;
7373import { FolderIcon } from '../../../../common/icons' ;
7474import { IconButton } from '../../../../common/IconButton' ;
75+ import { saveLocalBackup } from '../../utils/localBackup' ;
7576
7677import contextAwareHinter from '../../../../utils/contextAwareHinter' ;
7778import showRenameDialog from '../../../../utils/showRenameDialog' ;
@@ -217,7 +218,14 @@ class Editor extends React.Component {
217218 this . props . setUnsavedChanges ( true ) ;
218219 this . props . hideRuntimeErrorWarning ( ) ;
219220 this . props . updateFileContent ( this . props . file . id , this . _cm . getValue ( ) ) ;
220- if ( this . props . autorefresh && this . props . isPlaying ) {
221+
222+ // Save a local backup to localStorage for crash recovery (#3891).
223+ // This ensures work is recoverable even if the tab crashes
224+ // (e.g. from an infinite loop) before the server autosave fires.
225+ const projectId = this . props . project ?. id || 'unsaved' ;
226+ saveLocalBackup ( projectId , this . props . files ) ;
227+
228+ if ( this . props . autorefresh ) {
221229 this . props . clearConsole ( ) ;
222230 this . props . startSketch ( ) ;
223231 }
@@ -228,21 +236,73 @@ class Editor extends React.Component {
228236 this . _cm . on ( 'keyup' , this . handleKeyUp ) ;
229237 }
230238
231- this . _cm . on ( 'keydown' , ( _cm , e ) => {
232- // Skip hinting if the user is pasting (Ctrl/Cmd+V) or using modifier keys (Ctrl/Alt)
233- if (
234- ( ( e . ctrlKey || e . metaKey ) && e . key === 'v' ) ||
235- e . ctrlKey ||
236- e . altKey
237- ) {
238- return ;
239+ // Mobile autocomplete support (CM5 IME + contenteditable input)
240+ const triggerHint = ( cm ) => {
241+ const mode = cm . getOption ( 'mode' ) ;
242+ if ( mode !== 'css' && mode !== 'javascript' ) return ;
243+
244+ const cursor = cm . getCursor ( ) ;
245+ const token = cm . getTokenAt ( cursor ) ;
246+
247+ // Android keyboards often append a trailing space after each word.
248+ // When that happens, stripping the space so the hinter sees the word.
249+ if ( token . string === ' ' && cursor . ch > 0 && cursor . ch === token . end ) {
250+ const prevToken = cm . getTokenAt ( {
251+ line : cursor . line ,
252+ ch : cursor . ch - 1
253+ } ) ;
254+ if ( prevToken . string && / [ a - z ] / i. test ( prevToken . string ) ) {
255+ cm . replaceRange (
256+ '' ,
257+ { line : cursor . line , ch : cursor . ch - 1 } ,
258+ cursor ,
259+ '+trimHint'
260+ ) ;
261+ this . showHint ( cm ) ;
262+ return ;
263+ }
239264 }
240- const mode = this . _cm . getOption ( 'mode' ) ;
241- if ( / ^ [ a - z ] $ / i. test ( e . key ) && ( mode === 'css' || mode === 'javascript' ) ) {
242- this . showHint ( _cm ) ;
265+ if ( token . string && / [ a - z ] / i. test ( token . string ) ) {
266+ this . showHint ( cm ) ;
267+ }
268+ } ;
269+
270+ // Desktop: fires on each keystroke via CM5's textarea input path.
271+ this . _cm . on ( 'change' , ( _cm , changeObj ) => {
272+ if ( changeObj . origin !== '+input' ) return ;
273+ if ( / [ a - z ] / i. test ( changeObj . text . join ( '' ) ) ) {
274+ triggerHint ( _cm ) ;
243275 }
244276 } ) ;
245277
278+ // Mobile (word commit): fires when a composed word is accepted.
279+ this . _compositionEndHandler = ( ) => {
280+ setTimeout ( ( ) => {
281+ if ( this . _cm ) triggerHint ( this . _cm ) ;
282+ } , 150 ) ;
283+ } ;
284+ this . _cm
285+ . getInputField ( )
286+ . addEventListener ( 'compositionend' , this . _compositionEndHandler ) ;
287+
288+ // Mobile (per-character): forces CM5 to process composing text
289+ // during typing so autocomplete appears before keyboard dismissal.
290+ this . _compositionFlushTimer = null ;
291+ this . _compositionUpdateHandler = ( e ) => {
292+ if ( ! e . data || ! / [ a - z ] / i. test ( e . data ) ) return ;
293+ clearTimeout ( this . _compositionFlushTimer ) ;
294+ this . _compositionFlushTimer = setTimeout ( ( ) => {
295+ const display = this . _cm && this . _cm . display ;
296+ if ( display && display . input && display . input . composing ) {
297+ display . input . composing . done = true ;
298+ display . input . readFromDOMSoon ( ) ;
299+ }
300+ } , 200 ) ;
301+ } ;
302+ this . _cm
303+ . getInputField ( )
304+ . addEventListener ( 'compositionupdate' , this . _compositionUpdateHandler ) ;
305+
246306 this . _cm . getWrapperElement ( ) . style [
247307 'font-size'
248308 ] = `${ this . props . fontSize } px` ;
@@ -372,6 +432,20 @@ class Editor extends React.Component {
372432 componentWillUnmount ( ) {
373433 if ( this . _cm ) {
374434 this . _cm . off ( 'keyup' , this . handleKeyUp ) ;
435+ const inputField = this . _cm . getInputField ( ) ;
436+ if ( this . _compositionEndHandler ) {
437+ inputField . removeEventListener (
438+ 'compositionend' ,
439+ this . _compositionEndHandler
440+ ) ;
441+ }
442+ if ( this . _compositionUpdateHandler ) {
443+ inputField . removeEventListener (
444+ 'compositionupdate' ,
445+ this . _compositionUpdateHandler
446+ ) ;
447+ }
448+ clearTimeout ( this . _compositionFlushTimer ) ;
375449 }
376450 this . props . provideController ( null ) ;
377451 }
@@ -733,7 +807,6 @@ Editor.propTypes = {
733807 setUnsavedChanges : PropTypes . func . isRequired ,
734808 startSketch : PropTypes . func . isRequired ,
735809 autorefresh : PropTypes . bool . isRequired ,
736- isPlaying : PropTypes . bool . isRequired ,
737810 theme : PropTypes . string . isRequired ,
738811 unsavedChanges : PropTypes . bool . isRequired ,
739812 files : PropTypes . arrayOf (
@@ -756,11 +829,15 @@ Editor.propTypes = {
756829 provideController : PropTypes . func . isRequired ,
757830 t : PropTypes . func . isRequired ,
758831 setSelectedFile : PropTypes . func . isRequired ,
759- expandConsole : PropTypes . func . isRequired
832+ expandConsole : PropTypes . func . isRequired ,
833+ project : PropTypes . shape ( {
834+ id : PropTypes . string
835+ } )
760836} ;
761837
762838Editor . defaultProps = {
763- htmlFile : null
839+ htmlFile : null ,
840+ project : { }
764841} ;
765842
766843function mapStateToProps ( state ) {
0 commit comments