Skip to content

Commit 4e6d3d9

Browse files
CrispStrobeclaude
andcommitted
Add in-GUI "Pseudocode -> Project" panel with SVG upload
A native panel (bottom-left in the editor) that compiles SB3 Creator pseudocode plus optional uploaded SVG costumes into a project and loads it straight into the running VM via vm.loadProject. This brings the whole SB3 Creator workflow -- pseudocode compiler and per-sprite custom SVG art -- inside the editor, no separate site or round trip. Connected to state.scratchGui.vm; the compiler is the same lazily-loaded chunk used by the #pseudocode= URL path. Next step: fold this into the GUI more neatly alongside the sprite/costume editor rather than a floating panel. Verified locally: typing pseudocode + Compile & Load populates the editor with the sprites, blocks, and costumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9b72316 commit 4e6d3d9

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 &amp; 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);

‎src/playground/render-interface.jsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import TWPackagerIntegrationHOC from '../lib/tw-packager-integration-hoc.jsx';
3030
import SettingsStore from '../addons/settings-store-singleton';
3131
import '../lib/tw-fix-history-api';
3232
import GUI from './render-gui.jsx';
33+
import PseudocodeImporter from '../components/tw-pseudocode/pseudocode-importer.jsx';
3334
import MenuBar from '../components/menu-bar/menu-bar.jsx';
3435
import ProjectInput from '../components/tw-project-input/project-input.jsx';
3536
import FeaturedProjects from '../components/tw-featured-projects/featured-projects.jsx';
@@ -257,6 +258,7 @@ class Interface extends React.Component {
257258
backpackHost="_local_"
258259
{...props}
259260
/>
261+
{isEditor ? <PseudocodeImporter /> : null}
260262
{isHomepage ? (
261263
<React.Fragment>
262264
{isBrowserSupported() ? null : (

0 commit comments

Comments
 (0)