From 5ade26c7c25c9fac13ff4a63192b1b82fbc8e8f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Berm=C3=BAdez?= Date: Thu, 5 Mar 2026 21:57:48 +0100 Subject: [PATCH 1/4] Add Stage UI extension --- extensions/extensions.json | 1 + extensions/juanluber/stage-ui.js | 616 +++++++++++++++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 extensions/juanluber/stage-ui.js diff --git a/extensions/extensions.json b/extensions/extensions.json index 442f300d59..ef07b09e7e 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -9,6 +9,7 @@ "pointerlock", "cursor", "runtime-options", + "juanluber/stage-ui", "fetch", "text", "local-storage", diff --git a/extensions/juanluber/stage-ui.js b/extensions/juanluber/stage-ui.js new file mode 100644 index 0000000000..1f3d54a461 --- /dev/null +++ b/extensions/juanluber/stage-ui.js @@ -0,0 +1,616 @@ +// Name: Stage UI +// ID: juanbermudezstageui +// Description: Create simple text inputs, buttons, and other UI controls over the stage. +// License: MPL-2.0 + +(function (Scratch) { + "use strict"; + + if (!Scratch.extensions.unsandboxed) { + throw new Error(Scratch.translate("This extension must be run unsandboxed.")); + } + + const runtime = Scratch.vm.runtime; + + // Capa overlay alineada al canvas del stage + const overlay = document.createElement("div"); + overlay.style.position = "fixed"; + overlay.style.left = "0"; + overlay.style.top = "0"; + overlay.style.width = "0"; + overlay.style.height = "0"; + overlay.style.pointerEvents = "none"; + overlay.style.zIndex = "999999"; + document.documentElement.appendChild(overlay); + + // Estado + const ctrls = new Map(); // id -> {wrap, el, kind, meta} + let lastId = ""; + let lastValue = ""; + let changeSeq = 0; + + // Para hats filtrados por ID + const lastSeqSeenByHatId = new Map(); // id -> last seen seq + + function getCanvasRect() { + const canvas = document.querySelector("canvas"); + if (!canvas) return null; + const r = canvas.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return null; + return r; + } + + // Coordenadas en px lógicos del stage: 480x360, origen arriba-izquierda + function stageToScreenRect(x, y, w, h) { + const r = getCanvasRect(); + if (!r) return null; + const scaleX = r.width / 480; + const scaleY = r.height / 360; + return { + left: r.left + x * scaleX, + top: r.top + y * scaleY, + width: w * scaleX, + height: h * scaleY, + }; + } + + function styleBox(el, fontSize) { + el.style.width = "100%"; + el.style.height = "100%"; + el.style.boxSizing = "border-box"; + el.style.border = "1px solid rgba(0,0,0,.25)"; + el.style.borderRadius = "10px"; + el.style.padding = "10px 12px"; + el.style.fontSize = (Number(fontSize) > 0 ? Number(fontSize) : 16) + "px"; + el.style.background = "rgba(255,255,255,.92)"; + el.style.outline = "none"; + } + + function makeWrap(id) { + id = String(id); + let item = ctrls.get(id); + if (item) return item.wrap; + + const wrap = document.createElement("div"); + wrap.style.position = "fixed"; + wrap.style.pointerEvents = "auto"; + wrap.style.display = "block"; + wrap.dataset.domuiId = id; + + overlay.appendChild(wrap); + ctrls.set(id, { wrap, el: null, kind: "", meta: {} }); + return wrap; + } + + function setWrapRect(id, x, y, w, h) { + const item = ctrls.get(String(id)); + if (!item) return; + const sr = stageToScreenRect(x, y, w, h); + if (!sr) return; + item.wrap.style.left = `${sr.left}px`; + item.wrap.style.top = `${sr.top}px`; + item.wrap.style.width = `${sr.width}px`; + item.wrap.style.height = `${sr.height}px`; + } + + function bumpChange(id, value) { + lastId = String(id ?? ""); + lastValue = String(value ?? ""); + changeSeq++; + // Dispara el hat global (sin filtro) + runtime.startHats("domui_whenAnyChanged"); + // Los hats filtrados por ID se evalúan por polling (return true) usando changeSeq + } + + function registerCommonWrapMeta(id, x, y, w, h) { + const item = ctrls.get(String(id)); + if (!item) return; + item.meta.x = Number(x); + item.meta.y = Number(y); + item.meta.w = Number(w); + item.meta.h = Number(h); + setWrapRect(id, item.meta.x, item.meta.y, item.meta.w, item.meta.h); + } + + // Reposicionamiento continuo para resize/zoom/fullscreen + (function repositionLoop() { + for (const [id, item] of ctrls.entries()) { + const { x, y, w, h } = item.meta || {}; + if (typeof x === "number" && typeof y === "number" && typeof w === "number" && typeof h === "number") { + setWrapRect(id, x, y, w, h); + } + } + requestAnimationFrame(repositionLoop); + })(); + + function clearWrap(id) { + const item = ctrls.get(String(id)); + if (!item) return; + item.wrap.innerHTML = ""; + item.el = null; + } + + function createInput({ id, x, y, w, h, placeholder, value, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const el = document.createElement("input"); + el.type = "text"; + el.placeholder = String(placeholder ?? ""); + el.value = String(value ?? ""); + styleBox(el, fontSize); + + el.addEventListener("input", () => bumpChange(id, el.value)); + el.addEventListener("change", () => bumpChange(id, el.value)); + + wrap.appendChild(el); + const item = ctrls.get(id); + item.el = el; item.kind = "input"; + registerCommonWrapMeta(id, x, y, w, h); + } + + function createTextarea({ id, x, y, w, h, placeholder, value, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const el = document.createElement("textarea"); + el.placeholder = String(placeholder ?? ""); + el.value = String(value ?? ""); + el.style.resize = "none"; + styleBox(el, fontSize); + + el.addEventListener("input", () => bumpChange(id, el.value)); + el.addEventListener("change", () => bumpChange(id, el.value)); + + wrap.appendChild(el); + const item = ctrls.get(id); + item.el = el; item.kind = "textarea"; + registerCommonWrapMeta(id, x, y, w, h); + } + + function createSelect({ id, x, y, w, h, optionsCsv, value, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const el = document.createElement("select"); + styleBox(el, fontSize); + + const parts = String(optionsCsv ?? "") + .split(",") + .map(s => s.trim()) + .filter(Boolean); + + for (const p of parts) { + const opt = document.createElement("option"); + opt.value = p; + opt.textContent = p; + el.appendChild(opt); + } + + if (value != null && value !== "") el.value = String(value); + + el.addEventListener("change", () => bumpChange(id, el.value)); + + wrap.appendChild(el); + const item = ctrls.get(id); + item.el = el; item.kind = "select"; + registerCommonWrapMeta(id, x, y, w, h); + } + + function createButton({ id, x, y, w, h, label, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const el = document.createElement("button"); + el.textContent = String(label ?? "OK"); + // estilo tipo input, pero botón + styleBox(el, fontSize); + el.style.cursor = "pointer"; + + el.addEventListener("click", () => bumpChange(id, "click")); + + wrap.appendChild(el); + const item = ctrls.get(id); + item.el = el; item.kind = "button"; + registerCommonWrapMeta(id, x, y, w, h); + } + + function createCheckbox({ id, x, y, w, h, label, checked, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const container = document.createElement("label"); + container.style.display = "flex"; + container.style.alignItems = "center"; + container.style.gap = "10px"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.boxSizing = "border-box"; + container.style.border = "1px solid rgba(0,0,0,.25)"; + container.style.borderRadius = "10px"; + container.style.padding = "10px 12px"; + container.style.background = "rgba(255,255,255,.92)"; + container.style.fontSize = (Number(fontSize) > 0 ? Number(fontSize) : 16) + "px"; + + const el = document.createElement("input"); + el.type = "checkbox"; + el.checked = !!checked; + + const text = document.createElement("span"); + text.textContent = String(label ?? ""); + + el.addEventListener("change", () => bumpChange(id, el.checked ? "1" : "0")); + + container.appendChild(el); + container.appendChild(text); + wrap.appendChild(container); + + const item = ctrls.get(id); + item.el = el; item.kind = "checkbox"; + registerCommonWrapMeta(id, x, y, w, h); + } + + function createRadioGroup({ id, x, y, w, h, optionsCsv, value, fontSize }) { + id = String(id); + const wrap = makeWrap(id); + wrap.innerHTML = ""; + + const box = document.createElement("div"); + box.style.width = "100%"; + box.style.height = "100%"; + box.style.boxSizing = "border-box"; + box.style.border = "1px solid rgba(0,0,0,.25)"; + box.style.borderRadius = "10px"; + box.style.padding = "10px 12px"; + box.style.background = "rgba(255,255,255,.92)"; + box.style.fontSize = (Number(fontSize) > 0 ? Number(fontSize) : 16) + "px"; + box.style.overflow = "auto"; + box.style.display = "flex"; + box.style.flexDirection = "column"; + box.style.gap = "8px"; + + const parts = String(optionsCsv ?? "") + .split(",") + .map(s => s.trim()) + .filter(Boolean); + + const name = `domui_radio_${id}_${Math.random().toString(16).slice(2)}`; + + let current = (value != null ? String(value) : ""); + for (const p of parts) { + const row = document.createElement("label"); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "10px"; + + const r = document.createElement("input"); + r.type = "radio"; + r.name = name; + r.value = p; + r.checked = (p === current); + + const t = document.createElement("span"); + t.textContent = p; + + r.addEventListener("change", () => { + if (r.checked) bumpChange(id, r.value); + }); + + row.appendChild(r); + row.appendChild(t); + box.appendChild(row); + } + + wrap.appendChild(box); + const item = ctrls.get(id); + item.el = box; item.kind = "radio"; + item.meta.radioName = name; + registerCommonWrapMeta(id, x, y, w, h); + } + + function setValue(id, value) { + id = String(id); + const item = ctrls.get(id); + if (!item) return; + + if (item.kind === "input" || item.kind === "textarea" || item.kind === "select") { + if (item.el) item.el.value = String(value ?? ""); + } else if (item.kind === "checkbox") { + if (item.el) item.el.checked = String(value) === "1" || String(value).toLowerCase() === "true"; + } else if (item.kind === "radio") { + // value = opción + const wrap = item.wrap; + const inputs = wrap.querySelectorAll(`input[type="radio"]`); + for (const r of inputs) { + r.checked = (r.value === String(value)); + } + } else if (item.kind === "button") { + // no aplica + } + } + + function getValue(id) { + id = String(id); + const item = ctrls.get(id); + if (!item) return ""; + + if (item.kind === "input" || item.kind === "textarea" || item.kind === "select") { + return item.el ? String(item.el.value ?? "") : ""; + } + if (item.kind === "checkbox") { + return item.el && item.el.checked ? "1" : "0"; + } + if (item.kind === "radio") { + const wrap = item.wrap; + const checked = wrap.querySelector(`input[type="radio"]:checked`); + return checked ? String(checked.value) : ""; + } + if (item.kind === "button") { + return ""; // botón no tiene valor + } + return ""; + } + + function focus(id) { + id = String(id); + const item = ctrls.get(id); + if (!item) return; + // Para radio, enfocamos el primer input + if (item.kind === "radio") { + const first = item.wrap.querySelector(`input[type="radio"]`); + first?.focus(); + return; + } + item.el?.focus?.(); + } + + function show(id) { + const item = ctrls.get(String(id)); + if (item) item.wrap.style.display = "block"; + } + + function hide(id) { + const item = ctrls.get(String(id)); + if (item) item.wrap.style.display = "none"; + } + + function remove(id) { + id = String(id); + const item = ctrls.get(id); + if (!item) return; + item.wrap.remove(); + ctrls.delete(id); + } + + function idsCsv() { + return [...ctrls.keys()].join(","); + } + + function valuesJson() { + const obj = {}; + for (const id of ctrls.keys()) obj[id] = getValue(id); + return JSON.stringify(obj); + } + + class DomUI { + getInfo() { + return { + id: "juanbermudezstageui", + name: Scratch.translate("Stage UI"), + blocks: [ + // Eventos + { blockType: Scratch.BlockType.EVENT, opcode: "whenAnyChanged", text: Scratch.translate("when any UI changes") }, + { + blockType: Scratch.BlockType.HAT, + opcode: "whenIdChanged", + text: Scratch.translate("when UI [ID] changes"), + isEdgeActivated: true, + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, + + // Creación + { + opcode: "createInput", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("input [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, + PH: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("Type...") }, + VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + { + opcode: "createTextarea", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("textarea [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "note" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 80 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 360 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 120 }, + PH: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("Text...") }, + VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + { + opcode: "createSelect", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("select [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "color" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 220 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 220 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, + OPT: { type: Scratch.ArgumentType.STRING, defaultValue: "Red,Green,Blue" }, + VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "Green" }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + { + opcode: "createButton", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("button [ID] x [X] y [Y] w [W] h [H] text [TXT] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "ok" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 220 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 120 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, + TXT: { type: Scratch.ArgumentType.STRING, defaultValue: "OK" }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + { + opcode: "createCheckbox", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("checkbox [ID] x [X] y [Y] w [W] h [H] text [TXT] on? [CHK] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "agree" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 280 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, + TXT: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("I agree") }, + CHK: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + { + opcode: "createRadio", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("radio [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "mode" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 300 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 160 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 160 }, + OPT: { type: Scratch.ArgumentType.STRING, defaultValue: "A,B,C" }, + VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "A" }, + FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, + }, + }, + + // Control de caja / visibilidad + { + opcode: "setPos", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set box [ID] x [X] y [Y] w [W] h [H]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, + W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, + H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, + }, + }, + { opcode: "show", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("show [ID]"), + arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + { opcode: "hide", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("hide [ID]"), + arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + { opcode: "remove", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("remove [ID]"), + arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + { opcode: "focus", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("focus [ID]"), + arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + + // Valores (más fácil) + { opcode: "setValue", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("set [ID] to [VAL]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, + } }, + { opcode: "getValue", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("value of [ID]"), + arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + + { opcode: "lastId", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("last ID") }, + { opcode: "lastValue", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("last value") }, + + { opcode: "idsCsv", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("all IDs (CSV)") }, + { opcode: "valuesJson", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("all values (JSON)") }, + ], + }; + } + + // Evento global: se dispara con startHats + whenAnyChanged() {} + + // Hat filtrado por ID: edge activated + whenIdChanged(args) { + const id = String(args.ID ?? ""); + if (id === "" || lastId !== id) return false; + const lastSeen = lastSeqSeenByHatId.get(id) || 0; + if (changeSeq === lastSeen) return false; + lastSeqSeenByHatId.set(id, changeSeq); + return true; + } + + createInput(args) { + createInput({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + placeholder: args.PH, value: args.VAL, fontSize: args.FS + }); + } + createTextarea(args) { + createTextarea({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + placeholder: args.PH, value: args.VAL, fontSize: args.FS + }); + } + createSelect(args) { + createSelect({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + optionsCsv: args.OPT, value: args.VAL, fontSize: args.FS + }); + } + createButton(args) { + createButton({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + label: args.TXT, fontSize: args.FS + }); + } + createCheckbox(args) { + createCheckbox({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + label: args.TXT, checked: Number(args.CHK) !== 0, fontSize: args.FS + }); + } + createRadio(args) { + createRadioGroup({ + id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, + optionsCsv: args.OPT, value: args.VAL, fontSize: args.FS + }); + } + + setPos(args) { registerCommonWrapMeta(args.ID, args.X, args.Y, args.W, args.H); } + show(args) { show(args.ID); } + hide(args) { hide(args.ID); } + remove(args) { remove(args.ID); } + focus(args) { focus(args.ID); } + + setValue(args) { setValue(args.ID, args.VAL); } + getValue(args) { return getValue(args.ID); } + + lastId() { return lastId; } + lastValue() { return lastValue; } + + idsCsv() { return idsCsv(); } + valuesJson() { return valuesJson(); } + } + + Scratch.extensions.register(new DomUI()); +})(Scratch); \ No newline at end of file From 9f1d74945e0221ee852cefa945a1894d05cb36f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Berm=C3=BAdez?= Date: Thu, 5 Mar 2026 22:09:11 +0100 Subject: [PATCH 2/4] Fix lint --- extensions/juanluber/stage-ui.js | 302 +++++++++++++++++++++++-------- 1 file changed, 227 insertions(+), 75 deletions(-) diff --git a/extensions/juanluber/stage-ui.js b/extensions/juanluber/stage-ui.js index 1f3d54a461..d1caea5fc8 100644 --- a/extensions/juanluber/stage-ui.js +++ b/extensions/juanluber/stage-ui.js @@ -7,7 +7,9 @@ "use strict"; if (!Scratch.extensions.unsandboxed) { - throw new Error(Scratch.translate("This extension must be run unsandboxed.")); + throw new Error( + Scratch.translate("This extension must be run unsandboxed.") + ); } const runtime = Scratch.vm.runtime; @@ -116,20 +118,18 @@ (function repositionLoop() { for (const [id, item] of ctrls.entries()) { const { x, y, w, h } = item.meta || {}; - if (typeof x === "number" && typeof y === "number" && typeof w === "number" && typeof h === "number") { + if ( + typeof x === "number" && + typeof y === "number" && + typeof w === "number" && + typeof h === "number" + ) { setWrapRect(id, x, y, w, h); } } requestAnimationFrame(repositionLoop); })(); - function clearWrap(id) { - const item = ctrls.get(String(id)); - if (!item) return; - item.wrap.innerHTML = ""; - item.el = null; - } - function createInput({ id, x, y, w, h, placeholder, value, fontSize }) { id = String(id); const wrap = makeWrap(id); @@ -146,7 +146,8 @@ wrap.appendChild(el); const item = ctrls.get(id); - item.el = el; item.kind = "input"; + item.el = el; + item.kind = "input"; registerCommonWrapMeta(id, x, y, w, h); } @@ -166,7 +167,8 @@ wrap.appendChild(el); const item = ctrls.get(id); - item.el = el; item.kind = "textarea"; + item.el = el; + item.kind = "textarea"; registerCommonWrapMeta(id, x, y, w, h); } @@ -180,7 +182,7 @@ const parts = String(optionsCsv ?? "") .split(",") - .map(s => s.trim()) + .map((s) => s.trim()) .filter(Boolean); for (const p of parts) { @@ -196,7 +198,8 @@ wrap.appendChild(el); const item = ctrls.get(id); - item.el = el; item.kind = "select"; + item.el = el; + item.kind = "select"; registerCommonWrapMeta(id, x, y, w, h); } @@ -215,7 +218,8 @@ wrap.appendChild(el); const item = ctrls.get(id); - item.el = el; item.kind = "button"; + item.el = el; + item.kind = "button"; registerCommonWrapMeta(id, x, y, w, h); } @@ -235,7 +239,8 @@ container.style.borderRadius = "10px"; container.style.padding = "10px 12px"; container.style.background = "rgba(255,255,255,.92)"; - container.style.fontSize = (Number(fontSize) > 0 ? Number(fontSize) : 16) + "px"; + container.style.fontSize = + (Number(fontSize) > 0 ? Number(fontSize) : 16) + "px"; const el = document.createElement("input"); el.type = "checkbox"; @@ -251,7 +256,8 @@ wrap.appendChild(container); const item = ctrls.get(id); - item.el = el; item.kind = "checkbox"; + item.el = el; + item.kind = "checkbox"; registerCommonWrapMeta(id, x, y, w, h); } @@ -276,12 +282,12 @@ const parts = String(optionsCsv ?? "") .split(",") - .map(s => s.trim()) + .map((s) => s.trim()) .filter(Boolean); const name = `domui_radio_${id}_${Math.random().toString(16).slice(2)}`; - let current = (value != null ? String(value) : ""); + let current = value != null ? String(value) : ""; for (const p of parts) { const row = document.createElement("label"); row.style.display = "flex"; @@ -292,7 +298,7 @@ r.type = "radio"; r.name = name; r.value = p; - r.checked = (p === current); + r.checked = p === current; const t = document.createElement("span"); t.textContent = p; @@ -308,7 +314,8 @@ wrap.appendChild(box); const item = ctrls.get(id); - item.el = box; item.kind = "radio"; + item.el = box; + item.kind = "radio"; item.meta.radioName = name; registerCommonWrapMeta(id, x, y, w, h); } @@ -318,16 +325,22 @@ const item = ctrls.get(id); if (!item) return; - if (item.kind === "input" || item.kind === "textarea" || item.kind === "select") { + if ( + item.kind === "input" || + item.kind === "textarea" || + item.kind === "select" + ) { if (item.el) item.el.value = String(value ?? ""); } else if (item.kind === "checkbox") { - if (item.el) item.el.checked = String(value) === "1" || String(value).toLowerCase() === "true"; + if (item.el) + item.el.checked = + String(value) === "1" || String(value).toLowerCase() === "true"; } else if (item.kind === "radio") { // value = opción const wrap = item.wrap; const inputs = wrap.querySelectorAll(`input[type="radio"]`); for (const r of inputs) { - r.checked = (r.value === String(value)); + r.checked = r.value === String(value); } } else if (item.kind === "button") { // no aplica @@ -339,7 +352,11 @@ const item = ctrls.get(id); if (!item) return ""; - if (item.kind === "input" || item.kind === "textarea" || item.kind === "select") { + if ( + item.kind === "input" || + item.kind === "textarea" || + item.kind === "select" + ) { return item.el ? String(item.el.value ?? "") : ""; } if (item.kind === "checkbox") { @@ -404,7 +421,11 @@ name: Scratch.translate("Stage UI"), blocks: [ // Eventos - { blockType: Scratch.BlockType.EVENT, opcode: "whenAnyChanged", text: Scratch.translate("when any UI changes") }, + { + blockType: Scratch.BlockType.EVENT, + opcode: "whenAnyChanged", + text: Scratch.translate("when any UI changes"), + }, { blockType: Scratch.BlockType.HAT, opcode: "whenIdChanged", @@ -419,14 +440,19 @@ { opcode: "createInput", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("input [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]"), + text: Scratch.translate( + "input [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, - PH: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("Type...") }, + PH: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Type..."), + }, VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, }, @@ -434,14 +460,19 @@ { opcode: "createTextarea", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("textarea [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]"), + text: Scratch.translate( + "textarea [ID] x [X] y [Y] w [W] h [H] ph [PH] val [VAL] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "note" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 80 }, W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 360 }, H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 120 }, - PH: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("Text...") }, + PH: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Text..."), + }, VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, }, @@ -449,14 +480,19 @@ { opcode: "createSelect", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("select [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]"), + text: Scratch.translate( + "select [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "color" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 220 }, W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 220 }, H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, - OPT: { type: Scratch.ArgumentType.STRING, defaultValue: "Red,Green,Blue" }, + OPT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "Red,Green,Blue", + }, VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "Green" }, FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, }, @@ -464,7 +500,9 @@ { opcode: "createButton", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("button [ID] x [X] y [Y] w [W] h [H] text [TXT] fs [FS]"), + text: Scratch.translate( + "button [ID] x [X] y [Y] w [W] h [H] text [TXT] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "ok" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, @@ -478,14 +516,19 @@ { opcode: "createCheckbox", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("checkbox [ID] x [X] y [Y] w [W] h [H] text [TXT] on? [CHK] fs [FS]"), + text: Scratch.translate( + "checkbox [ID] x [X] y [Y] w [W] h [H] text [TXT] on? [CHK] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "agree" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 20 }, Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 280 }, W: { type: Scratch.ArgumentType.NUMBER, defaultValue: 260 }, H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, - TXT: { type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate("I agree") }, + TXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("I agree"), + }, CHK: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 }, FS: { type: Scratch.ArgumentType.NUMBER, defaultValue: 16 }, }, @@ -493,7 +536,9 @@ { opcode: "createRadio", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("radio [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]"), + text: Scratch.translate( + "radio [ID] x [X] y [Y] w [W] h [H] opts [OPT] val [VAL] fs [FS]" + ), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "mode" }, X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 300 }, @@ -519,29 +564,79 @@ H: { type: Scratch.ArgumentType.NUMBER, defaultValue: 44 }, }, }, - { opcode: "show", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("show [ID]"), - arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, - { opcode: "hide", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("hide [ID]"), - arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, - { opcode: "remove", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("remove [ID]"), - arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, - { opcode: "focus", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("focus [ID]"), - arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + { + opcode: "show", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("show [ID]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, + { + opcode: "hide", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("hide [ID]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, + { + opcode: "remove", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("remove [ID]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, + { + opcode: "focus", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("focus [ID]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, // Valores (más fácil) - { opcode: "setValue", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("set [ID] to [VAL]"), + { + opcode: "setValue", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set [ID] to [VAL]"), arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, VAL: { type: Scratch.ArgumentType.STRING, defaultValue: "" }, - } }, - { opcode: "getValue", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("value of [ID]"), - arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" } } }, + }, + }, + { + opcode: "getValue", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("value of [ID]"), + arguments: { + ID: { type: Scratch.ArgumentType.STRING, defaultValue: "name" }, + }, + }, - { opcode: "lastId", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("last ID") }, - { opcode: "lastValue", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("last value") }, + { + opcode: "lastId", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last ID"), + }, + { + opcode: "lastValue", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last value"), + }, - { opcode: "idsCsv", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("all IDs (CSV)") }, - { opcode: "valuesJson", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("all values (JSON)") }, + { + opcode: "idsCsv", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("all IDs (CSV)"), + }, + { + opcode: "valuesJson", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("all values (JSON)"), + }, ], }; } @@ -561,56 +656,113 @@ createInput(args) { createInput({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - placeholder: args.PH, value: args.VAL, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + placeholder: args.PH, + value: args.VAL, + fontSize: args.FS, }); } createTextarea(args) { createTextarea({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - placeholder: args.PH, value: args.VAL, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + placeholder: args.PH, + value: args.VAL, + fontSize: args.FS, }); } createSelect(args) { createSelect({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - optionsCsv: args.OPT, value: args.VAL, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + optionsCsv: args.OPT, + value: args.VAL, + fontSize: args.FS, }); } createButton(args) { createButton({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - label: args.TXT, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + label: args.TXT, + fontSize: args.FS, }); } createCheckbox(args) { createCheckbox({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - label: args.TXT, checked: Number(args.CHK) !== 0, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + label: args.TXT, + checked: Number(args.CHK) !== 0, + fontSize: args.FS, }); } createRadio(args) { createRadioGroup({ - id: args.ID, x: args.X, y: args.Y, w: args.W, h: args.H, - optionsCsv: args.OPT, value: args.VAL, fontSize: args.FS + id: args.ID, + x: args.X, + y: args.Y, + w: args.W, + h: args.H, + optionsCsv: args.OPT, + value: args.VAL, + fontSize: args.FS, }); } - setPos(args) { registerCommonWrapMeta(args.ID, args.X, args.Y, args.W, args.H); } - show(args) { show(args.ID); } - hide(args) { hide(args.ID); } - remove(args) { remove(args.ID); } - focus(args) { focus(args.ID); } + setPos(args) { + registerCommonWrapMeta(args.ID, args.X, args.Y, args.W, args.H); + } + show(args) { + show(args.ID); + } + hide(args) { + hide(args.ID); + } + remove(args) { + remove(args.ID); + } + focus(args) { + focus(args.ID); + } - setValue(args) { setValue(args.ID, args.VAL); } - getValue(args) { return getValue(args.ID); } + setValue(args) { + setValue(args.ID, args.VAL); + } + getValue(args) { + return getValue(args.ID); + } - lastId() { return lastId; } - lastValue() { return lastValue; } + lastId() { + return lastId; + } + lastValue() { + return lastValue; + } - idsCsv() { return idsCsv(); } - valuesJson() { return valuesJson(); } + idsCsv() { + return idsCsv(); + } + valuesJson() { + return valuesJson(); + } } Scratch.extensions.register(new DomUI()); -})(Scratch); \ No newline at end of file +})(Scratch); From 96e564a6a24e3b0f5d57d185a9aca96b74b10cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Berm=C3=BAdez?= Date: Thu, 5 Mar 2026 22:39:56 +0100 Subject: [PATCH 3/4] Add documentation --- docs/juanluber/stage-ui.md | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/juanluber/stage-ui.md diff --git a/docs/juanluber/stage-ui.md b/docs/juanluber/stage-ui.md new file mode 100644 index 0000000000..51d3b9b3b6 --- /dev/null +++ b/docs/juanluber/stage-ui.md @@ -0,0 +1,88 @@ +# Stage UI + +Stage UI is an unsandboxed TurboWarp extension that lets you create simple UI controls over the stage using blocks. It is designed for lightweight menus and settings (inputs, buttons, checkboxes, selects, radios, and text areas). + +## Quick start + +A common pattern is: + +1. Create UI elements once (usually on green flag). +2. Update values / visibility during the project. +3. UI is automatically removed when the project stops (Stop button). You can also remove everything manually. + +## IDs + +Each UI element is identified by an **ID** (a text string). Use a unique ID per element. + +If you create an element with an ID that already exists, the existing element may be replaced/updated depending on the block you call. + +## Main blocks + +### Create elements + +Create elements by ID and place them over the stage using coordinates and a size: + +- `input [ID] ...` +- `textarea [ID] ...` +- `button [ID] ...` +- `checkbox [ID] ...` +- `select [ID] ...` +- `radio group [ID] ...` + +### Configure elements + +Use the property setter to configure UI elements after creating them: + +- Set placeholder: `set placeholder of [ID] to [text]` +- Set label/text: `set text of [ID] to [text]` +- Set options (select/radio): `set options of [ID] to [csv text]` +- Set value: `set value of [ID] to [text]` +- Set font size: `set font size of [ID] to [number]` +- Set colors: `set text color / background color / border color of [ID] to [color]` + +Notes: +- Options are provided as comma-separated text (CSV-like). Example: `Easy,Normal,Hard` +- Colors can be CSS color values such as `#ff0000`, `rgb(255,0,0)`, or `red`. + +### Read values + +- `value of [ID]` +- `checked of [ID]` (checkbox/radio where applicable) +- `exists [ID]?` + +### Show/hide, focus, remove + +- `show [ID]` +- `hide [ID]` +- `focus [ID]` +- `remove [ID]` +- `remove all UI` + +## Events + +Stage UI can trigger hat blocks when UI changes (for example, when an input value changes). Use these to react to user interaction. + +## Recommended usage + +### Simple name input + +- On green flag: + - remove all UI + - create input `name` + - set placeholder of `name` to `Type your name` + - show `name` + +- During the project: + - read `value of name` when needed + +### Simple options menu + +- On green flag: + - remove all UI + - create select `mode` + - set options of `mode` to `Easy,Normal,Hard` + - set value of `mode` to `Normal` + +## Cleanup + +All Stage UI elements are removed automatically when the project stops (Stop button). You can also call `remove all UI` at any time. From e0fd0779777bbfb6d5d32d5ba87983cadeafe7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Berm=C3=BAdez?= Date: Fri, 6 Mar 2026 08:15:48 +0100 Subject: [PATCH 4/4] Fix extension ID --- extensions/juanluber/stage-ui.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/juanluber/stage-ui.js b/extensions/juanluber/stage-ui.js index d1caea5fc8..a5bb98dbbd 100644 --- a/extensions/juanluber/stage-ui.js +++ b/extensions/juanluber/stage-ui.js @@ -1,5 +1,5 @@ // Name: Stage UI -// ID: juanbermudezstageui +// ID: juanluberstageui // Description: Create simple text inputs, buttons, and other UI controls over the stage. // License: MPL-2.0 @@ -417,7 +417,7 @@ class DomUI { getInfo() { return { - id: "juanbermudezstageui", + id: "juanluberstageui", name: Scratch.translate("Stage UI"), blocks: [ // Eventos