|
| 1 | +import React from 'react'; |
| 2 | +import PropTypes from 'prop-types'; |
| 3 | +import {connect} from 'react-redux'; |
| 4 | + |
| 5 | +/** |
| 6 | + * In-GUI panel that compiles SB3 Creator pseudocode (plus optional uploaded SVG |
| 7 | + * costumes) into a project and loads it straight into the running VM. |
| 8 | + */ |
| 9 | +class PseudocodeImporter extends React.Component { |
| 10 | + constructor (props) { |
| 11 | + super(props); |
| 12 | + this.state = {open: false, code: '', uploads: [], status: '', busy: false}; |
| 13 | + this.handleFiles = this.handleFiles.bind(this); |
| 14 | + this.compile = this.compile.bind(this); |
| 15 | + } |
| 16 | + handleFiles (e) { |
| 17 | + const files = Array.from(e.target.files || []); |
| 18 | + files.forEach(f => { |
| 19 | + if (!/\.svg$/i.test(f.name) && !f.type.includes('svg')) return; |
| 20 | + const reader = new FileReader(); |
| 21 | + reader.onload = () => this.setState(s => ({ |
| 22 | + uploads: [...s.uploads, {sprite: '', filename: f.name, svg: String(reader.result)}] |
| 23 | + })); |
| 24 | + reader.readAsText(f); |
| 25 | + }); |
| 26 | + e.target.value = ''; |
| 27 | + } |
| 28 | + setSprite (i, sprite) { |
| 29 | + this.setState(s => ({uploads: s.uploads.map((u, idx) => (idx === i ? {...u, sprite} : u))})); |
| 30 | + } |
| 31 | + removeUpload (i) { |
| 32 | + this.setState(s => ({uploads: s.uploads.filter((_, idx) => idx !== i)})); |
| 33 | + } |
| 34 | + async compile () { |
| 35 | + this.setState({busy: true, status: 'Compiling…'}); |
| 36 | + try { |
| 37 | + const mod = await import(/* webpackChunkName: "sb3-creator" */ '../../lib/sb3-creator.js'); |
| 38 | + const SB3Creator = mod.default; |
| 39 | + const creator = new SB3Creator(); |
| 40 | + creator.parse(this.state.code); |
| 41 | + const missing = []; |
| 42 | + this.state.uploads.forEach(u => { |
| 43 | + const name = (u.sprite || '').trim(); |
| 44 | + if (name && u.svg && !creator.applyCustomSVG(name, u.svg)) missing.push(name); |
| 45 | + }); |
| 46 | + const blob = await creator.generateSB3(); |
| 47 | + const buffer = await blob.arrayBuffer(); |
| 48 | + await this.props.vm.loadProject(buffer); |
| 49 | + this.props.vm.setEditingTarget(this.props.vm.runtime.targets[1] && |
| 50 | + this.props.vm.runtime.targets[1].id); |
| 51 | + const warns = [...creator.warnings]; |
| 52 | + if (missing.length) warns.push(`no sprite: ${missing.join(', ')}`); |
| 53 | + this.setState({status: warns.length ? `Loaded with warnings: ${warns.slice(0, 3).join(' · ')}` : 'Loaded into the editor!'}); |
| 54 | + } catch (e) { |
| 55 | + this.setState({status: `Error: ${e.message}`}); |
| 56 | + } |
| 57 | + this.setState({busy: false}); |
| 58 | + } |
| 59 | + render () { |
| 60 | + const btn = {position: 'fixed', left: 12, bottom: 12, zIndex: 9999, padding: '8px 14px', |
| 61 | + borderRadius: 8, border: 'none', color: '#fff', cursor: 'pointer', fontWeight: 600, |
| 62 | + background: 'linear-gradient(135deg,#855cd6,#9966ff)', boxShadow: '0 2px 8px rgba(0,0,0,.25)'}; |
| 63 | + if (!this.state.open) { |
| 64 | + return <button style={btn} onClick={() => this.setState({open: true})}>📝 Pseudocode → Project</button>; |
| 65 | + } |
| 66 | + const panel = {position: 'fixed', left: 12, bottom: 12, zIndex: 9999, width: 420, maxWidth: '92vw', |
| 67 | + maxHeight: '80vh', overflow: 'auto', background: '#fff', color: '#222', borderRadius: 10, |
| 68 | + boxShadow: '0 8px 30px rgba(0,0,0,.3)', padding: 14, font: '13px/1.4 sans-serif'}; |
| 69 | + return ( |
| 70 | + <div style={panel}> |
| 71 | + <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8}}> |
| 72 | + <strong>Pseudocode → Project</strong> |
| 73 | + <button style={{border: 'none', background: 'none', fontSize: 18, cursor: 'pointer'}} |
| 74 | + onClick={() => this.setState({open: false})}>✕</button> |
| 75 | + </div> |
| 76 | + <textarea |
| 77 | + value={this.state.code} |
| 78 | + onChange={e => this.setState({code: e.target.value})} |
| 79 | + placeholder={'SPRITE Cat:\n WHEN flag clicked:\n say "Hello!" for 2 seconds'} |
| 80 | + spellCheck={false} |
| 81 | + style={{width: '100%', height: 150, fontFamily: 'monospace', fontSize: 12, |
| 82 | + boxSizing: 'border-box', border: '1px solid #cbd5e1', borderRadius: 6, padding: 8}} |
| 83 | + /> |
| 84 | + <div style={{margin: '8px 0'}}> |
| 85 | + <label style={{fontWeight: 600}}>Custom SVG costumes: </label> |
| 86 | + <input type="file" accept=".svg,image/svg+xml" multiple onChange={this.handleFiles} /> |
| 87 | + </div> |
| 88 | + {this.state.uploads.map((u, i) => ( |
| 89 | + <div key={i} style={{display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4}}> |
| 90 | + <img src={`data:image/svg+xml,${encodeURIComponent(u.svg)}`} alt="" |
| 91 | + style={{width: 28, height: 28, objectFit: 'contain', border: '1px solid #eee', borderRadius: 4}} /> |
| 92 | + <input placeholder="sprite name" value={u.sprite} |
| 93 | + onChange={e => this.setSprite(i, e.target.value)} |
| 94 | + style={{padding: '3px 6px', borderRadius: 5, border: '1px solid #cbd5e1', width: 110}} /> |
| 95 | + <span style={{flex: 1, opacity: .6, overflow: 'hidden', textOverflow: 'ellipsis'}}>{u.filename}</span> |
| 96 | + <button onClick={() => this.removeUpload(i)} style={{border: 'none', background: 'none', cursor: 'pointer'}}>✕</button> |
| 97 | + </div> |
| 98 | + ))} |
| 99 | + <button |
| 100 | + onClick={this.compile} |
| 101 | + disabled={this.state.busy || !this.state.code.trim()} |
| 102 | + style={{marginTop: 8, padding: '8px 14px', borderRadius: 8, border: 'none', color: '#fff', |
| 103 | + cursor: 'pointer', fontWeight: 600, background: 'linear-gradient(135deg,#4c97ff,#4280d7)'}} |
| 104 | + > |
| 105 | + 🚀 Compile & Load |
| 106 | + </button> |
| 107 | + {this.state.status ? <div style={{marginTop: 8, fontSize: 12}}>{this.state.status}</div> : null} |
| 108 | + </div> |
| 109 | + ); |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +PseudocodeImporter.propTypes = { |
| 114 | + vm: PropTypes.shape({loadProject: PropTypes.func}).isRequired |
| 115 | +}; |
| 116 | + |
| 117 | +export default connect(state => ({vm: state.scratchGui.vm}))(PseudocodeImporter); |
0 commit comments