ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /stage.js/
import {MakeBlock, map, flatten, duplicate, append, remove, removeInput, MakeSlot} from "./model.js"
import {Variable, List, VariableLibrary, ListLibrary, NumberSlot, TextSlot, Define, compareName, Custom, EnumeratedSlot} from "./core.js"
import {toNumber, toString, compileForDispatcher, Dispatcher, ticker} from "./compile.js"
import {categories as categories0, closestBlockID, query} from "./view.js"
import {addVariable, addList, addBlock, ProgramArea, LibraryArea, Scrollable, History, addValue} from "./areas.js"
import {Scratch} from "./scratch.js"
import {fromSyntax} from "./syntax.js"
export let audioContext = new AudioContext()
let svgns = "http://www.w3.org/2000/svg"
let extensionNames = ["pen", "music"]
let canvas = new OffscreenCanvas(1, 1)
canvas.getContext("2d")
let blankBlob = await canvas.convertToBlob()
let KeySlot = EnumeratedSlot(["any", "space", "up arrow", "down arrow", "left arrow", "right arrow", ..."abcdefghijklmnopqrstuvwxyz0123456789"])
let keyMap = new Map([
[" ", "space"],
["Spacebar", "space"],
["Up", "Down", "Left", "Right"].map(key => [key, key.toLowerCase() + " arrow"]),
..."abcdefghijklmnopqrstuvwxyz0123456789".split("").flatMap(key => [[key, key], [key.toUpperCase(), key]]),
])
export class Stage {
element = document.createElementNS(svgns, "svg")
spritesElement = document.createElementNS(svgns, "g")
bubblesElement = document.createElementNS(svgns, "foreignObject")
sprites = []
messages = []
ClosedMessageSlot = EnumeratedSlot(this.messages, {dynamic: true})
OpenMessageSlot = EnumeratedSlot(this.messages, {dynamic: true, canFit: undefined})
WhenClicked = MakeBlock({hat: true, run: event => event === "click", name: () => ["when stage clicked"], category: "events", stageOnly: true})
WhenReceived = MakeBlock({hat: true, run: (event, message) => event === `message: ${toString(message)}`, name: message => ["when I receive", message], category: "events", slots: [this.ClosedMessageSlot], defaults: ["message1"]})
WhenKeyPressed = MakeBlock({hat: true, run: (event, key) => event === `key: ${key}`, name: key => ["when", key, "key pressed"], category: "events", slots: [KeySlot], defaults: ["space"]})
variables = new Map()
lists = new Map()
backdrops = []
scripts = []
extensionMap = new Map()
volume = 100
audioDestination = audioContext.createGain()
library = []
pointerX = 0
pointerY = 0
mouseX = 0
mouseY = 0
extensions = {}
#background = document.createElementNS(svgns, "rect")
#Whenever = MakeBlock({hat: true, run: event => event === "run", name: () => ["whenever"]})
#backdrop
#mouseX = 0
#mouseY = 0
#mouseDown = false
#startListeners = []
#loadListeners = []
#backdropElement
#keys = new Set()
constructor(options = {})
{
this.name = options.name ?? "New Project"
this.tick = options.tick ? (() => options.tick()) : (() => ticker.tick())
let tickListeners = []
this.dispatcher = new Dispatcher(() =>
{
for (let fn of tickListeners) fn()
return this.tick()
})
this.scratch = Scratch({
dispatcher: this.dispatcher,
onBeforeTick: fn => tickListeners.push(fn),
username: options.username,
other: id => this.#other(id),
})
this.element.classList.add("stage")
this.element.setAttribute("viewBox", "-240 -180 480 360")
let g = document.createElementNS(svgns, "g")
let clipPath = document.createElementNS(svgns, "clipPath")
clipPath.id = "clip-path"
this.element.append(clipPath, g)
g.setAttribute("clip-path", "url('#clip-path')")
let rect = document.createElementNS(svgns, "rect")
rect.setAttribute("x", "-240")
rect.setAttribute("y", "-180")
rect.setAttribute("width", "480")
rect.setAttribute("height", "360")
clipPath.append(rect)
this.#background.setAttribute("x", "-240")
this.#background.setAttribute("y", "-180")
this.#background.setAttribute("width", "480")
this.#background.setAttribute("height", "360")
this.#background.setAttribute("fill", "#FFF")
this.bubblesElement.setAttribute("x", "-240")
this.bubblesElement.setAttribute("y", "-180")
this.bubblesElement.setAttribute("width", "480")
this.bubblesElement.setAttribute("height", "360")
this.#backdropElement = document.createElementNS(svgns, "g")
this.bubblesElement.classList.add("bubbles")
g.append(this.#background, this.#backdropElement, this.spritesElement, this.bubblesElement)
this.backdrop = this.makeBackdrop()
this.element.addEventListener("pointerdown", event =>
{
this.#updateMouse(event)
if (event.button === 0) this.#mouseDown = true
if (!event.target.closest(".sprite")) this.dispatcher.dispatch("click", this.scripts.map(({id}) => id))
})
addEventListener("pointerup", event =>
{
if (event.button === 0) {
this.#mouseDown = false
this.dragged = undefined
}
})
addEventListener("pointermove", event => this.#updateMouse(event))
this.element.addEventListener("contextmenu", event => event.preventDefault())
addEventListener("keyup", event => this.#keys.delete(keyMap.get(event.key)))
addEventListener("keydown", event =>
{
this.dispatcher.dispatch("key: any")
let key = keyMap.get(event.key)
if (!key) return
this.#keys.add(key)
this.dispatcher.dispatch("key: " + key)
})
this.audioDestination.connect(audioContext.destination)
this.library.push(
this.WhenClicked, this.WhenReceived, this.WhenKeyPressed,
...makeLibrary(this),
)
this.updateMessages()
}
get backdrop()
{
return this.#backdrop
}
set backdrop(backdrop)
{
this.#backdrop = backdrop
this.#backdropElement.textContent = ""
this.#backdropElement.append(backdrop.element)
}
makeSprite(options = {})
{
return new Sprite(this, undefined, options)
}
getSprite(name)
{
return this.sprites.find(sprite => sprite.name === name)
}
makeBackdrop(options = {})
{
let getBackdrop = () => this.backdrop
let setBackdrop = backdrop => this.backdrop = backdrop
let backdrop = new Costume(options, {
name: "backdrop", x: 0, y: 0,
costumes: this.backdrops,
get costume() { return getBackdrop() },
set costume(costume) { setBackdrop(costume) },
})
this.backdrops.push(backdrop)
return backdrop
}
onStart(fn)
{
this.#startListeners.push(fn)
}
async start(ids)
{
for (let fn of this.#startListeners) fn()
for (let sprite of this.sprites) {
while (sprite.clones.length > 0) sprite.clones[0].delete()
}
for (let element of this.bubblesElement.children) element.textContent = ""
await this.#compile(this.scripts, {variables: this.variables, lists: this.lists, dispatcher: this.dispatcher})
for (let sprite of this.sprites) await this.#compile(sprite.scripts, {variables: sprite.variables, lists: sprite.lists, dispatcher: this.dispatcher}, sprite)
return this.dispatcher.start(ids)
}
stop(ids)
{
this.dispatcher.stop(ids)
this.dispatcher.remove(ids)
}
async run(block, sprite = this)
{
let index = block.parent?.stack?.indexOf(block) ?? -1
let blocks = (index < 0 ? [block] : block.parent.stack.slice(index)).map(duplicate)
let result
if (block.type.shape === "reporter") {
let block = blocks[0]
result = new Promise(resolve =>
{
let Report = MakeBlock({run: value => resolve(toString(value)), name: value => ["report", value], slots: [TextSlot]})
blocks = [Report(block)]
})
result = result.then(value => { removeInput(block) ; return value })
}
if (block.type.hat) blocks = blocks[0].stack
let whenever = this.#Whenever()
append(whenever, ...blocks)
let [other] = await this.#compile([whenever, ...sprite.scripts], {variables: sprite.variables, lists: sprite.lists, dispatcher: this.dispatcher}, sprite)
return {
stop: () => this.stop([other.id]),
result: this.dispatcher.dispatch("run", [other.id])
.then(() => this.dispatcher.remove([other.id]))
.then(() => result),
}
}
onExtensionLoaded(fn)
{
this.#loadListeners.push(fn)
}
async addExtension(name)
{
if (!extensionNames.includes(name)) return
if (this.extensionMap.has(name)) return
let {extendStage = () => { }, extendSprite = () => { }, extendClone = extendSprite} = await import(`./${name}.js`)
this.extensionMap.set(name, {extendStage, extendSprite, extendClone})
this.extensions[name] = extendStage(this, name)
for (let sprite of this.sprites) {
sprite.extensions[name] = extendSprite(sprite, name)
for (let clone of sprite.clones) clone.extensions[name] = extendClone(clone, name)
}
for (let fn of this.#loadListeners) fn(name)
return true
}
updateMessages(defaultMessage = "message1")
{
this.messages.length = 0
this.messages.push(defaultMessage)
for (let block of [this, ...this.sprites].flatMap(sprite => sprite.scripts).flatMap(flatten)) {
for (let [i, slot] of block.type.slots?.entries() ?? []) {
if (slot.type !== this.ClosedMessageSlot.type && slot.type !== this.OpenMessageSlot.type) {
continue
}
let input = block.inputs[i]
if (input.type.shape !== "slot") continue
if (this.messages.includes(input.value)) continue
this.messages.push(input.value)
}
}
}
#updateMouse(event)
{
this.pointerX = event.x
this.pointerY = event.y
let rect = this.#background.getBoundingClientRect()
this.#mouseX = (event.x - rect.x) * (480 / rect.width) - 240
this.#mouseX = Math.max(-240, this.#mouseX)
this.#mouseX = Math.min(240, this.#mouseX)
this.#mouseY = 180 - (event.y - rect.y) * (360 / rect.height)
this.#mouseY = Math.max(-180, this.#mouseY)
this.#mouseY = Math.min(180, this.#mouseY)
if (this.dragged) {
this.dragged.x = this.#mouseX
this.dragged.y = this.#mouseY
}
}
#other(id)
{
for (let {scripts} of [this, ...this.sprites, ...this.sprites.flatMap(sprite => sprite.clones)]) {
if (!scripts.some(block => block.id === id)) continue
return scripts.filter(block => block.id !== id).map(block => block.id)
}
return []
}
async #compile(blocks, options, sprite = this)
{
for (let {transform} of Object.values(this.extensions)) {
if (!transform) continue
blocks = blocks.map(duplicate)
await transform(blocks, sprite)
}
compileForDispatcher(blocks, options)
return blocks
}
////// BLOCKS /////
broadcast(message)
{
this.broadcastAndWait(message)
}
async broadcastAndWait(message)
{
await this.dispatcher.dispatch("message: " + toString(message))
}
// todo: allow switching to next/previous/random backdrop
setBackdrop(which)
{
let name = toString(which)
let index = Math.round(toNumber(which))
this.backdrop = this.backdrops.find(backdrop => backdrop.name === name) ?? this.backdrops[index - 1] ?? this.backdrop
}
nextBackdrop()
{
this.backdrop = this.backdrops[(this.backdrops.indexOf(backdrop) + 1) % this.backdrops.length] ?? this.backdrop
}
backdropName()
{
return this.backdrop.name
}
backdropNumber()
{
return this.backdrops.indexOf(this.backdrop) + 1
}
mouseDown()
{
return this.#mouseDown
}
getMouseX()
{
return this.#mouseX
}
getMouseY()
{
return this.#mouseY
}
spriteX(name)
{
return this.getSprite(name)?.x ?? 0
}
spriteY(name)
{
return this.getSprite(name)?.y ?? 0
}
spriteDirection(name)
{
return this.getSprite(name)?.direction ?? 0
}
spriteSize(name)
{
return this.getSprite(name)?.size ?? 0
}
spriteCostumeName(name)
{
return this.getSprite(name)?.costume.name ?? 0
}
spriteCostumeNumber(name)
{
let sprite = this.getSprite(name)
if (sprite) return sprite.costumes.indexOf(sprite.costume) + 1
return 0
}
// todo: make this a dropdown
// todo: figure out a way to avoid duplicating the blocks and recompile
async cloneSprite(name)
{
name = toString(name)
let sprite = this.sprites.find(sprite => sprite.name === name)
if (!sprite) return
let clone = new Sprite(this, sprite)
await this.#compile(clone.scripts, {variables: clone.variables, lists: clone.lists, dispatcher: this.dispatcher}, clone)
this.dispatcher.dispatch("clone", clone.scripts.map(({id}) => id))
return clone
}
keyPressed(key)
{
return this.#keys.has(toString(key))
}
// todo: add more blocks
}
class Sprite {
WhenClicked = MakeBlock({hat: true, run: event => event === "click", name: () => ["when this sprite clicked"], category: "events"})
WhenCloned = MakeBlock({hat: true, run: event => event === "clone", name: () => ["when I start as a clone"], category: "control"})
element = document.createElementNS(svgns, "g")
costumes = []
selfVariables = new Map()
selfLists = new Map()
variables = {
get: (name) => this.selfVariables.get(name) ?? this.stage.variables.get(name),
set: (name, value) =>
{
if (this.stage.variables.has(name)) this.stage.variables.set(name, {value})
else this.selfVariables.set(name, {value})
},
keys: () => [...this.selfVariables.keys(), ...this.stage.variables.keys()],
has: (name) => this.selfVariables.has(name) || this.stage.variables.has(name),
}
lists = {
get: (name) => this.selfLists.get(name) ?? this.stage.lists.get(name),
set: (name, value) => this.selfLists.set(name, value),
keys: () => [...this.selfLists.keys(), ...this.stage.lists.keys()],
has: (name) => this.selfLists.has(name) || this.stage.lists.has(name),
}
scripts = []
library = []
extensions = {}
#bubble = document.createElement("div")
#rotationStyle = "full"
#direction
#x = 0
#y = 0
#size = 100
#volume = 100
#effects = {
brightness: 0,
ghost: 0,
color: 0,
}
#moveListeners = []
#costume
#updatePending = false
#original
constructor(stage, original, options = {})
{
this.name = original?.name ?? options.name ?? "Sprite" + (stage.sprites.length + 1)
this.stage = stage
this.#original = original
this.audioDestination = audioContext.createGain()
this.direction = original?.direction ?? 90
this.x = original?.x ?? 0
this.y = original?.y ?? 0
this.size = original?.size ?? 100
this.volume = original?.volume ?? 100
this.element.classList.add("sprite")
if (original) original.element.after(this.element)
else this.stage.spritesElement.append(this.element)
this.#bubble.classList.add("bubble")
this.stage.bubblesElement.append(this.#bubble)
this.element.addEventListener("pointerdown", event =>
{
if (event.button !== 0) return
stage.dispatcher.dispatch("click", this.scripts.map(({id}) => id))
if (this.draggable) this.stage.dragged = this
})
if (original) {
for (let costume of original.costumes) this.makeCostume({name: costume.name, x: costume.x, y: costume.y, url: costume.url, blob: costume.blob})
this.costume = this.costumes[original.costumes.indexOf(original.costume)]
}
else {
this.costume = this.makeCostume()
}
this.element.append(this.costume.element)
for (let [name, {value}] of original?.selfVariables ?? []) this.selfVariables.set(name, {value})
for (let [name, list] of original?.selfLists ?? []) this.selfLists.set(name, list.slice())
this.audioDestination.connect(audioContext.destination)
this.library.push(
this.WhenClicked, this.WhenCloned,
...makeLibrary(this),
)
if (original) {
this.original = original.original ?? original
this.clones = this.original.clones
}
else {
this.clones = []
}
if (!original) {
this.stage.sprites.push(this)
for (let [name, {extendSprite}] of this.stage.extensionMap) this.extensions[name] = extendSprite(this, name)
}
else {
original.clones.push(this)
for (let [name, {extendClone}] of this.stage.extensionMap) this.extensions[name] = extendClone(this, name)
if (!original.visible) this.hide()
this.scripts.push(...original.scripts.map(block => map(block, (block, inputs, stack) =>
{
if (typeof block.type === "string") return
let found = Object.entries(original).find(([_name, fn]) => typeof fn === "function" && fn.type === block.type)
for (let extension of Object.values(this.extensions)) {
if (found) break
if (!extension?.blocks) continue
found = Object.entries(extension.blocks).find(([_name, fn]) => typeof fn === "function" && fn.type === block.type)
}
if (!found) return
let other = this[found[0]](...inputs)
append(other, ...stack ?? [])
return other
})))
}
this.update()
}
get rotationStyle()
{
return this.#rotationStyle
}
get direction()
{
return this.#direction
}
set direction(direction)
{
this.#direction = toNumber(direction) % 360
if (this.#direction <= -180) this.#direction = 360 - this.#direction
if (this.#direction > 180) this.#direction -= 360
this.update()
}
get x()
{
return this.#x
}
set x(x)
{
this.#x = x
this.update()
}
get y()
{
return this.#y
}
set y(y)
{
this.#y = y
this.update()
}
get size()
{
return this.#size
}
set size(size)
{
this.#size = size
this.update()
}
get volume()
{
return this.#volume
}
set volume(volume)
{
this.#volume = volume
this.audioDestination.gain.value = volume / 100
}
get costume()
{
return this.#costume
}
set costume(costume)
{
this.#costume = costume
this.element.textContent = ""
if (costume) this.element.append(costume.element)
}
makeCostume(options = {})
{
let getCostume = () => this.costume
let setCostume = costume => this.costume = costume
let costume = new Costume(options, {
name: "costume", x: 0, y: 0,
costumes: this.costumes,
get costume() { return getCostume() },
set costume(costume) { setCostume(costume) },
})
this.costumes.push(costume)
return costume
}
remove()
{
if (this.original) return
for (let clone of this.clones) clone.delete()
this.stage.sprites.splice(this.stage.sprites.indexOf(this), 1)
this.element.remove()
this.#bubble.remove()
}
getFilter()
{
let filter = []
// todo: saturate slightly (like in Scratch)
if (this.#effects.color !== 0) filter.push(`hue-rotate(${this.#effects.color / 200}turn)`)
if (this.#effects.brightness !== 0) {
if (this.#effects.brightness > 99) filter.push("brightness(0) invert()")
else filter.push(`brightness(${1 / (1 - this.#effects.brightness / 100)})`)
}
if (this.#effects.ghost !== 0) filter.push(`opacity(${1 - this.#effects.ghost / 100})`)
return filter.join(" ")
}
update()
{
if (this.#updatePending) return
this.#updatePending = true
requestAnimationFrame(() => this.#update())
}
onMove(fns)
{
this.#moveListeners.push(fns)
}
get visible()
{
return !this.element.hasAttribute("opacity")
}
#getRotation()
{
if (this.rotationStyle === "full") return `rotate(${this.direction - 90})`
if (this.rotationStyle === "left-right") return this.direction < 0 || this.direction >= 180 ? "scale(-1, 1)" : ""
if (this.rotationStyle === "none") return ""
}
#move(fn)
{
for (let {before} of this.#moveListeners) before()
fn()
for (let {after} of this.#moveListeners) after()
}
#update()
{
this.#updatePending = false
this.element.style.setProperty("filter", this.getFilter())
this.element.setAttribute("transform", `translate(${this.x}, ${-this.y}) scale(${this.size / 100}) ${this.#getRotation()}`)
if (this.#effects.ghost >= 50) this.element.style.setProperty("pointer-events", "none")
else this.element.style.removeProperty("pointer-events")
if (!this.#bubble.textContent) return
let rect0 = this.stage.bubblesElement.getBoundingClientRect()
let rect = this.costume.image.getBoundingClientRect()
this.#bubble.style.setProperty("left", `${(rect.right - rect0.left) / (rect0.width / 480) - 8}px`)
this.#bubble.style.setProperty("bottom", `${(rect0.bottom - rect.top) / (rect0.height / 360) - 8}px`)
}
///// BLOCKS /////
move(n)
{
this.#move(() =>
{
n = toNumber(n)
if (this.rotationStyle !== "full") {
if (this.rotationStyle === "left-right" && (this.direction < 0 || this.direction >= 180)) {
this.x -= n
}
else {
this.x += n
}
return
}
let radians = this.direction * (Math.PI / 180)
this.x += Math.sin(radians) * n
this.y += Math.cos(radians) * n
})
}
rotateRight(degrees)
{
this.direction += toNumber(degrees)
}
rotateLeft(degrees)
{
this.direction -= toNumber(degrees)
}
say(message)
{
this.#bubble.textContent = toString(message).trim()
this.update()
}
async sayFor(message, duration)
{
this.say(message)
await new Promise(resolve => setTimeout(resolve, toNumber(duration) * 1000))
this.say("")
}
think(thought)
{
this.say(thought)
}
async thinkFor(thought, duration)
{
await this.sayFor(thought, duration)
}
gotoXY(x, y)
{
this.#move(() =>
{
this.x = toNumber(x)
this.y = toNumber(y)
})
}
setDirection(n)
{
this.direction = toNumber(n)
}
setX(n)
{
this.#move(() => this.x = toNumber(n))
}
setY(n)
{
this.#move(() => this.y = toNumber(n))
}
setSize(n)
{
this.size = toNumber(n)
}
changeX(n)
{
this.#move(() => this.x += toNumber(n))
}
changeY(n)
{
this.#move(() => this.y += toNumber(n))
}
changeSize(n)
{
this.size += toNumber(n)
}
async glideToXY(x1, y1, time)
{
let x0 = this.x
let y0 = this.y
x1 = toNumber(x1)
y1 = toNumber(y1)
time *= 1000
let later = performance.now() + time
while (true) {
let now = performance.now()
if (now > later) break
let t0 = (later - now) / time
let t1 = 1 - t0
this.gotoXY(x0 * t0 + x1 * t1, y0 * t0 + y1 * t1)
await this.stage.tick()
}
this.gotoXY(x1, y1)
}
getX()
{
return this.x
}
getY()
{
return this.y
}
getDirection()
{
return this.direction
}
getSize()
{
return this.size
}
clone()
{
return this.stage.cloneSprite(this.name)
}
delete()
{
if (!this.#original) return
this.clones.splice(this.clones.indexOf(this), 1)
let ids = this.scripts.map(({id}) => id)
this.stage.stop(ids)
this.element.remove()
this.#bubble.remove()
}
show()
{
this.element.removeAttribute("opacity")
}
hide()
{
this.element.setAttribute("opacity", "0")
}
setCostume(which)
{
let name = toString(which)
let index = Math.round(toNumber(which))
this.costume = this.costumes.find(costume => costume.name === name) ?? this.costumes[index - 1] ?? this.costume
}
nextCostume()
{
this.costume = this.costumes[(this.costumes.indexOf(this.costume) + 1) % this.costumes.length]
}
costumeName()
{
return this.costume.name
}
costumeNumber()
{
return this.costumes.indexOf(this.costume) + 1
}
gotoFront()
{
this.stage.spritesElement.append(this.element)
}
gotoBack()
{
this.stage.spritesElement.prepend(this.element)
}
goForward(count)
{
count = Math.round(toNumber(count))
if (count < 0) this.goBackward(count)
for (let i = 0 ; i < count ; i++) {
if (!this.element.nextElementSibling) break
this.element.nextElementSibling.after(this.element)
}
}
goBackward(count)
{
count = Math.round(toNumber(count))
if (count < 0) this.goForward(count)
for (let i = 0 ; i < count ; i++) {
if (!this.element.previousElementSibling) break
this.element.previousElementSibling.before(this.element)
}
}
touchingMouse()
{
return document.elementsFromPoint(this.stage.pointerX, this.stage.pointerY).some(other => this.element.contains(other))
}
clearEffects()
{
for (let name in this.#effects) this.#effects[name] = 0
this.update()
}
setEffect(name, value)
{
this.#effects[name] = toNumber(value)
this.update()
}
changeEffect(name, value)
{
this.#effects[name] += toNumber(value)
this.update()
}
disableRotation()
{
this.#rotationStyle = "none"
this.update()
}
enableRotation()
{
this.#rotationStyle = "full"
this.update()
}
enableFlipping()
{
this.#rotationStyle = "left-right"
this.update()
}
distanceToSprite(name)
{
name = toString(name)
let other = this.stage.sprites.find(sprite => sprite.name === name)
if (!other) return 10000
return Math.sqrt((other.x - this.x) ** 2 + (other.y - this.y0) ** 2)
}
// todo: add more blocks
}
class Costume {
element = document.createElementNS(svgns, "foreignObject")
image = document.createElement("img")
#options
#blob
#x = 0
#y = 0
constructor(options, options1)
{
this.#options = options1
this.element.classList.add("costume")
this.element.setAttribute("x", "-240")
this.element.setAttribute("y", "-180")
this.element.setAttribute("width", "480")
this.element.setAttribute("height", "360")
this.name = options.name ?? this.#options.name + (this.#options.costumes.length + 1)
this.image.draggable = false
this.element.append(this.image)
this.blob = options.blob ?? blankBlob
this.x = options.x ?? 0
this.y = options.y ?? 0
}
remove()
{
this.#revokeURL()
this.#options.costumes.splice(this.#options.costumes.indexOf(this), 1)
if (this.#options.costume === this) this.#options.costume = this.#options.costumes[0]
}
get blob()
{
return this.#blob
}
set blob(blob)
{
this.#revokeURL()
this.url = URL.createObjectURL(blob)
this.#blob = blob
this.image = document.createElement("img")
this.image.draggable = false
this.image.src = this.url
this.image.style.setProperty("opacity", "0")
this.image.style.setProperty("pointer-events", "none")
this.image.style.setProperty("position", "fixed")
this.image.addEventListener("load", async event =>
{
let target = event.target
await new Promise(resolve => setTimeout(resolve, 25))
if (target !== this.image) {
target.remove()
return
}
this.image.removeAttribute("style")
this.element.querySelector("img").replaceWith(this.image)
this.#updateOffset()
})
document.body.append(this.image)
}
get x()
{
return this.#x
}
get y()
{
return this.#y
}
set x(x)
{
this.#x = x
this.#updateOffset()
}
set y(y)
{
this.#y = y
this.#updateOffset()
}
#updateOffset()
{
if (!this.element.contains(this.image)) return
let transform = "translate(240px, 180px)"
if (this.blob?.type !== "image/svg+xml") transform += " scale(0.5, 0.5)"
transform += ` translate(${-this.x}px, ${-this.y}px)`
this.image.style.setProperty("transform", transform)
}
#revokeURL()
{
if (!this.image.complete) {
let outer = this
this.image.addEventListener("load", revoke)
this.image.addEventListener("error", revoke)
return
function revoke()
{
outer.#revokeURL()
outer.image.removeEventListener("load", revoke)
outer.image.removeEventListener("error", revoke)
}
}
if (this.url) URL.revokeObjectURL(this.url)
}
}
class Areas {
#variables = []
#lists = []
#sprite
#blocks = []
#running = new Map()
#options
constructor(stage, sprite, options)
{
this.stage = stage
this.#sprite = sprite
this.#options = options
if (this.stage !== this.#sprite) this.sprite = this.#sprite
let addGlobalVariable
let addGlobalList
let getLocalVariables = () => [...this.#sprite.variables.keys()]
let getLocalLists = () => [...this.#sprite.lists.keys()]
let getGlobalVariables = () => [...this.stage.variables.keys(), ...this.stage.sprites.flatMap(sprite => [...sprite.selfVariables.keys()])]
let getGlobalLists = () => [...this.stage.lists.keys(), ...this.stage.sprites.flatMap(sprite => [...sprite.selfLists.keys()])]
if (this.#sprite !== this.stage) {
addGlobalVariable = name => this.stage.variables.set(name, {value: 0})
addGlobalList = name => this.stage.lists.set(name, [])
}
else {
getLocalVariables = getGlobalVariables
getLocalLists = getGlobalLists
}
let addVariableButton = document.createElement("button")
addVariableButton.append(this.#options.messages?.getVariable?.() ?? "make a variable")
addVariableButton.classList.add("make", "make-variable")
addVariableButton.addEventListener("click", async () =>
{
await addVariable({
addLocal: name => this.#sprite.variables.set(name, {value: 0}),
addGlobal: addGlobalVariable,
locals: getLocalVariables(),
globals: getGlobalVariables(),
messages: this.#options.messages,
})
this.update()
})
let addListButton = document.createElement("button")
addListButton.append(this.#options.messages?.getList?.() ?? "make a list")
addListButton.classList.add("make", "make-list")
addListButton.addEventListener("click", async () =>
{
await addList({
addLocal: name => this.#sprite.lists.set(name, []),
addGlobal: addGlobalList,
locals: getLocalLists(),
globals: getGlobalVariables(),
messages: this.#options.messages,
})
this.update()
})
let addBlockButton = document.createElement("button")
addBlockButton.append(this.#options.messages?.getBlock?.() ?? "make a block")
addBlockButton.classList.add("make", "make-block")
addBlockButton.addEventListener("click", async () =>
{
let block = await addBlock({scripts:
this.#sprite.scripts,
messages: this.#options.messages,
getName: this.#options.getName,
})
if (!block) return
this.#sprite.scripts.push(block)
this.update()
})
let addExtensionButtons = []
let loadedExtensions = Object.keys(this.stage.extensions)
for (let name of extensionNames) {
let button = document.createElement("button")
addExtensionButtons.push(button)
button.disabled = loadedExtensions.includes(name)
button.append(this.#options.getExtensionName?.(name) ?? name)
button.classList.add("add-extension", `add-${name}-extension`)
button.addEventListener("click", () =>
{
button.disabled = true
this.stage.addExtension(name)
})
this.stage.onExtensionLoaded(name0 =>
{
if (name0 === name) button.disabled = true
})
}
let extra = new Map()
extra.set("variables", [addVariableButton])
extra.set("lists", [addListButton])
extra.set("custom", [addBlockButton])
extra.set("extensions", addExtensionButtons)
for (let category of ["motion", "sound"]) {
if (category === "motion" && this.#sprite !== this.stage) continue
if (this.#blocks .some(block => block.type.category === category)) continue
let div = document.createElement("div")
div.classList.add("missing-message")
div.append(this.#options.messages?.getMissing?.(category) ?? `no ${category} blocks implemented yet`)
extra.set(category, [div])
}
this.programArea = new ProgramArea(sprite.scripts, {
variables: this.#variables,
lists: this.#lists,
onDettached: block => this.#onDettached(block),
getName: (...args) => this.#getName(...args),
fromSyntax: syntax => this.#fromSyntax(syntax),
getItems: block => this.#getItems(block),
messages: this.#options.messages,
addValue: block => this.#addValue(block),
getAddMessage: type => type.options === this.stage.messages ? this.#options.messages?.getAddMessage?.() : undefined,
})
this.libraryArea = new LibraryArea(this.#blocks, block => categories0.includes(block.type.category) ? block.type.category : "extensions", {
order: categories0,
toName: name => this.#options.toCategoryName?.(name) ?? (name === "custom" ? "my blocks" : name),
extra,
getName: (...args) => this.#getName(...args),
getItems: block => this.#getItems(block),
variables: this.#variables,
lists: this.#lists,
messages: this.#options.messages,
getAddMessage: type => type.options === this.stage.messages ? this.#options.messages?.getAddMessage?.() : undefined,
})
new History([this.programArea])
new Scrollable({element: this.programArea.element})
new Scrollable({element: this.libraryArea.element, x: false})
this.programArea.onPaste(() => this.#updateLibrary())
let toRun
this.programArea.element.addEventListener("pointerdown", event =>
{
if (event.button !== 0) return
let id = closestBlockID(event.target)
if (!id) return
let block = this.#sprite.scripts.find(script => flatten(script.parent ?? script).some(block => block.id === id))
let element = query(id, this.programArea.element)
if (!element) return
toRun = {block, element}
})
this.libraryArea.element.addEventListener("pointerdown", event =>
{
if (event.button !== 0) return
let id = closestBlockID(event.target)
if (!id) return
let block = this.libraryArea.getBlock(id)
let element = query(id, this.libraryArea.element)
if (!element) return
toRun = {block, element}
})
addEventListener("pointerup", event =>
{
if (event.button !== 0) return
if (toRun?.element.contains(event.target)) this.run(toRun.block, toRun.element)
toRun = undefined
})
this.scripts = sprite.scripts
this.variables = sprite.variables
this.lists = sprite.lists
this.stage.onExtensionLoaded(() => this.#updateLibrary())
this.update()
}
async run(block, element)
{
let stop0 = this.#running.get(block.id)
if (stop0) {
if (typeof stop0 === "function") stop0()
return
}
let scriptElement = element?.closest(".script") ?? element
scriptElement?.classList.add("running")
this.#running.set(block.id, true)
let {result, stop} = await this.stage.run(block, this.#sprite)
this.#running.set(block.id, stop)
result = await result
this.#running.delete(block.id)
scriptElement?.classList.remove("running")
if (block.type.shape !== "reporter" || !element) return
let rect = element.getBoundingClientRect()
let div = document.createElement("div")
div.append(result)
div.style.setProperty("top", `${rect.bottom}px`)
div.style.setProperty("left", `${rect.left + rect.width / 2}px`)
div.classList.add("report")
addEventListener("pointerdown", () => div.remove(), {once: true})
document.body.append(div)
}
update()
{
this.#variables.length = 0
this.#lists.length = 0
this.#variables.push(...this.#sprite.variables.keys())
this.#lists.push(...this.#sprite.lists.keys())
this.#updateLibrary()
this.programArea.update()
}
onUpdate(fn)
{
this.programArea.onUpdate(fn)
}
#updateLibrary()
{
this.stage.updateMessages(this.#options.messages?.getMessage?.() ?? "message1")
let library = []
library.push(
...this.stage.scratch.library
.filter(fn => !fn.type?.references?.includes("variable"))
.filter(fn => !fn.type?.references?.includes("list")),
...this.#sprite.library,
...this.#sprite.scripts.filter(block => block.type === Define.type).map(block => () => Custom(block.names[0])),
...makeExtensionLibrary(this.#sprite),
...VariableLibrary([...this.#sprite.variables.keys()]),
...ListLibrary([...this.#sprite.lists.keys()]),
)
if (this.sprite) {
library.push(
...this.stage.library.filter(make => !make.type || !make.type.stageOnly),
...makeExtensionLibrary(this.stage, this.#sprite),
)
}
this.#blocks.length = 0
this.#blocks.push(...library.map(make => this.#options.Make?.(this, make) ?? make).map(make => make()))
this.libraryArea.update()
}
#fromSyntax(syntax)
{
let variables = [...this.#sprite.variables.keys()]
let lists = [...this.#sprite.lists.keys()]
let custom = this.#sprite.scripts.filter(block => block.type === Define.type).map(block => block.names[0])
let variablesLength = variables.length
let listsLength = lists.length
let customLength = custom.length
let blocks = fromSyntax(syntax, {
mutate: true,
variables, lists, custom,
types: this.#blocks.map(block => block.type),
getName: (...args) => this.#getName(...args),
})
variables = variables.slice(variablesLength)
lists = lists.slice(listsLength)
custom = custom.slice(customLength).filter(name => !blocks.some(block => block.type === Define.type && compareName(name, block.names[0])))
for (let name of variables) this.#sprite.variables.set(name, {value: 0})
for (let name of lists) this.#sprite.lists.set(name, [])
for (let name of custom) this.#sprite.scripts.push(Define(name))
return blocks.filter(block => block.type !== Define.type || !this.#sprite.scripts.some(block1 => block1.type === Define.type && compareName(block.names[0], block1.names[0])))
}
#getItems(block)
{
if (block?.type !== Variable.type && block?.type !== List.type) return
return [{
label: "delete",
run: () =>
{
let locals = block.type === Variable.type ? this.#sprite.selfVariables : this.#sprite.selfLists
let globals = block.type === Variable.type ? this.stage.variables : this.stage.lists
let removed = block.type === Variable.type ? "variable" : "list"
let isLocal = [...locals?.keys() ?? []].includes(block.value)
let sprites = isLocal ? [this.#sprite] : [this.stage, ...this.stage.sprites]
for (let {scripts} of sprites) {
for (let block1 of scripts.flatMap(flatten)) {
if (!block1.type.references?.includes(removed)) continue
let index = scripts.indexOf(block1)
if (index < 0) remove(block1)
else scripts.splice(index, 1)
}
}
if (isLocal) locals.delete(block.names[0])
else globals.delete(block.names[0])
this.update()
},
}]
}
#onDettached(block)
{
this.#running.get(block.id)?.()
if (block.type === Define.type) this.update()
}
#getName(...args)
{
return this.#options.getName?.(...args) ?? this.stage.scratch.getName(...args)
}
async #addValue(block)
{
if (block.type.options !== this.stage.messages) return
let value = await addValue({messages: this.#options.messages})
if (!value) return
block.type.options.push(value)
block.value = value
this.update()
}
}
export class StageAreas extends Areas {
constructor(stage, options = {})
{
super(stage, stage, options)
}
}
export class SpriteAreas extends Areas {
constructor(sprite, options = {})
{
super(sprite.stage, sprite, options)
}
}
function makeLibrary(sprite)
{
let combos = new Map()
let library = []
for (let name of Object.keys(nameFns)) {
if (!sprite[name]) continue
let upperCaseName = name[0].toUpperCase() + name.slice(1)
let slots1 = slots[name]
if (typeof slots1 === "function") slots1 = slots1(sprite)
let make = MakeBlock({
async: sync[name] === "async",
run: (...args) => sprite[name](...args),
slots: slots1 ?? defaults[name]?.map(n => typeof n === "number" ? NumberSlot : TextSlot),
category: categories[name] ?? "motion",
name: (...inputs) => nameFn(...inputs),
shape: shapes[name],
defaults: defaults[name],
stageOnly: stageOnly[name],
output: outputs[name] ?? (shapes[name] === "reporter" ? "text" : undefined),
cap: isCap[name],
})
sprite[upperCaseName] = make
let nameFn = nameFns[name]
if (typeof nameFn !== "function" && nameFn !== undefined) {
let {label, name} = nameFn
let combo = combos.get(name) ?? {options: []}
combos.set(name, combo)
combo.options.push({morph: block => make(...block.inputs), label, type: make.type})
nameFn = (...inputs) => nameFns[name](...inputs, combo)
if (combo.options.length > 1) continue
}
library.push(make)
}
return library
}
function makeExtensionLibrary(sprite, sprite0 = sprite)
{
let extensions = sprite.extensions
let [variable] = sprite0.variables.keys()
let [list] = sprite0.lists.keys()
let library = []
for (let extension of Object.values(extensions)) {
for (let make of Object.values(extension?.blocks ?? {})) {
if (make.type.references?.includes("variable") && !variable) continue
if (make.type.references?.includes("list") && !list) continue
if (make.type.references?.length) {
library.push(() => make(...make.type.references.map(reference => reference === "list" ? list : variable)))
continue
}
library.push(make)
}
}
return library
}
let nameFns = {
// motion
move: count => ["move", count, "steps"],
rotateRight: degrees => ["turn", [getIcon("turn-right"), "right", "cw"], degrees, "degrees"],
rotateLeft: degrees => ["turn", [getIcon("turn-left"), "right", "ccw"], degrees, "degrees"],
// todo: goto [ v]
gotoXY: (x, y) => ["go to x:", x, "y:", y],
// todo: glide to [ v]
glideToXY: (x, y, seconds) => ["glide", seconds, "secs to x:", x, "y:", y],
setDirection: degrees => ["point in direction", degrees],
// todo: point towards
changeX: x => ["change x by", x],
setX: x => ["set x to", x],
changeY: y => ["change y by", y],
setY: y => ["set y to", y],
// todo: bounce
enableRotation: {name: "setRotationStyle", label: "all around"},
enableFlipping: {name: "setRotationStyle", label: "left-right"},
disableRotation: {name: "setRotationStyle", label: "don't rotate"},
setRotationStyle: style => ["set rotation style", style],
getX: () => ["x position"],
getY: () => ["y position"],
getDirection: () => ["direction"],
// looks
sayFor: (message, duration) => ["say", message, "for", duration, "seconds"],
say: message => ["say", message],
thinkFor: (thought, duration) => ["think", thought, "for", duration, "seconds"],
think: thought => ["think", thought],
setCostume: costume => ["switch costume to", costume],
setBackdrop: which => ["switch backdrop to", which],
nextCostume: () => ["next costume"],
nextBackdrop: () => ["next backdrop"],
// todo: switch backdrop and wait
changeSize: size => ["change size by", size],
setSize: size => ["set size to", size, "%"],
changeEffect: (effect, value) => ["change", effect, "effect by", value],
setEffect: (effect, value) => ["set", effect, "effect to", value],
clearEffects: () => ["clear graphic effects"],
show: () => ["show"],
hide: () => ["hide"],
gotoLayer: which => ["go to", which, "layer"],
gotoBack: {name: "gotoLayer", label: "back"},
gotoFront: {name: "gotoLayer", label: "front"},
changeLayer: (count, which) => ["go", which, count, "layers"],
goForward: {name: "changeLayer", label: "forward"},
goBackward: {name: "changeLayer", label: "backward"},
costumeNumberOrName: which => ["costume", which],
costumeName: {name: "costumeNumberOrName", label: "name"},
costumeNumber: {name: "costumeNumberOrName", label: "number"},
backdropNameOrNumber: which => ["backdrop", which],
backdropName: {name: "backdropNameOrNumber", label: "name"},
backdropNumber: {name: "backdropNameOrNumber", label: "number"},
getSize: () => ["size"],
// sound
// (empty for now)
// events
broadcast: message => ["broadcast", message],
broadcastAndWait: message => ["broadcast", message, "and wait"],
// control
cloneThing: thing => ["create clone of", thing],
clone: {name: "cloneThing", label: "myself"},
cloneSprite: name => ["create clone of", name],
delete: () => ["delete this clone"],
mouseDown: () => ["mouse down?"],
// sensing
touching: thing => ["touching", thing, "?"],
touchingMouse: {name: "touching", label: "mouse-pointer"},
// todo: touching color
distanceToSprite: name => ["distance to", name],
// todo: ask and wait
// todo: answer
keyPressed: key => ["key", key, "pressed?"],
getMouseX: () => ["mouse x"],
getMouseY: () => ["mouse y"],
// todo: set drag mode
// todo: loudness
of: (name, property) => [property, "of", name],
spriteX: {name: "of", label: "x position"},
spriteY: {name: "of", label: "y position"},
spriteDirection: {name: "of", label: "direction"},
spriteSize: {name: "of", label: "size"},
spriteCostumeName: {name: "of", label: "costume name"},
spriteCostumeNumber: {name: "of", label: "costume #"},
}
let defaults = {
move: [10],
rotateRight: [15],
rotateLeft: [15],
gotoXY: [0, 0],
setDirection: [90],
setX: [0],
setY: [0],
changeX: [0],
changeY: [0],
setSize: [100],
changeSize: [10],
glideToXY: [0, 0, 1],
broadcast: ["message1"],
broadcastAndWait: ["message1"],
setBackdrop: [""],
goForward: [1],
goBackward: [1],
setCostume: [""],
spriteX: [""],
spriteY: [""],
spriteDirection: [""],
spriteSize: [""],
spriteCostumeName: [""],
spriteCostumeNumber: [""],
say: ["Hello!"],
sayFor: ["Hello!", 2],
think: ["Hmm..."],
thinkFor: ["Hmm...", 2],
cloneSprite: [""],
distanceToSprite: [""],
changeEffect: ["color", 25],
setEffect: ["color", 0],
keyPressed: ["space"],
}
let categories = {
changeSize: "looks",
setSize: "looks",
getSize: "looks",
clone: "control",
delete: "control",
show: "looks",
hide: "looks",
broadcast: "events",
broadcastAndWait: "events",
setBackdrop: "looks",
nextBackdrop: "looks",
backdropName: "looks",
backdropNumber: "looks",
setCostume: "looks",
nextCostume: "looks",
costumeName: "looks",
costumeNumber: "looks",
mouseDown: "sensing",
getMouseX: "sensing",
getMouseY: "sensing",
gotoFront: "looks",
gotoBack: "looks",
goForward: "looks",
goBackward: "looks",
spriteX: "sensing",
spriteY: "sensing",
spriteDirection: "sensing",
spriteSize: "sensing",
spriteCostumeName: "sensing",
spriteCostumeNumber: "sensing",
touchingMouse: "sensing",
setEffect: "looks",
changeEffect: "looks",
clearEffects: "looks",
say: "looks",
sayFor: "looks",
think: "looks",
thinkFor: "looks",
cloneSprite: "control",
distanceToSprite: "sensing",
keyPressed: "sensing",
}
let shapes = {
getX: "reporter",
getY: "reporter",
getDirection: "reporter",
getSize: "reporter",
backdropName: "reporter",
backdropNumber: "reporter",
costumeName: "reporter",
costumeNumber: "reporter",
mouseDown: "reporter",
getMouseX: "reporter",
getMouseY: "reporter",
spriteX: "reporter",
spriteY: "reporter",
spriteDirection: "reporter",
spriteSize: "reporter",
spriteCostumeName: "reporter",
spriteCostumeNumber: "reporter",
touchingMouse: "reporter",
distanceToSprite: "reporter",
keyPressed: "reporter",
}
let outputs = {
getX: "number",
getY: "number",
getDirection: "number",
getSize: "positive",
backdropNumber: "natural",
costumeNumber: "natural",
mouseDown: "boolean",
getMouseX: "number",
getMouseY: "number",
spriteX: "number",
spriteY: "number",
spriteDirection: "number",
spriteSize: "positive",
spriteCostumeNumber: "natural",
touchingMouse: "boolean",
distanceToSprite: "positive",
keyPressed: "boolean",
}
let sync = {
broadcastAndWait: "async",
glideToXY: "async",
sayFor: "async",
thinkFor: "async",
}
let slots = {
setEffect: [EnumeratedSlot(["color", "brightness", "ghost"]), NumberSlot],
changeEffect: [EnumeratedSlot(["color", "brightness", "ghost"]), NumberSlot],
broadcast: stage => [stage.OpenMessageSlot],
broadcastAndWait: stage => [stage.OpenMessageSlot],
keyPressed: [KeySlot],
}
let stageOnly = {
// empty (for now)
}
let isCap = {
delete: true,
}
function getIcon(name)
{
let span = document.createElement("span")
span.insertAdjacentHTML("beforeend", icons[name])
let svg = span.querySelector("svg")
svg.style.setProperty("height", "1.5em")
svg.style.setProperty("vertical-align", "middle")
return span
}
let icons = {
"turn-right": `<svg viewbox="0 0 24 24" fill="#444" stroke="#444"><path d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z"/></svg>`,
"turn-left": `<svg viewbox="0 0 24 24" fill="#444" stroke="#444"><path transform-origin="12 12" transform="scale(-1, 1)" d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z"/></svg>`,
"loop-arrow": `<svg viewbox="0 0 24 24" fill="#EEE" stroke="#444"><path transform-origin="12 12" transform="rotate(90)" d="M10 9V7.41c0-.89-1.08-1.34-1.71-.71L3.7 11.29c-.39.39-.39 1.02 0 1.41l4.59 4.59c.63.63 1.71.19 1.71-.7V14.9c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>`,
"stop-sign": `<svg viewbox="0 0 24 24" fill="#E24" stroke="#444"><path d="M14.9,3H9.1C8.57,3,8.06,3.21,7.68,3.59l-4.1,4.1C3.21,8.06,3,8.57,3,9.1v5.8c0,0.53,0.21,1.04,0.59,1.41l4.1,4.1 C8.06,20.79,8.57,21,9.1,21h5.8c0.53,0,1.04-0.21,1.41-0.59l4.1-4.1C20.79,15.94,21,15.43,21,14.9V9.1c0-0.53-0.21-1.04-0.59-1.41 l-4.1-4.1C15.94,3.21,15.43,3,14.9,3z"/></svg>`,
bolt: `<svg viewbox="6 0 12 24" fill="#EEE" stroke="#444"><path d="M10.67,21L10.67,21c-0.35,0-0.62-0.31-0.57-0.66L11,14H7.5c-0.88,0-0.33-0.75-0.31-0.78c1.26-2.23,3.15-5.53,5.65-9.93 c0.1-0.18,0.3-0.29,0.5-0.29h0c0.35,0,0.62,0.31,0.57,0.66L13.01,10h3.51c0.4,0,0.62,0.19,0.4,0.66c-3.29,5.74-5.2,9.09-5.75,10.05 C11.07,20.89,10.88,21,10.67,21z"/></svg>`,
}
function toColorElement(block, options)
{
let input = document.createElement("input")
input.value = block.value
input.type = "color"
if (options.frozen) input.disabled = true
input.addEventListener("change", () => block.value = value)
input.style.setProperty("display", "block")
input.style.setProperty("height", "256px")
input.style.setProperty("width", "256px")
input.style.setProperty("margin", "-128px")
input.style.setProperty("border", "none")
input.style.setProperty("padding", "0")
let span = document.createElement("span")
span.classList.add("input", "color-input")
span.append(input)
return span
}
export let ColorSlot = MakeSlot({toElement: toColorElement, type: "text", normalise: text => text.match(/^#[0-9A-Fa-f]{6}$/) ? text.toUpperCase() : "#000000"})
let css = `
.stage, .stage *, .stage ::before, .stage ::after {
touch-action: none;
image-rendering: crisp-edges;
image-rendering: pixelated;
user-select: none;
}
.bubbles {
pointer-events: none;
}
.bubble {
background: #FFF;
border: 3px solid #888;
width: max-content;
max-width: 16ch;
border-radius: 2em;
padding: 0.5em 1em;
position: absolute;
transform: translate(-8px, 8px);
border-bottom-left-radius: 0.5em;
}
.bubble:empty {
display: none;
}
.report {
position: fixed;
z-index: 5000;
background: #FFF;
border: 1px solid #888;
border-radius: 0.5em;
padding: 0.5em 1em;
box-shadow: 0 0 8px #0002;
transform: translate(-50%, 8px);
max-width: 24em;
text-align: center;
}
.running {
filter: drop-shadow(0 0 2px #FD6) drop-shadow(0 0 2px #FD6);
}
.costume {
pointer-events: none;
}
.costume > img {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
}
.color-input::before {
content: "x";
color: #0000;
}
`
let style = document.createElement("style")
style.textContent = css
document.head.append(style)