Type or paste scratchblocks-style text and turn it into real blocks, sprites, costumes and backdrops in the Scratch editor (also works on TurboWarp).
// ==UserScript==
// @name Scratch Text → Blocks
// @namespace https://scratch.mit.edu/
// @version 1.3.0
// @description Type or paste scratchblocks-style text and turn it into real blocks, sprites, costumes and backdrops in the Scratch editor (also works on TurboWarp).
// @match https://scratch.mit.edu/projects/*
// @match https://turbowarp.org/*
// @grant none
// #author djjsman
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* =====================================================================
* 1. VALUE MAPS (what you type in a dropdown -> Scratch's internal value)
* ===================================================================== */
const lc = s => String(s).trim().toLowerCase();
const MAPS = {
target: { 'random position': '_random_', 'random': '_random_', 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_' },
touch: { 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_', 'edge': '_edge_' },
clone: { 'myself': '_myself_' },
stage: { 'stage': '_stage_' },
effect: { color: 'COLOR', colour: 'COLOR', fisheye: 'FISHEYE', whirl: 'WHIRL', pixelate: 'PIXELATE', mosaic: 'MOSAIC', brightness: 'BRIGHTNESS', ghost: 'GHOST' },
seffect: { pitch: 'PITCH', 'pan left/right': 'PAN', pan: 'PAN' },
mathop: { abs: 'abs', floor: 'floor', ceiling: 'ceiling', sqrt: 'sqrt', sin: 'sin', cos: 'cos', tan: 'tan', asin: 'asin', acos: 'acos', atan: 'atan', ln: 'ln', log: 'log', 'e ^': 'e ^', 'e^': 'e ^', '10 ^': '10 ^', '10^': '10 ^' },
current: { year: 'YEAR', month: 'MONTH', date: 'DATE', 'day of week': 'DAYOFWEEK', dayofweek: 'DAYOFWEEK', hour: 'HOUR', minute: 'MINUTE', second: 'SECOND' },
gt: { loudness: 'LOUDNESS', timer: 'TIMER' },
pencol: { color: 'color', colour: 'color', saturation: 'saturation', brightness: 'brightness', transparency: 'transparency' },
numname: { number: 'number', '#': 'number', name: 'name' },
frontback: { front: 'front', back: 'back' },
fwdback: { forward: 'forward', backward: 'backward', backwards: 'backward' },
};
const DEFAULTS = { target: '_random_', touch: '_mouse_', clone: '_myself_', stage: '_stage_', key: 'space', pencol: 'color' };
function mapHas(map, raw) { return !!MAPS[map] && Object.prototype.hasOwnProperty.call(MAPS[map], lc(raw)); }
function mapValue(map, raw) {
if (map === 'key' || map === 'lower') return lc(raw);
if (mapHas(map, raw)) return MAPS[map][lc(raw)];
return String(raw).trim();
}
/* =====================================================================
* 2. BLOCK SPECS
* [opcode, shape, ...patterns]
* shapes: s=stack h=hat r=reporter b=boolean c=C-block cc=C-cap (forever) cap=cap
* slot {NAME:type}
* num pnum wnum int ang txt col -> input with shadow
* bool -> boolean input
* f:map[!] -> dropdown field on the block (! = value must be in map)
* m:menuOpcode:FIELD[:map] -> dropdown input (shadow menu block)
* var / list / bc -> variable / list / broadcast field
* bcm -> broadcast menu input
* ===================================================================== */
const SPECS = [
// ---- Motion
['motion_movesteps', 's', 'move {STEPS:num} steps'],
['motion_turnright', 's', 'turn right {DEGREES:num} degrees', 'turn cw {DEGREES:num} degrees', 'turn ↻ {DEGREES:num} degrees', 'turn @turnright {DEGREES:num} degrees'],
['motion_turnleft', 's', 'turn left {DEGREES:num} degrees', 'turn ccw {DEGREES:num} degrees', 'turn ↺ {DEGREES:num} degrees', 'turn @turnleft {DEGREES:num} degrees'],
['motion_gotoxy', 's', 'go to x: {X:num} y: {Y:num}'],
['motion_goto', 's', 'go to {TO:m:motion_goto_menu:TO:target}'],
['motion_glidesecstoxy', 's', 'glide {SECS:num} secs to x: {X:num} y: {Y:num}', 'glide {SECS:num} seconds to x: {X:num} y: {Y:num}'],
['motion_glideto', 's', 'glide {SECS:num} secs to {TO:m:motion_glideto_menu:TO:target}', 'glide {SECS:num} seconds to {TO:m:motion_glideto_menu:TO:target}'],
['motion_pointindirection', 's', 'point in direction {DIRECTION:ang}'],
['motion_pointtowards', 's', 'point towards {TOWARDS:m:motion_pointtowards_menu:TOWARDS:target}'],
['motion_changexby', 's', 'change x by {DX:num}'],
['motion_setx', 's', 'set x to {X:num}'],
['motion_changeyby', 's', 'change y by {DY:num}'],
['motion_sety', 's', 'set y to {Y:num}'],
['motion_ifonedgebounce', 's', 'if on edge, bounce'],
['motion_setrotationstyle', 's', 'set rotation style {STYLE:f:lower}'],
['motion_xposition', 'r', 'x position'],
['motion_yposition', 'r', 'y position'],
['motion_direction', 'r', 'direction'],
// ---- Looks
['looks_sayforsecs', 's', 'say {MESSAGE:txt} for {SECS:num} seconds', 'say {MESSAGE:txt} for {SECS:num} secs'],
['looks_say', 's', 'say {MESSAGE:txt}'],
['looks_thinkforsecs', 's', 'think {MESSAGE:txt} for {SECS:num} seconds', 'think {MESSAGE:txt} for {SECS:num} secs'],
['looks_think', 's', 'think {MESSAGE:txt}'],
['looks_switchcostumeto', 's', 'switch costume to {COSTUME:m:looks_costume:COSTUME}'],
['looks_nextcostume', 's', 'next costume'],
['looks_switchbackdroptoandwait', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP} and wait'],
['looks_switchbackdropto', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP}'],
['looks_nextbackdrop', 's', 'next backdrop'],
['looks_changesizeby', 's', 'change size by {CHANGE:num}'],
['looks_setsizeto', 's', 'set size to {SIZE:num} %', 'set size to {SIZE:num}'],
['looks_changeeffectby', 's', 'change {EFFECT:f:effect!} effect by {CHANGE:num}'],
['looks_seteffectto', 's', 'set {EFFECT:f:effect!} effect to {VALUE:num}'],
['looks_cleargraphiceffects', 's', 'clear graphic effects'],
['looks_show', 's', 'show'],
['looks_hide', 's', 'hide'],
['looks_gotofrontback', 's', 'go to {FRONT_BACK:f:frontback} layer'],
['looks_goforwardbackwardlayers', 's', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layers', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layer'],
['looks_costumenumbername', 'r', 'costume {NUMBER_NAME:f:numname}'],
['looks_backdropnumbername', 'r', 'backdrop {NUMBER_NAME:f:numname}'],
['looks_size', 'r', 'size'],
// ---- Sound
['sound_playuntildone', 's', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU} until done'],
['sound_play', 's', 'start sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}'],
['sound_stopallsounds', 's', 'stop all sounds'],
['sound_changeeffectby', 's', 'change {EFFECT:f:seffect!} effect by {VALUE:num}'],
['sound_seteffectto', 's', 'set {EFFECT:f:seffect!} effect to {VALUE:num}'],
['sound_cleareffects', 's', 'clear sound effects'],
['sound_changevolumeby', 's', 'change volume by {VOLUME:num}'],
['sound_setvolumeto', 's', 'set volume to {VOLUME:num} %', 'set volume to {VOLUME:num}'],
['sound_volume', 'r', 'volume'],
// ---- Events
['event_whenflagclicked', 'h', 'when flag clicked', 'when green flag clicked', 'when @greenflag clicked', 'when ⚑ clicked', 'when gf clicked'],
['event_whenkeypressed', 'h', 'when {KEY_OPTION:f:key} key pressed'],
['event_whenthisspriteclicked', 'h', 'when this sprite clicked'],
['event_whenstageclicked', 'h', 'when stage clicked'],
['event_whenbackdropswitchesto', 'h', 'when backdrop switches to {BACKDROP:f}'],
['event_whengreaterthan', 'h', 'when {WHENGREATERTHANMENU:f:gt} > {VALUE:num}'],
['event_whenbroadcastreceived', 'h', 'when i receive {BROADCAST_OPTION:bc}'],
['event_broadcastandwait', 's', 'broadcast {BROADCAST_INPUT:bcm} and wait'],
['event_broadcast', 's', 'broadcast {BROADCAST_INPUT:bcm}'],
// ---- Control
['control_wait', 's', 'wait {DURATION:pnum} seconds', 'wait {DURATION:pnum} secs', 'wait {DURATION:pnum}'],
['control_repeat', 'c', 'repeat {TIMES:wnum}'],
['control_forever', 'cc', 'forever'],
['control_if', 'c', 'if {CONDITION:bool} then', 'if {CONDITION:bool}'],
['control_wait_until', 's', 'wait until {CONDITION:bool}'],
['control_repeat_until', 'c', 'repeat until {CONDITION:bool}'],
['control_while', 'c', 'while {CONDITION:bool}'],
['control_stop', 'cap', 'stop {STOP_OPTION:f:lower}'],
['control_start_as_clone', 'h', 'when i start as a clone'],
['control_create_clone_of', 's', 'create clone of {CLONE_OPTION:m:control_create_clone_of_menu:CLONE_OPTION:clone}'],
['control_delete_this_clone', 'cap', 'delete this clone'],
// ---- Sensing
['sensing_touchingcolor', 'b', 'touching color {COLOR:col}', 'touching colour {COLOR:col}'],
['sensing_touchingobject', 'b', 'touching {TOUCHINGOBJECTMENU:m:sensing_touchingobjectmenu:TOUCHINGOBJECTMENU:touch}'],
['sensing_coloristouchingcolor', 'b', 'color {COLOR:col} is touching {COLOR2:col}', 'colour {COLOR:col} is touching {COLOR2:col}'],
['sensing_distanceto', 'r', 'distance to {DISTANCETOMENU:m:sensing_distancetomenu:DISTANCETOMENU:touch}'],
['sensing_askandwait', 's', 'ask {QUESTION:txt} and wait'],
['sensing_answer', 'r', 'answer'],
['sensing_keypressed', 'b', 'key {KEY_OPTION:m:sensing_keyoptions:KEY_OPTION:key} pressed'],
['sensing_mousedown', 'b', 'mouse down'],
['sensing_mousex', 'r', 'mouse x'],
['sensing_mousey', 'r', 'mouse y'],
['sensing_setdragmode', 's', 'set drag mode {DRAG_MODE:f:lower}'],
['sensing_loudness', 'r', 'loudness'],
['sensing_timer', 'r', 'timer'],
['sensing_resettimer', 's', 'reset timer'],
['sensing_current', 'r', 'current {CURRENTMENU:f:current}'],
['sensing_dayssince2000', 'r', 'days since 2000'],
['sensing_username', 'r', 'username'],
// ---- Variables & lists (before operators so "[list v] contains" / "length of [list v]" win)
['data_setvariableto', 's', 'set {VARIABLE:var} to {VALUE:txt}'],
['data_changevariableby', 's', 'change {VARIABLE:var} by {VALUE:num}'],
['data_showvariable', 's', 'show variable {VARIABLE:var}'],
['data_hidevariable', 's', 'hide variable {VARIABLE:var}'],
['data_addtolist', 's', 'add {ITEM:txt} to {LIST:list}'],
['data_deletealloflist', 's', 'delete all of {LIST:list}'],
['data_deleteoflist', 's', 'delete {INDEX:int} of {LIST:list}'],
['data_insertatlist', 's', 'insert {ITEM:txt} at {INDEX:int} of {LIST:list}'],
['data_replaceitemoflist', 's', 'replace item {INDEX:int} of {LIST:list} with {ITEM:txt}'],
['data_itemoflist', 'r', 'item {INDEX:int} of {LIST:list}'],
['data_itemnumoflist', 'r', 'item # of {ITEM:txt} in {LIST:list}'],
['data_lengthoflist', 'r', 'length of {LIST:list}'],
['data_listcontainsitem', 'b', '{LIST:list} contains {ITEM:txt}'],
['data_showlist', 's', 'show list {LIST:list}'],
['data_hidelist', 's', 'hide list {LIST:list}'],
// ---- Operators
['operator_add', 'r', '{NUM1:num} + {NUM2:num}'],
['operator_subtract', 'r', '{NUM1:num} - {NUM2:num}'],
['operator_multiply', 'r', '{NUM1:num} * {NUM2:num}', '{NUM1:num} x {NUM2:num}', '{NUM1:num} × {NUM2:num}'],
['operator_divide', 'r', '{NUM1:num} / {NUM2:num}', '{NUM1:num} ÷ {NUM2:num}'],
['operator_random', 'r', 'pick random {FROM:num} to {TO:num}'],
['operator_gt', 'b', '{OPERAND1:txt} > {OPERAND2:txt}'],
['operator_lt', 'b', '{OPERAND1:txt} < {OPERAND2:txt}'],
['operator_equals', 'b', '{OPERAND1:txt} = {OPERAND2:txt}'],
['operator_and', 'b', '{OPERAND1:bool} and {OPERAND2:bool}'],
['operator_or', 'b', '{OPERAND1:bool} or {OPERAND2:bool}'],
['operator_not', 'b', 'not {OPERAND:bool}'],
['operator_join', 'r', 'join {STRING1:txt} {STRING2:txt}'],
['operator_letter_of', 'r', 'letter {LETTER:wnum} of {STRING:txt}'],
['operator_length', 'r', 'length of {STRING:txt}'],
['operator_contains', 'b', '{STRING1:txt} contains {STRING2:txt}'],
['operator_mod', 'r', '{NUM1:num} mod {NUM2:num}'],
['operator_round', 'r', 'round {NUM:num}'],
['operator_mathop', 'r', '{OPERATOR:f:mathop!} of {NUM:num}'],
['sensing_of', 'r', '{PROPERTY:f} of {OBJECT:m:sensing_of_object_menu:OBJECT:stage}'],
// ---- Pen extension (auto-loaded when used)
['pen_clear', 's', 'erase all', 'clear'],
['pen_stamp', 's', 'stamp'],
['pen_penDown', 's', 'pen down'],
['pen_penUp', 's', 'pen up'],
['pen_setPenColorToColor', 's', 'set pen color to {COLOR:col}', 'set pen colour to {COLOR:col}'],
['pen_changePenColorParamBy', 's', 'change pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} by {VALUE:num}'],
['pen_setPenColorParamTo', 's', 'set pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} to {VALUE:num}'],
['pen_changePenSizeBy', 's', 'change pen size by {SIZE:num}'],
['pen_setPenSizeTo', 's', 'set pen size to {SIZE:num}'],
];
const normWord = w => w.toLowerCase().replace(/[,?:]/g, '');
const SIG = new Map();
for (const [opcode, shape, ...pats] of SPECS) {
for (const pat of pats) {
const args = [], sig = [];
for (const tok of pat.split(/\s+/)) {
const m = tok.match(/^\{(\w+):(.+)\}$/);
if (m) { args.push(parseArgDef(m[1], m[2])); sig.push('_'); }
else { const w = normWord(tok); if (w) sig.push(w); }
}
const key = sig.join(' ');
if (!SIG.has(key)) SIG.set(key, []);
SIG.get(key).push({ opcode, shape, args });
}
}
function parseArgDef(name, t) {
let strict = false;
if (t.endsWith('!')) { strict = true; t = t.slice(0, -1); }
const p = t.split(':');
const d = { name, type: p[0], strict };
if (d.type === 'f') d.map = p[1];
if (d.type === 'm') { d.menu = p[1]; d.field = p[2]; d.map = p[3]; }
return d;
}
/* ---------- Extension blocks, read live from whatever extensions are loaded ---------- */
const DYN_DONE = new Set();
function addSig(key, spec) { if (!SIG.has(key)) SIG.set(key, []); SIG.get(key).push(spec); }
function dynArgDef(cat, name, ai) {
if (ai.menu) {
const mi = (cat.menuInfo || {})[ai.menu] || {};
const mapName = `ext_${cat.id}_${ai.menu}`;
let first = '';
if (Array.isArray(mi.items)) {
const o = {};
for (const it of mi.items) {
if (typeof it === 'string') { o[lc(it)] = it; if (!first) first = it; }
else if (it && typeof it === 'object') {
const v = String(it.value);
o[lc(String(it.text))] = v; o[lc(v)] = v;
o[lc(String(it.text).replace(/^\(\d+\)\s*/, ''))] = v; // "(2) Bass Drum" -> also "bass drum"
if (!first) first = v;
}
}
MAPS[mapName] = o;
}
const def = ai.defaultValue !== undefined ? String(ai.defaultValue) : first;
if (mi.acceptReporters) return { name, type: 'm', menu: `${cat.id}_menu_${ai.menu}`, field: ai.menu, map: mapName, def, strict: false };
return { name, type: 'f', map: mapName, strict: false };
}
const t = { number: 'num', angle: 'ang', string: 'txt', Boolean: 'bool', color: 'col', note: 'note', matrix: 'matrix' }[ai.type] || 'txt';
return { name, type: t, strict: false };
}
function refreshDynamicSpecs(vm) {
const cats = (vm && vm.runtime && vm.runtime._blockInfo) || [];
for (const cat of cats) {
for (const b of cat.blocks || []) {
const info = b && b.info;
if (!info || !info.opcode || typeof info.blockType !== 'string') continue;
const text = Array.isArray(info.text) ? info.text[0] : info.text;
if (typeof text !== 'string') continue;
const opcode = `${cat.id}_${info.opcode}`;
const key = opcode + '|' + text;
if (DYN_DONE.has(key)) continue;
DYN_DONE.add(key);
const bt = info.blockType;
const shape = bt === 'command' ? (info.isTerminal ? 'cap' : 's')
: bt === 'reporter' ? 'r'
: bt === 'Boolean' ? 'b'
: (bt === 'hat' || bt === 'event') ? 'h'
: bt === 'conditional' ? 'c'
: bt === 'loop' ? (info.isTerminal ? 'cc' : 'c')
: null;
if (!shape) continue;
const args = [], sig = [];
const re = /\[([^\]]+)\]|([^[\]]+)/g;
let m;
while ((m = re.exec(text))) {
if (m[1] !== undefined) {
const ai = (info.arguments || {})[m[1]] || {};
if (ai.type === 'image') continue; // icons, not inputs
args.push(dynArgDef(cat, m[1], ai));
sig.push('_');
} else {
for (const w of m[2].split(/\s+/)) { const nw = normWord(w); if (nw) sig.push(nw); }
}
}
if (!sig.length) continue;
addSig(sig.join(' '), { opcode, shape, args, branches: info.branchCount || (shape === 'c' || shape === 'cc' ? 1 : 0), ext: cat.id });
}
}
}
// Friendly names -> built-in extension ids
const EXT_NAMES = {
pen: 'pen', music: 'music', video: 'videoSensing', 'video sensing': 'videoSensing', videosensing: 'videoSensing',
'text to speech': 'text2speech', 'text-to-speech': 'text2speech', text2speech: 'text2speech', tts: 'text2speech',
translate: 'translate', 'makey makey': 'makeymakey', makeymakey: 'makeymakey',
'micro:bit': 'microbit', microbit: 'microbit', 'micro bit': 'microbit',
ev3: 'ev3', 'lego ev3': 'ev3', 'lego mindstorms ev3': 'ev3', boost: 'boost', 'lego boost': 'boost',
wedo: 'wedo2', wedo2: 'wedo2', 'lego wedo': 'wedo2', 'lego wedo 2.0': 'wedo2', 'lego education wedo 2.0': 'wedo2',
gdxfor: 'gdxfor', 'force and acceleration': 'gdxfor', 'go direct force & acceleration': 'gdxfor', 'force & acceleration': 'gdxfor',
};
function extensionRef(name) {
const raw = name.trim();
if (/^https?:\/\//i.test(raw) || raw.startsWith('data:')) return raw;
const k = lc(raw);
if (EXT_NAMES[k]) return EXT_NAMES[k];
if (raw.includes('/')) return `https://extensions.turbowarp.org/${raw.replace(/\.js$/, '')}.js`; // TurboWarp gallery, e.g. Lily/TempVariables2
return raw; // assume it's already an extension id
}
// If a line isn't recognized, these hints tell us which built-in extension it probably needs
const EXT_HINTS = [
['pen', /^(pen (up|down)|erase all|stamp\b|set pen |change pen )/],
['music', /^(play drum|rest for|play note|set instrument|set tempo|change tempo)\b|\(tempo\)/],
['videoSensing', /\bvideo\b/],
['text2speech', /^speak\b|set voice to|set language to/],
['translate', /\btranslate\b|\(language\)/],
['makeymakey', /pressed in order/],
['microbit', /button pressed|^display\b|clear display|\btilted\b|tilt angle|when pin\b/],
['gdxfor', /force sensor|\bforce\b|acceleration|spin speed|free fall|when shaken/],
];
/* =====================================================================
* 3. PARSER
* ===================================================================== */
class ParseError extends Error {}
const PErr = msg => new ParseError(msg);
// Is the "<" or ">" at s[j] a comparison operator (sitting between two inputs)?
function isOpAt(s, j, lo) {
let a = j - 1; while (a >= lo && s[a] === ' ') a--;
let b = j + 1; while (b < s.length && s[b] === ' ') b++;
if (a < lo || b >= s.length) return false;
const spaced = s[j - 1] === ' ' || s[j + 1] === ' ';
return spaced && ')]>'.includes(s[a]) && /[(\[<\d\-.]/.test(s[b]);
}
function findClose(s, i) {
const open = s[i];
if (open === '[') {
const j = s.indexOf(']', i + 1);
if (j < 0) throw PErr('Missing a closing "]"');
return j;
}
const close = open === '(' ? ')' : '>';
let j = i + 1;
while (j < s.length) {
const c = s[j];
if (c === '[' || c === '(') { j = findClose(s, j) + 1; continue; }
if (c === '<' && !isOpAt(s, j, i + 1)) { j = findClose(s, j) + 1; continue; }
if (c === close && !(c === '>' && isOpAt(s, j, i + 1))) return j;
j++;
}
throw PErr(`Missing a closing "${close}"`);
}
function parseParts(s) {
const parts = [];
let i = 0, word = '';
const flush = () => { for (const w of word.split(/\s+/)) if (w) parts.push({ t: 'w', v: w }); word = ''; };
while (i < s.length) {
const c = s[i];
if (c === '(' || c === '[' || (c === '<' && !isOpAt(s, i, 0))) {
flush();
const j = findClose(s, i);
parts.push({ t: c, raw: s.slice(i + 1, j) });
i = j + 1;
} else { word += c; i++; }
}
flush();
// Drop scratchblocks "::category" annotations
const out = [];
let skip = false;
for (const p of parts) {
if (p.t === 'w' && p.v.includes('::')) {
const pre = p.v.split('::')[0];
if (pre) out.push({ t: 'w', v: pre });
skip = true; continue;
}
if (skip && p.t === 'w') continue;
skip = false; out.push(p);
}
return out;
}
const NUM_RE = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;
function interpretArg(p, ctx) {
if (p.t === '[') {
const m = p.raw.match(/^(.*?)\s+v$/);
if (m) return { lit: true, value: m[1].trim(), menu: true };
return { lit: true, value: p.raw, menu: false };
}
if (p.t === '(') {
const raw = p.raw.trim();
if (raw === '' || NUM_RE.test(raw)) return { lit: true, value: raw, menu: false };
const m = raw.match(/^(.*?)\s+v$/);
if (m) return { lit: true, value: m[1].trim(), menu: true };
if (ctx.procArgs && ctx.procArgs.s.has(raw)) return { block: { kind: 'argr', name: raw, bool: false } };
const blk = tryParseBlock(raw, 'r', ctx);
if (blk) return { block: blk };
if (ctx.lists.has(raw) && !ctx.vars.has(raw)) return { block: { kind: 'listr', name: raw } };
return { block: { kind: 'var', name: raw } };
}
if (p.t === '<') {
const raw = p.raw.trim();
if (!raw) return { empty: true };
if (ctx.procArgs && ctx.procArgs.b.has(raw)) return { block: { kind: 'argr', name: raw, bool: true } };
const blk = tryParseBlock(raw, 'b', ctx);
if (blk) return { block: blk };
throw PErr(`I don't recognize the boolean block <${raw}>`);
}
throw PErr('Unexpected input');
}
function argFits(d, a) {
if (['f', 'var', 'list', 'bc'].includes(d.type)) {
if (d.type === 'var' && a.block && a.block.kind === 'var') return true;
if (!a.lit) return false;
if (d.type === 'list' && !a.menu) return false;
if (d.strict && !mapHas(d.map, a.value)) return false;
return true;
}
if (d.type === 'bool') return !a.lit;
return true;
}
const SHAPES_FOR = { s: ['s', 'h', 'c', 'cc', 'cap'], r: ['r', 'b'], b: ['b'] };
function matchOnce(parts, want, ctx) {
const sig = parts.map(p => (p.t === 'w' ? normWord(p.v) : '_')).filter(Boolean).join(' ');
const argParts = parts.filter(p => p.t !== 'w');
const cands = (SIG.get(sig) || []).filter(c => SHAPES_FOR[want].includes(c.shape));
let args = null;
for (const c of cands) {
if (!args) args = argParts.map(p => interpretArg(p, ctx));
if (c.args.every((d, k) => argFits(d, args[k]))) {
return { kind: 'std', opcode: c.opcode, shape: c.shape, spec: c, args };
}
}
if (want === 's' && ctx.procs.has(sig)) {
const proc = ctx.procs.get(sig);
if (!args) args = argParts.map(p => interpretArg(p, ctx));
return { kind: 'call', shape: 's', proc, args };
}
return null;
}
function matchParts(parts, want, ctx) {
let r = matchOnce(parts, want, ctx);
if (!r) {
// Be forgiving: "move 10 steps" -> "move (10) steps"
const alt = parts.map(p => (p.t === 'w' && NUM_RE.test(p.v) ? { t: '(', raw: p.v } : p));
if (alt.some((p, i) => p !== parts[i])) r = matchOnce(alt, want, ctx);
}
return r;
}
function tryParseBlock(raw, want, ctx) { return matchParts(parseParts(raw), want, ctx); }
function parseDefine(line) {
let body = line.replace(/^define\s+/i, '');
let warp = false;
if (/run without screen refresh/i.test(body)) {
warp = true;
body = body.replace(/(::)?\s*run without screen refresh/i, '');
}
const parts = parseParts(body);
const code = [], names = [], types = [], sig = [];
for (const p of parts) {
if (p.t === 'w') { code.push(p.v); const w = normWord(p.v); if (w) sig.push(w); }
else {
const bool = p.t === '<';
code.push(bool ? '%b' : '%s');
names.push(p.raw.trim());
types.push(bool ? 'b' : 's');
sig.push('_');
}
}
if (!code.length) throw PErr('"define" needs a block name');
return {
proccode: code.join(' '), names, types, warp, sig: sig.join(' '),
ids: names.map(() => uid()), fromText: true,
};
}
function parseLine(text, ctx, isFirst) {
if (/^define\s/i.test(text)) {
const d = parseDefine(text);
const proc = ctx.procs.get(d.sig) || d;
if (isFirst) {
ctx.procArgs = { s: new Set(), b: new Set() };
proc.names.forEach((n, k) => ctx.procArgs[proc.types[k] === 'b' ? 'b' : 's'].add(n));
}
return { kind: 'define', shape: 'h', proc };
}
// A lone reporter / boolean on its own line
if ((text[0] === '(' || text[0] === '<') && findClose(text, 0) === text.length - 1) {
const a = interpretArg({ t: text[0], raw: text.slice(1, -1) }, ctx);
if (a.block) return Object.assign({ shape: a.block.shape || 'r' }, a.block);
}
const node = matchParts(parseParts(text), 's', ctx);
if (!node) throw PErr(`I don't recognize the block "${text}"`);
if (node.opcode === 'control_stop') {
const v = lc(node.args[0].value);
if (v.startsWith('other')) { node.shape = 's'; node.hasNext = true; }
}
return node;
}
function parseStack(L, i, ctx, depth) {
const nodes = [];
while (i < L.length) {
const ln = L[i];
if (ln.blank) {
if (depth === 0 && nodes.length) return { nodes, i: i + 1, term: null };
i++; continue;
}
const low = ln.text.toLowerCase();
if (low === 'end') {
if (depth === 0) { i++; continue; }
return { nodes, i: i + 1, term: 'end' };
}
if (low === 'else') {
if (depth === 0) throw lineErr(ln.n, '"else" without an "if"');
return { nodes, i: i + 1, term: 'else' };
}
if (depth === 0 && nodes.length === 0) ctx.procArgs = null;
let node;
try { node = parseLine(ln.text, ctx, depth === 0 && nodes.length === 0); }
catch (e) { throw lineErr(ln.n, e.message); }
// A hat block in the middle of a script starts a new script
if (node.shape === 'h' && depth === 0 && nodes.length) return { nodes, i, term: null };
node.line = ln.n; node.text = ln.text;
i++;
if (node.shape === 'c' || node.shape === 'cc') {
const r = parseStack(L, i, ctx, depth + 1);
node.sub = r.nodes; i = r.i;
if (r.term === 'else') {
const twoBranch = node.spec && node.spec.branches >= 2;
if (node.opcode !== 'control_if' && !twoBranch) throw lineErr(ln.n, '"else" only works with an "if" block');
if (node.opcode === 'control_if') node.opcode = 'control_if_else';
const r2 = parseStack(L, i, ctx, depth + 1);
node.sub2 = r2.nodes; i = r2.i;
}
}
nodes.push(node);
}
return { nodes, i, term: null };
}
function lineErr(n, msg) { const e = new ParseError(`Line ${n}: ${msg}`); e.line = n; return e; }
function validate(nodes, top) {
nodes.forEach((nd, k) => {
if (nd.shape === 'h' && !(top && k === 0)) throw lineErr(nd.line, `"${nd.text}" is a hat block, so it has to be at the top of a script`);
if ((nd.shape === 'r' || nd.shape === 'b') && (!top || nodes.length > 1)) throw lineErr(nd.line, `"${nd.text}" is a reporter, so it can't be stacked with other blocks`);
if ((nd.shape === 'cap' || nd.shape === 'cc') && k < nodes.length - 1) throw lineErr(nodes[k + 1].line, `Nothing can go after "${nd.text}"`);
if (nd.sub) validate(nd.sub, false);
if (nd.sub2) validate(nd.sub2, false);
});
}
function parseText(text, ctx) {
const L = text.replace(/\r/g, '').split('\n').map((t, idx) => {
const s = t.replace(/\s\/\/.*$/, '').trim();
if (s.startsWith('//')) return null; // comment-only lines are skipped (they don't split scripts)
return s ? { text: s, n: idx + 1 } : { blank: true, n: idx + 1 };
}).filter(Boolean);
// Pre-pass: collect custom block definitions so calls can appear anywhere
for (const ln of L) {
if (!ln.blank && /^define\s/i.test(ln.text)) {
try { const d = parseDefine(ln.text); ctx.procs.set(d.sig, d); }
catch (e) { throw lineErr(ln.n, e.message); }
}
}
const scripts = [];
let i = 0;
while (i < L.length) {
if (L[i].blank) { i++; continue; }
const r = parseStack(L, i, ctx, 0);
if (r.nodes.length) { validate(r.nodes, true); scripts.push(r.nodes); }
if (r.i === i) i++; else i = r.i;
}
return scripts;
}
/* =====================================================================
* 4. XML GENERATION (Blockly XML, which Scratch turns into real blocks)
* ===================================================================== */
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
function uid() {
const c = '!#%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let s = ''; for (let i = 0; i < 20; i++) s += c[Math.floor(Math.random() * c.length)];
return s;
}
const SHADOW = {
num: ['math_number', 'NUM'], pnum: ['math_positive_number', 'NUM'], wnum: ['math_whole_number', 'NUM'],
int: ['math_integer', 'NUM'], ang: ['math_angle', 'NUM'], txt: ['text', 'TEXT'], col: ['colour_picker', 'COLOUR'],
note: ['note', 'NOTE'], matrix: ['matrix', 'MATRIX'],
};
function normColor(v) {
let m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
if (m) return '#' + m[1].toLowerCase();
m = String(v).trim().match(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (m) return ('#' + m[1] + m[1] + m[2] + m[2] + m[3] + m[3]).toLowerCase();
return '#000000';
}
function varFieldXml(g, field, name, type) {
return `<field name="${field}" id="${esc(g.varId(name, type))}" variabletype="${type}">${esc(name)}</field>`;
}
const valueXml = (name, inner) => `<value name="${name}">${inner}</value>`;
function argXml(d, a, g) {
const blk = a.block ? blockXml(a.block, g, '') : '';
switch (d.type) {
case 'f': return `<field name="${d.name}">${esc(mapValue(d.map, a.value))}</field>`;
case 'var': return varFieldXml(g, d.name, a.lit ? a.value : a.block.name, '');
case 'list': return varFieldXml(g, d.name, a.value, 'list');
case 'bc': return varFieldXml(g, d.name, a.value, 'broadcast_msg');
case 'bool': return blk ? valueXml(d.name, blk) : '';
case 'bcm': {
const name = a.lit && a.value ? a.value : 'message1';
return valueXml(d.name, `<shadow type="event_broadcast_menu">${varFieldXml(g, 'BROADCAST_OPTION', name, 'broadcast_msg')}</shadow>${blk}`);
}
case 'm': {
const v = a.lit ? mapValue(d.map, a.value) : (DEFAULTS[d.map] || d.def || '');
return valueXml(d.name, `<shadow type="${d.menu}"><field name="${d.field}">${esc(v)}</field></shadow>${blk}`);
}
default: {
const [st, f] = SHADOW[d.type];
let v = a.lit ? a.value : '';
if (d.type === 'col') v = normColor(v);
return valueXml(d.name, `<shadow type="${st}"><field name="${f}">${esc(v)}</field></shadow>${blk}`);
}
}
}
function procMutation(proc, withNames) {
let m = `<mutation proccode="${esc(proc.proccode)}" argumentids="${esc(JSON.stringify(proc.ids))}"`;
if (withNames) {
m += ` argumentnames="${esc(JSON.stringify(proc.names))}" argumentdefaults="${esc(JSON.stringify(proc.types.map(t => (t === 'b' ? 'false' : ''))))}"`;
}
return m + ` warp="${proc.warp ? 'true' : 'false'}"></mutation>`;
}
function blockXml(node, g, next) {
const nx = next ? `<next>${next}</next>` : '';
g.opcodes.add(node.opcode || node.kind);
switch (node.kind) {
case 'var':
return `<block type="data_variable">${varFieldXml(g, 'VARIABLE', node.name, '')}</block>`;
case 'listr':
return `<block type="data_listcontents">${varFieldXml(g, 'LIST', node.name, 'list')}</block>`;
case 'argr':
return `<block type="${node.bool ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(node.name)}</field></block>`;
case 'define': {
const p = node.proc;
const args = p.ids.map((id, k) =>
valueXml(esc(id), `<shadow type="${p.types[k] === 'b' ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(p.names[k])}</field></shadow>`)
).join('');
return `<block type="procedures_definition"><value name="custom_block"><shadow type="procedures_prototype">${procMutation(p, true)}${args}</shadow></value>${nx}</block>`;
}
case 'call': {
const p = node.proc;
const args = p.ids.map((id, k) => {
const a = node.args[k] || { lit: true, value: '' };
const blk = a.block ? blockXml(a.block, g, '') : '';
if (p.types[k] === 'b') return blk ? valueXml(esc(id), blk) : '';
const sh = p.types[k] === 'n' ? ['math_number', 'NUM'] : ['text', 'TEXT'];
return valueXml(esc(id), `<shadow type="${sh[0]}"><field name="${sh[1]}">${esc(a.lit ? a.value : '')}</field></shadow>${blk}`);
}).join('');
return `<block type="procedures_call">${procMutation(p, false)}${args}${nx}</block>`;
}
default: {
let inner = '';
if (node.opcode === 'control_stop') inner += `<mutation hasnext="${node.hasNext ? 'true' : 'false'}"></mutation>`;
node.spec.args.forEach((d, k) => { inner += argXml(d, node.args[k], g); });
if (node.sub && node.sub.length) inner += `<statement name="SUBSTACK">${stackXml(node.sub, g)}</statement>`;
if (node.sub2 && node.sub2.length) inner += `<statement name="SUBSTACK2">${stackXml(node.sub2, g)}</statement>`;
return `<block type="${node.opcode}">${inner}${nx}</block>`;
}
}
}
function stackXml(nodes, g) {
let xml = '';
for (let k = nodes.length - 1; k >= 0; k--) xml = blockXml(nodes[k], g, xml);
return xml;
}
/* =====================================================================
* 5. EDITOR HOOKUP
* ===================================================================== */
function getEditor() {
let el = document.querySelector('[class*="blocks_blocks"]');
if (!el) { const inj = document.querySelector('.injectionDiv'); el = inj && inj.parentElement; }
for (let node = el, up = 0; node && up < 6; up++, node = node.parentElement) {
const key = Object.keys(node).find(k => k.startsWith('__reactInternalInstance$') || k.startsWith('__reactFiber$'));
if (!key) continue;
for (let f = node[key]; f; f = f.return) {
const sn = f.stateNode;
if (sn && sn.ScratchBlocks && sn.workspace) return { SB: sn.ScratchBlocks, ws: sn.workspace, vm: sn.props && sn.props.vm };
}
}
return null;
}
function xmlToDom(SB, text) {
if (SB.Xml && typeof SB.Xml.textToDom === 'function') return SB.Xml.textToDom(text);
if (SB.utils && SB.utils.xml && typeof SB.utils.xml.textToDom === 'function') return SB.utils.xml.textToDom(text);
const doc = new DOMParser().parseFromString(text, 'text/xml');
const err = doc.getElementsByTagName('parsererror')[0];
if (err) throw new Error('Internal XML error: ' + err.textContent);
return doc.documentElement;
}
function buildCtx(ws) {
const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
try {
for (const v of ws.getAllVariables()) {
if (v.type === '') ctx.vars.add(v.name);
if (v.type === 'list') ctx.lists.add(v.name);
}
} catch (e) { /* ignore */ }
try {
for (const b of ws.getAllBlocks()) {
if (b.type !== 'procedures_prototype') continue;
const m = b.mutationToDom();
const proccode = m.getAttribute('proccode');
const ids = JSON.parse(m.getAttribute('argumentids') || '[]');
const names = JSON.parse(m.getAttribute('argumentnames') || '[]');
const toks = proccode.split(' ');
const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
ctx.procs.set(sig, { proccode, ids, names, types, warp: m.getAttribute('warp') === 'true', sig });
}
} catch (e) { /* ignore */ }
return ctx;
}
function collectOpcodes(nodes, set) {
for (const n of nodes) {
if (n.opcode) set.add(n.opcode);
(n.args || []).forEach(a => a.block && collectOpcodes([a.block], set));
if (n.sub) collectOpcodes(n.sub, set);
if (n.sub2) collectOpcodes(n.sub2, set);
}
return set;
}
/* ---------- sections: "sprite: Name" / "stage:" ---------- */
const HEAD_SPRITE = /^sprite\s*:\s*(.+?)\s*$/i;
const HEAD_STAGE = /^stage\s*:?\s*$/i;
const DIRECTIVE = /^(costume|backdrop|sound|at|position|size|direction|hidden|visible|draggable|extensions?)\s*:\s*(.*)$/i;
function splitSections(text) {
const lines = text.replace(/\r/g, '').split('\n');
const secs = [];
let cur = { kind: 'current', name: null, line: 0, owned: new Set(), directives: [] };
secs.push(cur);
lines.forEach((raw, idx) => {
const t = raw.trim();
let m;
if ((m = t.match(HEAD_SPRITE))) {
cur = { kind: 'sprite', name: m[1], line: idx + 1, owned: new Set(), directives: [] };
secs.push(cur); return;
}
if (HEAD_STAGE.test(t)) {
cur = { kind: 'stage', name: 'Stage', line: idx + 1, owned: new Set(), directives: [] };
secs.push(cur); return;
}
if ((m = t.match(DIRECTIVE))) { cur.directives.push({ key: m[1].toLowerCase(), val: m[2].trim(), n: idx + 1 }); return; }
cur.owned.add(idx);
});
for (const s of secs) s.text = lines.map((l, i) => (s.owned.has(i) ? l : '//')).join('\n');
return secs.filter(s => s.kind !== 'current' || s.directives.length || /\S/.test(s.text.replace(/^\/\/$/gm, '')));
}
/* ---------- costumes made from simple shapes ---------- */
const CAT = [
{ name: 'costume1', assetId: 'bcf454acf82e4504149f7ffe07081dbc', dataFormat: 'svg', cx: 48, cy: 50 },
{ name: 'costume2', assetId: '0fb9be3e8397c983338cb71dc84d0b25', dataFormat: 'svg', cx: 46, cy: 53 },
];
const SOUNDS = {
pop: { assetId: '83a9787d4cb6f3b7632b4ddfebf74367', dataFormat: 'wav', rate: 48000, sampleCount: 1032 },
meow: { assetId: '83c36d806dc92327b9e7049a565c6bff', dataFormat: 'wav', rate: 48000, sampleCount: 40682 },
};
const COLOR_RE = /^(#[0-9a-f]{3,8}|[a-z]+)$/i;
function tokenizeArgs(s) {
const out = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m;
while ((m = re.exec(s))) out.push(m[1] !== undefined ? { q: m[1] } : m[2] !== undefined ? { q: m[2] } : { w: m[3].replace(/,$/, '') });
return out;
}
function parseLook(val, isStage, n) {
let name = null, body = val;
const eq = val.match(/^([^=]+?)\s*=\s*(.+)$/);
if (eq) { name = eq[1].trim(); body = eq[2]; }
const toks = tokenizeArgs(body);
if (!toks.length) throw lineErr(n, 'Missing a costume description');
let shape = (toks[0].w || '').toLowerCase();
let rest = toks.slice(1);
if (!toks[0].w || toks[0].w.startsWith('#')) {
shape = isStage ? 'color' : 'square'; rest = toks; // e.g. "backdrop: #000000"
}
const colors = [], nums = []; let text = null;
for (const t of rest) {
if (t.q !== undefined) { text = t.q; continue; }
if (NUM_RE.test(t.w)) nums.push(Number(t.w));
else if (COLOR_RE.test(t.w)) colors.push(t.w);
else if (shape === 'text' && text === null) text = t.w;
else throw lineErr(n, `I don't understand "${t.w}" (colors look like #ff0000 or red)`);
}
const look = { name, shape, colors, nums, text };
if (isStage && !['color', 'colour', 'solid', 'gradient'].includes(shape)) throw lineErr(n, 'Backdrops can be: #color, color #hex, or gradient #top #bottom');
if (!isStage && !['circle', 'square', 'rect', 'rectangle', 'triangle', 'star', 'text', 'cat', 'cat2'].includes(shape)) throw lineErr(n, `Unknown costume shape "${shape}". Try circle, square, rect, triangle, star, text, or cat`);
return look;
}
function lookSvg(l) {
const c1 = l.colors[0] || (l.shape === 'gradient' ? '#4c97ff' : l.shape.startsWith('colo') || l.shape === 'solid' ? '#ffffff' : '#4c97ff');
const stroke = 'stroke="rgba(0,0,0,0.35)" stroke-width="2"';
const svg = (w, h, inner) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${inner}</svg>`;
const p = 2;
switch (l.shape) {
case 'color': case 'colour': case 'solid':
return { svg: svg(480, 360, `<rect width="480" height="360" fill="${c1}"/>`), cx: 240, cy: 180 };
case 'gradient': {
const c2 = l.colors[1] || '#ffffff';
return { svg: svg(480, 360, `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs><rect width="480" height="360" fill="url(#g)"/>`), cx: 240, cy: 180 };
}
case 'circle': { const d = l.nums[0] || 40, w = d + p * 2; return { svg: svg(w, w, `<circle cx="${w / 2}" cy="${w / 2}" r="${d / 2}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
case 'square': { const s = l.nums[0] || 40, w = s + p * 2; return { svg: svg(w, w, `<rect x="${p}" y="${p}" width="${s}" height="${s}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
case 'rect': case 'rectangle': {
const rw = l.nums[0] || 80, rh = l.nums[1] || 20, w = rw + p * 2, h = rh + p * 2;
return { svg: svg(w, h, `<rect x="${p}" y="${p}" width="${rw}" height="${rh}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: h / 2 };
}
case 'triangle': { // points right, so it faces the way the sprite moves
const s = l.nums[0] || 40, w = s + p * 2;
return { svg: svg(w, w, `<polygon points="${p},${p} ${s + p},${s / 2 + p} ${p},${s + p}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 };
}
case 'star': {
const s = l.nums[0] || 40, w = s + p * 2, R = s / 2, r = R * 0.45, cx = w / 2;
const pts = [];
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r : R; pts.push(`${(cx + rr * Math.cos(a)).toFixed(2)},${(cx + rr * Math.sin(a)).toFixed(2)}`); }
return { svg: svg(w, w, `<polygon points="${pts.join(' ')}" fill="${c1}" ${stroke}/>`), cx, cy: cx };
}
case 'text': {
const t = l.text || 'Text', f = l.nums[0] || 24;
const w = Math.ceil(t.length * f * 0.62) + 8, h = Math.ceil(f * 1.4);
return { svg: svg(w, h, `<text x="${w / 2}" y="${(f * 1.05).toFixed(1)}" font-family="Sans Serif" font-size="${f}" font-weight="bold" text-anchor="middle" fill="${l.colors[0] || '#000000'}">${esc(t)}</text>`), cx: w / 2, cy: h / 2 };
}
}
return null;
}
function makeCostume(vm, look, defaultName) {
if (look.shape === 'cat' || look.shape === 'cat2') {
const c = CAT[look.shape === 'cat' ? 0 : 1];
return { name: look.name || defaultName, assetId: c.assetId, dataFormat: 'svg', md5ext: c.assetId + '.svg', cx: c.cx, cy: c.cy, asset: null };
}
const { svg, cx, cy } = lookSvg(look);
const st = vm.runtime.storage;
const data = new TextEncoder().encode(svg);
const asset = st.createAsset(st.AssetType.ImageVector, st.DataFormat.SVG, data, null, true);
// Cache it so a new sprite's JSON can reference it by id
try {
if (typeof st.cache === 'function') st.cache(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
else if (st.builtinHelper && st.builtinHelper._store) st.builtinHelper._store(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
} catch (e) { /* ignore */ }
return { name: look.name || defaultName, assetId: asset.assetId, dataFormat: 'svg', md5ext: asset.assetId + '.svg', cx, cy, asset };
}
function findSprite(vm, name) {
return vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name === name) ||
vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name.toLowerCase() === name.toLowerCase());
}
function ctxFromVM(vm, target) {
const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
const stage = vm.runtime.getTargetForStage();
for (const t of [stage, target]) {
if (!t) continue;
for (const v of Object.values(t.variables || {})) {
if (v.type === '') ctx.vars.add(v.name);
if (v.type === 'list') ctx.lists.add(v.name);
}
}
if (target && target.blocks && target.blocks._blocks) {
for (const b of Object.values(target.blocks._blocks)) {
if (b.opcode !== 'procedures_prototype' || !b.mutation) continue;
try {
const proccode = b.mutation.proccode;
const ids = JSON.parse(b.mutation.argumentids || '[]');
const names = JSON.parse(b.mutation.argumentnames || '[]');
const toks = proccode.split(' ');
const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
ctx.procs.set(sig, { proccode, ids, names, types, warp: String(b.mutation.warp) === 'true', sig });
} catch (e) { /* ignore */ }
}
}
return ctx;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const yes = v => /^(yes|true|on|1)$/i.test(v.trim());
const plainId = () => { const c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let r = ''; for (let i = 0; i < 20; i++) r += c[Math.floor(Math.random() * c.length)]; return r; };
function countRows(nodes) {
let n = 0;
for (const nd of nodes) { n += 1; if (nd.sub) n += countRows(nd.sub) + 1; if (nd.sub2) n += countRows(nd.sub2) + 1; }
return n;
}
// Put scripts straight into a sprite's (or the stage's) code in the project,
// without needing that sprite to be open in the editor.
function insertScriptsVM(vm, target, scripts, opts, created) {
const stage = vm.runtime.getTargetForStage();
const g = {
opcodes: new Set(),
varId(name, type) {
let v = target.lookupVariableByNameAndType(name, type);
if (!v) {
const id = plainId();
const owner = (opts.local && type !== 'broadcast_msg' && !target.isStage) ? target : stage;
owner.createVariable(id, name, type);
v = owner.variables[id];
if (type !== 'broadcast_msg') created.add(name);
}
return v.id;
},
};
// Place new scripts to the right of any code already there
let x = 40, y = 40;
const existing = (target.blocks._scripts || []).map(id => target.blocks._blocks[id]).filter(Boolean);
if (existing.length) x = Math.max(...existing.map(b => Number(b.x) || 0)) + 520;
for (const sc of scripts) {
let xml = stackXml(sc, g);
const topId = plainId();
let first = true;
xml = xml.replace(/<(block|shadow) type=/g, (m, tag) => {
if (first) { first = false; return `<${tag} id="${topId}" x="${Math.round(x)}" y="${Math.round(y)}" type=`; }
return `<${tag} id="${plainId()}" type=`;
});
target.blocks.blocklyListen({ type: 'create', blockId: topId, xml: { outerHTML: xml } });
y += countRows(sc) * 44 + 70;
}
}
async function createBlocks(text, opts) {
const ed = getEditor();
if (!ed) throw new Error("Couldn't find the Scratch code area. Open the project in the editor (See inside) and stay on the Code tab.");
const vm = ed.vm;
if (!vm) throw new Error("Couldn't reach the Scratch project (VM).");
const stage = vm.runtime.getTargetForStage();
// ---- 1. Parse everything first; nothing changes if there's a mistake
const secs = splitSections(text);
if (!secs.length) throw new Error('Nothing to build. Type some blocks first.');
// Extensions: load any listed with "extension: name", then read their blocks
const em = vm.extensionManager;
const isLoaded = id => { try { return em.isExtensionLoaded(id); } catch (e) { return false; } };
const addedExt = [];
async function loadExt(ref, n) {
if (!/^(https?:|data:)/i.test(ref) && isLoaded(ref)) return;
try { await em.loadExtensionURL(ref); }
catch (e) { throw lineErr(n, `Couldn't load the extension "${ref}"${e && e.message ? ': ' + e.message : ''}`); }
addedExt.push(ref);
await sleep(150);
}
for (const sec of secs) {
for (const d of sec.directives) {
if (d.key !== 'extension' && d.key !== 'extensions') continue;
for (const name of d.val.split(',').map(x => x.trim()).filter(Boolean)) await loadExt(extensionRef(name), d.n);
}
}
refreshDynamicSpecs(vm);
const parseAll = () => {
for (const sec of secs) {
const target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
const isStage = sec.kind === 'stage' || (sec.kind === 'current' && target && target.isStage);
sec.looks = []; sec.sounds = []; sec.props = {};
for (const d of sec.directives) {
if (d.key === 'costume' || d.key === 'backdrop') {
if (isStage && d.key === 'costume') throw lineErr(d.n, 'The stage has backdrops, not costumes. Use "backdrop:"');
if (!isStage && d.key === 'backdrop') throw lineErr(d.n, '"backdrop:" goes under "stage:"');
sec.looks.push(parseLook(d.val, isStage, d.n));
} else if (d.key === 'sound') {
const k = d.val.toLowerCase();
if (!SOUNDS[k]) throw lineErr(d.n, 'Built-in sounds you can add: pop, meow');
sec.sounds.push(k);
} else if (d.key === 'extension' || d.key === 'extensions') {
// handled before parsing
} else {
if (isStage) throw lineErr(d.n, `"${d.key}:" only works for sprites`);
if (d.key === 'at' || d.key === 'position') {
const nums = d.val.split(/[\s,]+/).filter(Boolean).map(Number);
if (nums.length !== 2 || nums.some(isNaN)) throw lineErr(d.n, 'Use "at: x, y", like "at: 0, -120"');
sec.props.xy = nums;
} else if (d.key === 'size' || d.key === 'direction') {
const v = Number(d.val); if (isNaN(v)) throw lineErr(d.n, `"${d.key}:" needs a number`);
sec.props[d.key] = v;
} else if (d.key === 'hidden') sec.props.visible = !yes(d.val);
else if (d.key === 'visible') sec.props.visible = yes(d.val);
else if (d.key === 'draggable') sec.props.draggable = yes(d.val);
}
}
sec.scripts = parseText(sec.text, ctxFromVM(vm, target));
}
};
// If a line isn't recognized, see if a built-in extension would know it, add it, and try again
const srcLines = text.replace(/\r/g, '').split('\n');
for (let attempt = 0; ; attempt++) {
try { parseAll(); break; } catch (e) {
if (!(e instanceof ParseError) || !e.line || attempt > 10) throw e;
const lineText = lc(srcLines[e.line - 1] || '');
const hit = EXT_HINTS.find(([id, re]) => !isLoaded(id) && re.test(lineText));
if (!hit) throw e;
await loadExt(hit[0], e.line);
refreshDynamicSpecs(vm);
}
}
// ---- 2. Build
const created = new Set(), report = [], cleared = new Set();
let total = 0;
for (const sec of secs) {
let target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
let isNew = false;
if (sec.kind === 'sprite' && !target) {
const looks = sec.looks.length ? sec.looks : [{ shape: 'cat' }, { shape: 'cat2', name: 'costume2' }];
const costumes = looks.map((l, i) => makeCostume(vm, l, `costume${i + 1}`));
const p = sec.props;
const json = {
isStage: false, name: sec.name, variables: {}, lists: {}, broadcasts: {}, blocks: {}, comments: {},
currentCostume: 0,
costumes: costumes.map(c => ({ name: c.name, bitmapResolution: 1, dataFormat: c.dataFormat, assetId: c.assetId, md5ext: c.md5ext, rotationCenterX: c.cx, rotationCenterY: c.cy })),
sounds: sec.sounds.map(k => ({ name: k, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, format: '', rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount, md5ext: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat })),
volume: 100, visible: p.visible !== false,
x: p.xy ? p.xy[0] : Math.round(Math.random() * 200 - 100), y: p.xy ? p.xy[1] : Math.round(Math.random() * 160 - 80),
size: p.size || 100, direction: p.direction || 90, draggable: !!p.draggable, rotationStyle: 'all around',
};
const before = new Set(vm.runtime.targets.map(t => t.id));
await vm.addSprite(JSON.stringify(json));
target = findSprite(vm, sec.name) || vm.runtime.targets.find(t => t.isOriginal && !t.isStage && !before.has(t.id));
if (!target) throw lineErr(sec.line, `Scratch didn't create the sprite "${sec.name}"`);
isNew = true;
report.push(`made sprite "${target.sprite.name}"`);
} else {
if (!target) continue;
// Add costumes/backdrops/sounds that aren't there yet
let firstName = null;
for (let i = 0; i < sec.looks.length; i++) {
const base = target.isStage ? 'backdrop' : 'costume';
const c = makeCostume(vm, sec.looks[i], `${base}${target.getCostumes().length + 1}`);
if (!firstName) firstName = c.name;
if (target.getCostumeIndexByName(c.name) >= 0) continue;
await vm.addCostume(c.md5ext, {
name: c.name, dataFormat: c.dataFormat, asset: c.asset || undefined, md5: c.md5ext, assetId: c.assetId,
rotationCenterX: c.cx, rotationCenterY: c.cy, bitmapResolution: 1, skinId: null,
}, target.id);
}
if (firstName) { const ix = target.getCostumeIndexByName(firstName); if (ix >= 0) target.setCostume(ix); }
for (const k of sec.sounds) {
if (target.sprite.sounds.some(s => s.name === k)) continue;
await vm.addSound({ name: k, md5: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount }, target.id);
}
const p = sec.props;
if (p.xy) target.setXY(p.xy[0], p.xy[1]);
if (p.size !== undefined) target.setSize(p.size);
if (p.direction !== undefined) target.setDirection(p.direction);
if (p.visible !== undefined) target.setVisible(p.visible);
if (p.draggable !== undefined) target.setDraggable(p.draggable);
if (sec.looks.length || sec.sounds.length || Object.keys(p).length) report.push(`updated ${target.isStage ? 'the stage' : `"${target.sprite.name}"`}`);
}
if (sec.scripts.length) {
if (opts.replace && !isNew && !cleared.has(target.id)) {
cleared.add(target.id);
for (const id of (target.blocks._scripts || []).slice()) target.blocks.deleteBlock(id);
}
insertScriptsVM(vm, target, sec.scripts, opts, created);
total += sec.scripts.length;
report.push(`${sec.scripts.length} script${sec.scripts.length === 1 ? '' : 's'} → ${target.isStage ? 'Stage' : target.sprite.name}`);
}
}
try { vm.runtime.emitProjectChanged(); } catch (e) { /* ignore */ }
try { vm.emitTargetsUpdate(); } catch (e) { /* ignore */ }
try { vm.emitWorkspaceUpdate(); } catch (e) { /* ignore */ }
if (addedExt.length) report.unshift(`added extension${addedExt.length === 1 ? '' : 's'}: ${addedExt.join(', ')}`);
return { count: total, created: [...created], report };
}
/* =====================================================================
* 6. UI
* ===================================================================== */
const EXAMPLE = `// A whole game in one script: Catch the falling ball!
stage:
backdrop: Sky = gradient #7ec8ff #ffffff
sprite: Paddle
costume: paddle = rect #3366ff 100 18
at: 0, -150
when flag clicked
go to x: (0) y: (-150)
set [score v] to (0)
forever
if <key (left arrow v) pressed?> then
change x by (-9)
end
if <key (right arrow v) pressed?> then
change x by (9)
end
end
sprite: Ball
costume: ball = circle #ff4444 26
sound: pop
when flag clicked
set [speed v] to (4)
go to x: (pick random (-200) to (200)) y: (170)
forever
change y by ((0) - (speed))
if <touching (Paddle v)?> then
change [score v] by (1)
change [speed v] by (0.5)
start sound (pop v)
go to x: (pick random (-200) to (200)) y: (170)
end
if <(y position) < (-170)> then
broadcast [game over v]
stop [this script v]
end
end
sprite: GameOver
costume: text "GAME OVER" #222222 40
hidden: yes
at: 0, 40
when flag clicked
hide
when I receive [game over v]
show
stop [all v]`;
function initUI() {
const css = `
#ttb-toggle{position:fixed;left:72px;bottom:16px;z-index:9999;background:#855cd6;color:#fff;border:none;border-radius:20px;
padding:8px 14px;font:600 13px/1 "Helvetica Neue",Helvetica,Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.25)}
#ttb-toggle:hover{background:#7448c9}
#ttb-panel{position:fixed;left:72px;bottom:60px;width:470px;z-index:10000;background:#fff;border:1px solid #d9d9d9;border-radius:10px;
box-shadow:0 6px 24px rgba(0,0,0,.25);font:13px "Helvetica Neue",Helvetica,Arial,sans-serif;color:#575e75;display:none}
#ttb-panel header{background:#855cd6;color:#fff;padding:8px 12px;border-radius:10px 10px 0 0;cursor:move;display:flex;justify-content:space-between;align-items:center;font-weight:600}
#ttb-panel header button{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1}
#ttb-panel .ttb-body{padding:10px 12px}
#ttb-panel textarea{width:100%;box-sizing:border-box;height:260px;font:12.5px/1.45 Menlo,Consolas,monospace;border:1px solid #ccc;border-radius:6px;padding:8px;resize:vertical;tab-size:2}
#ttb-panel .ttb-row{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
#ttb-panel .ttb-btn{border:none;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
#ttb-panel .ttb-go{background:#4c97ff;color:#fff}
#ttb-panel .ttb-go:hover{background:#3373cc}
#ttb-panel .ttb-sec{background:#e9eef2;color:#575e75}
#ttb-panel label{display:flex;gap:4px;align-items:center;font-size:12px}
#ttb-status{margin-top:8px;min-height:18px;font-size:12px;white-space:pre-wrap}
#ttb-status.err{color:#d4352b}#ttb-status.ok{color:#0b8e69}
#ttb-panel .ttb-hint{font-size:11px;color:#8a8fa3;margin-top:6px}`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
const toggle = document.createElement('button');
toggle.id = 'ttb-toggle';
toggle.textContent = '✎ Text → Blocks';
toggle.style.display = 'none';
document.body.appendChild(toggle);
const panel = document.createElement('div');
panel.id = 'ttb-panel';
panel.innerHTML = `
<header><span>Text → Blocks</span><button title="Close">×</button></header>
<div class="ttb-body">
<textarea spellcheck="false" placeholder="Type scratchblocks text here…"></textarea>
<div class="ttb-row">
<button class="ttb-btn ttb-go">Create blocks</button>
<button class="ttb-btn ttb-sec" data-a="example">Example</button>
<button class="ttb-btn ttb-sec" data-a="clear">Clear</button>
<label title="New variables/lists are made 'For this sprite only'"><input type="checkbox" data-o="local"> Sprite-only vars</label>
<label title="Delete the code already in each sprite the text lists before adding the new code (so rebuilding doesn't make duplicates)"><input type="checkbox" data-o="replace"> Replace code</label>
</div>
<div id="ttb-status"></div>
<div class="ttb-hint">(round) = number/reporter · [square] = text · [thing v] = dropdown · <pointy> = boolean · close C-blocks with "end". Start a section with "sprite: Name" or "stage:". Ctrl+Enter to build.</div>
</div>`;
document.body.appendChild(panel);
const ta = panel.querySelector('textarea');
const status = panel.querySelector('#ttb-status');
const local = panel.querySelector('[data-o=local]');
const replace = panel.querySelector('[data-o=replace]');
try {
ta.value = localStorage.getItem('ttb-text') || '';
local.checked = localStorage.getItem('ttb-local') === '1';
replace.checked = localStorage.getItem('ttb-replace') === '1';
} catch (e) { /* ignore */ }
const setStatus = (msg, cls) => { status.textContent = msg; status.className = cls || ''; };
const save = () => { try { localStorage.setItem('ttb-text', ta.value); localStorage.setItem('ttb-local', local.checked ? '1' : '0'); localStorage.setItem('ttb-replace', replace.checked ? '1' : '0'); } catch (e) { /* ignore */ } };
async function go() {
save();
setStatus('Building…');
try {
const { count, created, report } = await createBlocks(ta.value, { local: local.checked, replace: replace.checked });
let msg = `✓ Done: ${report.length ? report.join(' · ') : `added ${count} script${count === 1 ? '' : 's'}`}.`;
if (created.length) msg += `\nNew variables/lists made: ${[...new Set(created)].join(', ')} (if one is a typo of a block name, fix the text and try again).`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message || String(e), 'err');
if (e.line) selectLine(e.line);
if (!(e instanceof ParseError)) console.error('[Text → Blocks]', e);
}
}
function selectLine(n) {
const lines = ta.value.split('\n');
let start = 0;
for (let k = 0; k < n - 1; k++) start += lines[k].length + 1;
ta.focus();
ta.setSelectionRange(start, start + (lines[n - 1] || '').length);
}
toggle.onclick = () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; if (panel.style.display === 'block') ta.focus(); };
panel.querySelector('header button').onclick = () => { panel.style.display = 'none'; };
panel.querySelector('.ttb-go').onclick = go;
panel.querySelector('[data-a=example]').onclick = () => { ta.value = EXAMPLE; save(); };
panel.querySelector('[data-a=clear]').onclick = () => { ta.value = ''; save(); setStatus(''); ta.focus(); };
local.onchange = save;
replace.onchange = save;
ta.addEventListener('input', save);
ta.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); go(); }
if (e.key === 'Tab') {
e.preventDefault();
const s = ta.selectionStart;
ta.setRangeText(' ', s, ta.selectionEnd, 'end');
}
});
['keyup', 'keypress'].forEach(t => ta.addEventListener(t, e => e.stopPropagation()));
// Dragging the panel by its header
const header = panel.querySelector('header');
header.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
const r = panel.getBoundingClientRect();
const dx = e.clientX - r.left, dy = e.clientY - r.top;
const move = ev => {
panel.style.left = Math.max(0, ev.clientX - dx) + 'px';
panel.style.top = Math.max(0, ev.clientY - dy) + 'px';
panel.style.bottom = 'auto';
};
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
e.preventDefault();
});
// Only show the button while the editor's code area is on screen
setInterval(() => {
const inEditor = !!document.querySelector('[class*="blocks_blocks"], .injectionDiv');
toggle.style.display = inEditor ? 'block' : 'none';
if (!inEditor) panel.style.display = 'none';
}, 1000);
}
if (typeof window !== 'undefined' && window.document && !window.__TTB_TEST__) initUI();
if (typeof window !== 'undefined' && window.__TTB_TEST__) window.__TTB__ = { refreshDynamicSpecs, insertScriptsVM, parseText, stackXml, buildCtx, splitSections, parseLook, lookSvg };
})();