diff --git a/src/lib/extensions.js b/src/lib/extensions.js
index 5b294abe0..0424cfe05 100644
--- a/src/lib/extensions.js
+++ b/src/lib/extensions.js
@@ -366,6 +366,14 @@ export default [
notes: "Gallery banner by Dillon",
isGitHub: true,
},
+ {
+ name: "Animated Loaders",
+ description: "Create and display modern CSS loading spinners on the stage.",
+ code: "Ikelene/loaders.js",
+ banner: "Ikelene/loaders.png",
+ creator: "Ikelene",
+ isGitHub: true,
+ },
{
name: "Git Penguin",
description: "Make requests and control the files for your GitHub repository.",
diff --git a/static/extensions/Ikelene/loaders.js b/static/extensions/Ikelene/loaders.js
new file mode 100644
index 000000000..2d3a509d0
--- /dev/null
+++ b/static/extensions/Ikelene/loaders.js
@@ -0,0 +1,530 @@
+// Name: Animated Loaders
+// ID: ikeleneAnimatedLoaders
+// Description: Create and display modern CSS loading spinners on the stage.
+// By: Ikelene
+// License: MIT
+
+(function (Scratch) {
+ "use strict";
+
+ if (!Scratch.extensions.unsandboxed) {
+ throw new Error("Animated Loaders must run unsandboxed (DOM access is required).");
+ }
+
+ const EXT_ID = "ikeleneAnimatedLoaders";
+ const STYLE_TAG_ID = "pma-animated-loaders-stylesheet";
+ const DEFAULT_STAGE_W = 480;
+ const DEFAULT_STAGE_H = 360;
+
+ // presets scale off wrapper font-size (em units) and read --pma-color / --pma-speed
+ const BASE_CSS = `
+.pma-loader-wrapper {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ will-change: left, top, font-size;
+}
+.pma-loader-wrapper * { box-sizing: border-box; }
+
+@keyframes pma-spin { to { transform: rotate(360deg); } }
+@keyframes pma-spin-rev { to { transform: rotate(-360deg); } }
+
+.pma-ring {
+ width: 1em; height: 1em; border-radius: 50%;
+ border: 0.12em solid rgba(255,255,255,0.18);
+ border-top-color: var(--pma-color);
+ animation: pma-spin calc(0.9s / var(--pma-speed)) linear infinite;
+}
+
+.pma-dualring { position: relative; width: 1em; height: 1em; }
+.pma-dualring i { position: absolute; inset: 0; border-radius: 50%; border: 0.09em solid transparent; }
+.pma-dualring i:nth-child(1) {
+ border-top-color: var(--pma-color);
+ animation: pma-spin calc(1s / var(--pma-speed)) linear infinite;
+}
+.pma-dualring i:nth-child(2) {
+ border-bottom-color: var(--pma-color);
+ opacity: 0.45;
+ animation: pma-spin-rev calc(1.4s / var(--pma-speed)) linear infinite;
+}
+
+.pma-gradient {
+ width: 1em; height: 1em; border-radius: 50%;
+ background: conic-gradient(from 0deg, transparent 0%, var(--pma-color) 100%);
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 0.13em), #000 calc(100% - 0.13em));
+ mask: radial-gradient(farthest-side, transparent calc(100% - 0.13em), #000 calc(100% - 0.13em));
+ animation: pma-spin calc(0.8s / var(--pma-speed)) linear infinite;
+}
+
+.pma-dots { display: flex; align-items: flex-end; justify-content: center; gap: 0.16em; width: 1em; height: 1em; }
+.pma-dots span {
+ width: 0.22em; height: 0.22em; border-radius: 50%; background: var(--pma-color);
+ animation: pma-bounce calc(0.6s / var(--pma-speed)) ease-in-out infinite;
+}
+.pma-dots span:nth-child(2) { animation-delay: calc(0.1s / var(--pma-speed)); }
+.pma-dots span:nth-child(3) { animation-delay: calc(0.2s / var(--pma-speed)); }
+@keyframes pma-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.6; } 40% { transform: translateY(-0.5em); opacity: 1; } }
+
+.pma-dotpulse { display: flex; align-items: center; justify-content: center; gap: 0.18em; width: 1em; height: 1em; }
+.pma-dotpulse span {
+ width: 0.22em; height: 0.22em; border-radius: 50%; background: var(--pma-color);
+ animation: pma-dotpulse calc(1s / var(--pma-speed)) ease-in-out infinite;
+}
+.pma-dotpulse span:nth-child(2) { animation-delay: calc(0.15s / var(--pma-speed)); }
+.pma-dotpulse span:nth-child(3) { animation-delay: calc(0.3s / var(--pma-speed)); }
+@keyframes pma-dotpulse { 0%, 100% { transform: scale(0.6); opacity: 0.35; } 50% { transform: scale(1); opacity: 1; } }
+
+.pma-bars { display: flex; align-items: center; justify-content: center; gap: 0.12em; width: 1em; height: 1em; }
+.pma-bars span {
+ width: 0.15em; height: 100%; background: var(--pma-color); border-radius: 0.08em;
+ animation: pma-eq calc(0.9s / var(--pma-speed)) ease-in-out infinite;
+ transform-origin: center;
+}
+.pma-bars span:nth-child(1) { animation-delay: calc(-0.6s / var(--pma-speed)); }
+.pma-bars span:nth-child(2) { animation-delay: calc(-0.4s / var(--pma-speed)); }
+.pma-bars span:nth-child(3) { animation-delay: calc(-0.2s / var(--pma-speed)); }
+.pma-bars span:nth-child(4) { animation-delay: 0s; }
+@keyframes pma-eq { 0%, 100% { transform: scaleY(0.25); } 50% { transform: scaleY(1); } }
+
+.pma-pulse { position: relative; width: 1em; height: 1em; }
+.pma-pulse span {
+ position: absolute; inset: 0; border-radius: 50%; border: 0.07em solid var(--pma-color);
+ opacity: 0; animation: pma-ripple calc(1.4s / var(--pma-speed)) ease-out infinite;
+}
+.pma-pulse span:nth-child(2) { animation-delay: calc(0.7s / var(--pma-speed)); }
+@keyframes pma-ripple { 0% { transform: scale(0.2); opacity: 0.8; } 100% { transform: scale(1); opacity: 0; } }
+
+.pma-rippledot { position: relative; width: 1em; height: 1em; }
+.pma-rippledot span {
+ position: absolute; inset: 0; border-radius: 50%; background: var(--pma-color);
+ opacity: 0; animation: pma-rippledot calc(1.2s / var(--pma-speed)) ease-out infinite;
+}
+.pma-rippledot span:nth-child(2) { animation-delay: calc(0.6s / var(--pma-speed)); }
+@keyframes pma-rippledot { 0% { transform: scale(0); opacity: 0.55; } 100% { transform: scale(1); opacity: 0; } }
+
+.pma-orbit { position: relative; width: 1em; height: 1em; animation: pma-spin calc(1.1s / var(--pma-speed)) linear infinite; }
+.pma-orbit i {
+ position: absolute; top: 50%; left: 50%; width: 0.18em; height: 0.18em; margin: -0.09em;
+ border-radius: 50%; background: var(--pma-color);
+}
+.pma-orbit i:nth-child(1) { transform: rotate(0deg) translateX(0.44em); opacity: 1; }
+.pma-orbit i:nth-child(2) { transform: rotate(120deg) translateX(0.44em); opacity: 0.6; }
+.pma-orbit i:nth-child(3) { transform: rotate(240deg) translateX(0.44em); opacity: 0.3; }
+
+.pma-sweep { width: 4.4em; height: 0.34em; border-radius: 0; background: rgba(255,255,255,0.12); position: relative; overflow: hidden; }
+.pma-sweep i {
+ position: absolute; top: 0; left: -35%; width: 35%; height: 100%; border-radius: 0;
+ background: linear-gradient(90deg, transparent, var(--pma-color), transparent);
+ animation: pma-sweep calc(1.3s / var(--pma-speed)) cubic-bezier(.4,0,.2,1) infinite;
+}
+@keyframes pma-sweep {
+ 0% { left: -35%; opacity: 0; }
+ 12% { opacity: 1; }
+ 55% { opacity: 1; }
+ 100% { left: 100%; opacity: 0; }
+}
+
+.pma-indeterminate { width: 4.4em; height: 0.28em; border-radius: 0; background: rgba(255,255,255,0.12); position: relative; overflow: hidden; }
+.pma-indeterminate i { position: absolute; top: 0; height: 100%; border-radius: 0; background: var(--pma-color); }
+.pma-indeterminate i:nth-child(1) { animation: pma-indet1 calc(1.6s / var(--pma-speed)) cubic-bezier(.65,.15,.35,.85) infinite; }
+.pma-indeterminate i:nth-child(2) { animation: pma-indet2 calc(1.6s / var(--pma-speed)) cubic-bezier(.65,.15,.35,.85) infinite; opacity: 0.6; }
+@keyframes pma-indet1 { 0% { left: -40%; width: 40%; } 60% { left: 100%; width: 60%; } 100% { left: 100%; width: 60%; } }
+@keyframes pma-indet2 { 0% { left: -60%; width: 50%; } 100% { left: 120%; width: 20%; } }
+`;
+
+ function injectBaseStylesheet() {
+ if (document.getElementById(STYLE_TAG_ID)) return;
+ const style = document.createElement("style");
+ style.id = STYLE_TAG_ID;
+ style.textContent = BASE_CSS;
+ document.head.appendChild(style);
+ }
+
+ const PRESETS = {
+ ring: () => `
`,
+ dualring: () => `
`,
+ gradient: () => ``,
+ dots_bounce: () => `
`,
+ dots_pulse: () => `
`,
+ bars: () => `
`,
+ radar: () => `
`,
+ ripple: () => `
`,
+ orbit: () => `
`,
+ progress_sweep: () => `
`,
+ indeterminate: () => `
`,
+ };
+
+ class AnimatedLoaders {
+ constructor(runtime) {
+ this.runtime = runtime;
+ this.loaders = new Map();
+ this.container = null;
+ this._observedCanvas = null;
+ this._looping = false;
+ this._rafId = null;
+ injectBaseStylesheet();
+ }
+
+ getInfo() {
+ return {
+ id: EXT_ID,
+ name: "Animated Loaders",
+ color1: "#8b5cf6",
+ color2: "#7c3aed",
+ blocks: [
+ {
+ opcode: "createLoader",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "create loader [ID] with style [STYLE]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ STYLE: { type: Scratch.ArgumentType.STRING, menu: "STYLES", defaultValue: "ring" },
+ },
+ },
+ {
+ opcode: "removeLoader",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "remove loader [ID]",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ {
+ opcode: "removeAllLoaders",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "remove all loaders",
+ },
+ "---",
+ {
+ opcode: "showLoader",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "show loader [ID]",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ {
+ opcode: "hideLoader",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "hide loader [ID]",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ "---",
+ {
+ opcode: "setLoaderPosition",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set loader [ID] position to x: [X] y: [Y]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 },
+ Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 },
+ },
+ },
+ {
+ opcode: "changeLoaderPosition",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "change loader [ID] position by x: [X] y: [Y]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ X: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 },
+ Y: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 },
+ },
+ },
+ {
+ opcode: "setLoaderSize",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set loader [ID] size to [SIZE]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ SIZE: { type: Scratch.ArgumentType.NUMBER, defaultValue: 60 },
+ },
+ },
+ {
+ opcode: "setLoaderSpeed",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set loader [ID] speed to [SPEED] x",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ SPEED: { type: Scratch.ArgumentType.NUMBER, defaultValue: 1 },
+ },
+ },
+ {
+ opcode: "setLoaderColor",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set loader [ID] color to [COLOR]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ COLOR: { type: Scratch.ArgumentType.COLOR, defaultValue: "#8b5cf6" },
+ },
+ },
+ {
+ opcode: "setLoaderStyle",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set loader [ID] style to [STYLE]",
+ arguments: {
+ ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" },
+ STYLE: { type: Scratch.ArgumentType.STRING, menu: "STYLES", defaultValue: "ring" },
+ },
+ },
+ "---",
+ {
+ opcode: "loaderExists",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "loader [ID] exists?",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ {
+ opcode: "loaderVisible",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "loader [ID] is visible?",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ {
+ opcode: "getLoaderX",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "loader [ID] x",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ {
+ opcode: "getLoaderY",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "loader [ID] y",
+ arguments: { ID: { type: Scratch.ArgumentType.STRING, defaultValue: "loader1" } },
+ },
+ ],
+ menus: {
+ STYLES: {
+ acceptReporters: true,
+ items: [
+ { text: "ring", value: "ring" },
+ { text: "dual ring", value: "dualring" },
+ { text: "gradient spinner", value: "gradient" },
+ { text: "bouncing dots", value: "dots_bounce" },
+ { text: "pulsing dots", value: "dots_pulse" },
+ { text: "equalizer bars", value: "bars" },
+ { text: "radar ripple", value: "radar" },
+ { text: "ripple", value: "ripple" },
+ { text: "orbit dots", value: "orbit" },
+ { text: "progress sweep bar", value: "progress_sweep" },
+ { text: "indeterminate bar", value: "indeterminate" },
+ ],
+ },
+ },
+ };
+ }
+
+ // re-checked every frame instead of cached once, since fullscreen or a stage size change can swap the canvas or reparent it
+ _ensureContainer() {
+ const canvas = this.runtime.renderer && this.runtime.renderer.canvas;
+ if (!canvas || !canvas.parentElement) return this.container;
+
+ const parent = canvas.parentElement;
+
+ if (this.container && this.container.parentElement === parent && document.body.contains(this.container)) {
+ return this.container;
+ }
+
+ if (getComputedStyle(parent).position === "static") {
+ parent.style.position = "relative";
+ }
+
+ let container = this.container;
+ if (!container || !document.body.contains(container)) {
+ const orphaned = container; // still holds live loader elements
+ container = document.createElement("div");
+ container.id = "pma-loader-container";
+ container.style.position = "absolute";
+ container.style.top = "0";
+ container.style.left = "0";
+ container.style.right = "0";
+ container.style.bottom = "0";
+ container.style.overflow = "hidden";
+ container.style.pointerEvents = "none";
+ container.style.zIndex = "300";
+ if (orphaned) {
+ while (orphaned.firstChild) container.appendChild(orphaned.firstChild);
+ }
+ this.container = container;
+ }
+
+ if (container.parentElement !== parent) {
+ parent.appendChild(container);
+ }
+
+ return container;
+ }
+
+ _repositionAll() {
+ for (const id of this.loaders.keys()) this._applyTransform(id);
+ }
+
+ _startLoop() {
+ if (this._looping) return;
+ this._looping = true;
+ const step = () => {
+ if (this.loaders.size === 0) {
+ this._looping = false;
+ return;
+ }
+ this._ensureContainer();
+ this._repositionAll();
+ this._rafId = requestAnimationFrame(step);
+ };
+ this._rafId = requestAnimationFrame(step);
+ }
+
+ _applyTransform(id) {
+ const loader = this.loaders.get(id);
+ const container = this.container;
+ if (!loader || !container) return;
+
+ const rect = container.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+
+ const stageWidth = this.runtime.stageWidth || DEFAULT_STAGE_W;
+ const pxPerUnit = rect.width / stageWidth;
+
+ const leftPx = rect.width / 2 + loader.x * pxPerUnit;
+ const topPx = rect.height / 2 - loader.y * pxPerUnit;
+
+ loader.wrapper.style.left = `${leftPx}px`;
+ loader.wrapper.style.top = `${topPx}px`;
+ loader.wrapper.style.fontSize = `${Math.max(1, loader.size * pxPerUnit)}px`;
+ }
+
+ _renderPreset(loader) {
+ const build = PRESETS[loader.style] || PRESETS.ring;
+ loader.wrapper.innerHTML = build();
+ }
+
+ _getOrNull(id) {
+ return this.loaders.get(String(id)) || null;
+ }
+
+ createLoader(args) {
+ const id = String(args.ID);
+ const container = this._ensureContainer();
+ if (!container) return;
+
+ let loader = this.loaders.get(id);
+ if (!loader) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "pma-loader-wrapper";
+ container.appendChild(wrapper);
+ loader = {
+ wrapper,
+ x: 0,
+ y: 0,
+ size: 60,
+ speed: 1,
+ color: "#8b5cf6",
+ style: "ring",
+ visible: true,
+ };
+ this.loaders.set(id, loader);
+ this._startLoop();
+ }
+
+ loader.style = String(args.STYLE);
+ loader.wrapper.style.setProperty("--pma-color", loader.color);
+ loader.wrapper.style.setProperty("--pma-speed", String(loader.speed));
+ loader.wrapper.style.display = loader.visible ? "" : "none";
+ this._renderPreset(loader);
+ this._applyTransform(id);
+ }
+
+ removeLoader(args) {
+ const id = String(args.ID);
+ const loader = this.loaders.get(id);
+ if (!loader) return;
+ loader.wrapper.remove();
+ this.loaders.delete(id);
+ }
+
+ removeAllLoaders() {
+ for (const id of Array.from(this.loaders.keys())) {
+ this.removeLoader({ ID: id });
+ }
+ }
+
+ showLoader(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.visible = true;
+ loader.wrapper.style.display = "";
+ }
+
+ hideLoader(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.visible = false;
+ loader.wrapper.style.display = "none";
+ }
+
+ setLoaderPosition(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.x = Number(args.X) || 0;
+ loader.y = Number(args.Y) || 0;
+ this._applyTransform(String(args.ID));
+ }
+
+ changeLoaderPosition(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.x += Number(args.X) || 0;
+ loader.y += Number(args.Y) || 0;
+ this._applyTransform(String(args.ID));
+ }
+
+ setLoaderSize(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.size = Math.max(1, Number(args.SIZE) || 60);
+ this._applyTransform(String(args.ID));
+ }
+
+ setLoaderSpeed(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ let speed = Number(args.SPEED);
+ if (!isFinite(speed) || speed === 0) speed = 1;
+ speed = Math.min(50, Math.max(0.05, Math.abs(speed)));
+ loader.speed = speed;
+ loader.wrapper.style.setProperty("--pma-speed", String(speed));
+ }
+
+ setLoaderColor(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ const hex = String(args.COLOR);
+ loader.color = hex;
+ loader.wrapper.style.setProperty("--pma-color", hex);
+ }
+
+ setLoaderStyle(args) {
+ const loader = this._getOrNull(args.ID);
+ if (!loader) return;
+ loader.style = String(args.STYLE);
+ this._renderPreset(loader);
+ this._applyTransform(String(args.ID));
+ }
+
+ loaderExists(args) {
+ return this.loaders.has(String(args.ID));
+ }
+
+ loaderVisible(args) {
+ const loader = this._getOrNull(args.ID);
+ return !!loader && loader.visible;
+ }
+
+ getLoaderX(args) {
+ const loader = this._getOrNull(args.ID);
+ return loader ? loader.x : 0;
+ }
+
+ getLoaderY(args) {
+ const loader = this._getOrNull(args.ID);
+ return loader ? loader.y : 0;
+ }
+ }
+
+ Scratch.extensions.register(new AnimatedLoaders(Scratch.vm.runtime));
+})(Scratch);
diff --git a/static/images/Ikelene/loaders.png b/static/images/Ikelene/loaders.png
new file mode 100644
index 000000000..846ef8733
Binary files /dev/null and b/static/images/Ikelene/loaders.png differ