ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /areas.js/
import {flatten, append, after, prepend, removeInput, setInput, save, load, compare, replace, duplicate, complement, splice} from "./model.js"
import {Forever, Else, WaitUntil, RepeatUntil, Define, If, compareName, Custom, TextParameter, BooleanParameter} from "./core.js"
import {toElement, query, closestBlockID, closestParameterIndex, closestSlotID, closestNamePartIndex} from "./view.js"
import {toSyntax} from "./syntax.js"
let heldElement = document.createElement("div")
heldElement.classList.add("held")
document.body.append(heldElement)
let held
function updateHeld(event)
{
heldElement.style.setProperty("left", `${event.x + held.x}px`)
heldElement.style.setProperty("top", `${event.y + held.y}px`)
}
addEventListener("pointerup", () =>
{
if (!held) return
if (held.canDettach && !held.canDettach?.(held.blocks[0])) held.reattach?.()
else held.onDettached?.(held.blocks[0])
heldElement.textContent = ""
held = undefined
})
addEventListener("pointermove", event =>
{
if (!held) return
updateHeld(event)
})
export class Stoppable {
stoppable = this
#stoppables = []
#handlers = []
stop()
{
for (let [event, fn] of this.#handlers) removeEventListener(event, fn)
for (let stoppable of this.#stoppables) stoppable.stop()
}
addEventListener(event, fn)
{
this.#handlers.push([event, fn])
addEventListener(event, fn)
}
onStopped({stoppable})
{
if (!stoppable) return
this.#stoppables.push(stoppable)
}
}
class Attachable {
stoppable = new Stoppable()
#options
#ghostElement
#lastGhost
constructor(options)
{
this.#options = options
this.stoppable.addEventListener("pointerup", () =>
{
this.#dettachGhost()
this.#ghostElement = undefined
})
this.stoppable.addEventListener("pointermove", event =>
{
if (!held) return
if (this.#options.frozen?.()) return
if (!event.target.isConnected) return
if (!this.#options.element.contains(event.target)) {
this.#dettachGhost()
return
}
this.#attach(held.blocks[0], event, true)
})
this.#options.element.addEventListener("pointerup", event =>
{
if (!held) return
if (event.button !== 0) return
if (held.canDettach && !held.canDettach(held.blocks[0], this.#options.id)) {
held.reattach?.()
heldElement.textContent = ""
held = undefined
return
}
if (this.#options.canAttach && !this.#options.canAttach(held.blocks[0])) {
held.reattach?.()
heldElement.textContent = ""
held = undefined
return
}
held.onDettached?.(held.blocks[0], this.#options.id)
this.#attach(held.blocks[0], event)
this.#options.onAttached?.(held.blocks[0], held.id)
heldElement.textContent = ""
held = undefined
}, {capture: true})
}
#attach(held, event, ghost)
{
if (this.#options.frozen?.()) return
if (!this.#ghostElement) {
this.#ghostElement = this.#options.toElement(held)
for (let element of this.#ghostElement.querySelectorAll(".block")) {
element.classList.add("ghost")
}
}
let choices = []
if (this.#options.isTop(held)) {
choices.push({type: "top", y: this.#options.element.getBoundingClientRect().top})
for (let block of this.#options.blocks) {
choices.push({
type: "top",
block,
y: query(block.id, this.#options.element).closest(".script").getBoundingClientRect().bottom + 16,
})
}
}
let isHat = held.type.hat
if (held.type.shape === undefined) {
let cap = (held.parent ? held.parent.stack.at(-1) : held).type.cap && held.stack?.length !== 0
for (let top of this.#options.blocks) {
if (top.type.shape === undefined && !top.type.hat) {
if (!isHat || held.stack.length === 0 || !held.stack.at(-1).type.cap) {
let element = query(top.id, this.#options.element)
choices.push({block: top.parent, y: element.getBoundingClientRect().top, type: "inside"})
}
}
if (isHat) continue
for (let block of (top.parent?.stack ?? [top]).flatMap(flatten)) {
if (block.type.shape !== undefined) continue
let element = query(block.id, this.#options.element)
if (block.stack && (!cap || block.stack.length === 0)) {
let element = query(block.id, this.#options.element)
if (!element.matches(".line")) element = element.querySelector(".line")
choices.push({block, y: element.getBoundingClientRect().bottom, type: "inside"})
}
if (block.type.cap || block.type.hat) continue
if (cap && block !== block.parent.stack.at(-1)) continue
choices.push({block, y: element.getBoundingClientRect().bottom, type: "after"})
}
}
}
if (held.type.shape === "reporter") {
for (let top of this.#options.blocks) {
let element = query(top.id, this.#options.element).closest(".script")
if (!element.contains(event.target)) continue
for (let block of flatten(top.parent ?? top)) {
if (block.type.shape !== "slot") continue
if (block.type.canFit && !block.type.canFit(held)) continue
let element = query(block.id, this.#options.element)
if (!element) continue
let rect = element.getBoundingClientRect()
let x = rect.left + rect.width / 2
let y = rect.top + rect.height / 2
choices.push({x, y, block, type: "reporter"})
}
}
}
if (choices.length === 0) {
this.#dettachGhost()
return
}
let best = choices[0]
for (let choice of choices) {
choice.d = (choice.y - event.y) ** 2 + ((choice.x ?? event.x) - event.x) ** 2
if (choice.d < best.d) best = choice
}
let {block, type} = best
if (ghost && this.#lastGhost && this.#lastGhost.type === type && this.#lastGhost.block === block) return
this.#lastGhost = best
this.#dettachGhost()
if (type === "reporter") {
if (ghost) {
query(block.id).classList.add("ghost")
}
else {
setInput(block.parent, block.parent.inputs.indexOf(block), held)
query(block.id, this.#options.element).replaceWith(...this.#options.toElement(held).children)
}
return
}
if (type === "top") {
let i = this.#options.blocks.indexOf(block)
if (i === -1) this.#options.element.prepend(this.#ghostElement)
else query(this.#options.blocks[i].id, this.#options.element).closest(".script").after(this.#ghostElement)
if (!ghost) {
this.#options.blocks.splice(i + 1, 0, held)
for (let ghost of this.#options.element.querySelectorAll(".ghost")) ghost.classList.remove("ghost")
this.#lastGhost = undefined
}
return
}
let mouth
let element = query(block.id, this.#options.element)
if (block.parent?.type.complement === block.type) {
let block0 = block
while (block0.parent.type.complement === block0.type) block0 = block0.parent
let element = query(block0.id, this.#options.element)
mouth = element.querySelector(`.line[data-id="${block.id}"] + .mouth`)
}
else {
if (element) {
mouth = element.querySelector(".mouth")
}
else {
element = query(block.stack[0].id, this.#options.element)
mouth = element.closest(".script")
}
}
if (held.stack?.length === 0 && !isHat) {
let mouth1 = this.#ghostElement.querySelector(".mouth")
let elements
if (type === "after" || block.type.hat) {
elements = this.#options.element.querySelectorAll(`[data-id="${block.id}"] ~ *`)
element.after(...this.#ghostElement.children)
}
else {
elements = [...mouth.children]
mouth.prepend(...this.#ghostElement.children)
}
mouth1.append(...elements)
}
else {
if (type === "inside" && !block.type.hat) {
mouth.prepend(...this.#ghostElement.children)
}
else {
element.after(...this.#ghostElement.children)
}
}
if (ghost) return
if (held.stack?.length === 0 || isHat) {
let index = 0
if (type === "after") {
index = block.parent.stack.indexOf(block) + 1
block = block.parent
}
if (block.stack[index]) append(held, ...splice(block.stack[index]))
append(block, ...splice(held))
}
else {
if (type === "inside") prepend(block, ...splice(held))
else after(block, ...splice(held))
}
for (let [i, block] of this.#options.blocks.entries()) {
while (block?.parent) block = block.parent
if (!block.type.hat) block = block?.stack?.[0] ?? block
this.#options.blocks[i] = block
}
for (let ghost of this.#options.element.querySelectorAll(".ghost")) ghost.classList.remove("ghost")
this.#lastGhost = undefined
}
#dettachGhost()
{
this.#lastGhost = undefined
this.#options.element.querySelector(".ghost:not(.block)")?.classList.remove("ghost")
let ghost = this.#options.element.querySelector(".block.ghost")
if (!ghost) return
if (this.#ghostElement.isConnected) {
this.#ghostElement.remove()
return
}
ghost.replaceWith(...ghost.querySelectorAll(".ghost > .mouth > :not(.ghost)"))
this.#ghostElement.prepend(ghost)
while (true) {
let ghost = this.#options.element.querySelector(".block.ghost")
if (!ghost) return true
this.#ghostElement.append(ghost)
}
}
}
class Dettachable {
stoppable = new Stoppable()
#options
constructor(options)
{
this.#options = options
let resolve
this.stoppable.addEventListener("pointermove", () => { resolve?.() ; resolve = undefined })
this.stoppable.addEventListener("pointerup", () => resolve = undefined)
this.stoppable.addEventListener("pointerdown", async event =>
{
if (!this.#options.element.contains(event.target)) return
if (event.button !== 0) return
if (held) return
event.target.releasePointerCapture(event.pointerId)
await new Promise(resolve1 => resolve = resolve1)
let reattach
if (this.#options.save && this.#options.load) {
let structure = this.#options.save()
reattach = () => this.#options.load(structure)
}
let info = this.#dettach(event, event.ctrlKey || event.metaKey, event.shiftKey)
if (!info) return
held = {
x: info.rect.x - event.x,
y: info.rect.y - event.y,
blocks: info.block.parent?.stack?.slice() ?? [info.block],
canDettach: this.#options.canDettach,
reattach,
onDettached: this.#options.onDettached,
id: this.#options.id,
}
heldElement.append(toElement(held.blocks[0], {getName: this.#options.getName}))
updateHeld(event)
this.#options.element.dispatchEvent(new PointerEvent("pointermove", {clientX: event.x, clientY: event.y, bubbles: true}))
})
}
#dettach(event, single, duplicated)
{
if (this.#options.frozen?.()) return
let every = this.#options.blocks.flatMap(block => block.parent?.stack ?? [block]).flatMap(flatten)
let parameterInfo = closestParameterIndex(event.target)
if (parameterInfo) {
let {index, id, element} = parameterInfo
let block = every.find(block => block.id === id)
let {name, type} = block.names[0].filter(part => typeof part !== "string")[index]
return {block: type === "boolean" ? BooleanParameter(name) : TextParameter(name), rect: element.getBoundingClientRect()}
}
let id = closestBlockID(event.target)
if (!id) return
let block = every.find(block => block.id === id)
if (!block) return
let element1 = query(block.id, this.#options.element)
let result = {block, rect: element1.getBoundingClientRect()}
if (duplicated) {
let blocks = single ? [block] : block.parent?.stack?.slice(block.parent.stack.indexOf(block))
blocks = blocks.map(duplicate)
if (block.type.shape === undefined) Forever(...blocks)
result.block = blocks[0]
return result
}
let index = this.#options.blocks.indexOf(block)
if (block.type.shape === "reporter" && index < 0) {
let parent = block.parent
removeInput(block)
let result = {block, rect: query(block.id, this.#options.element).getBoundingClientRect()}
query(parent.id, this.#options.elements).replaceWith(this.#options.toElement(parent).children[0])
return result
}
let stack = block.parent?.stack
let blocks = !stack || single ? [block] : block.parent.stack.slice(block.parent.stack.indexOf(block))
Forever(...blocks)
if (index >= 0) {
this.#options.blocks.splice(index, 1, ...stack?.[0] ? [stack[0]] : [])
if (!stack?.[0]) element1.closest(".script").remove()
}
blocks.forEach(block => query(block.id, this.#options.element)?.remove())
return result
}
}
export class Scrollable {
stoppable = new Stoppable()
constructor(options = {})
{
let scrolling = false
this.stoppable.addEventListener("pointerup", () => scrolling = false)
options.element.addEventListener("pointerdown", event =>
{
if (event.pointerType !== "mouse") return
if (event.button !== 0) return
if (held) return
if (event.target.closest(".block")) return
scrolling = true
})
this.stoppable.addEventListener("pointermove", event =>
{
if (!scrolling) return
event.preventDefault()
let x = -event.movementX
let y = -event.movementY
if (!(options.x ?? true)) x = 0
if (!(options.y ?? true)) y = 0
options.element.scrollBy({left: x, top: y, behavior: "instant"})
})
}
}
export class History {
stoppable = new Stoppable()
#index = 0
#history = []
#loading = false
#areas
constructor(areas)
{
this.#areas = areas
this.#history.push(this.#save())
for (let area of this.#areas) area.onUpdate(() => this.save())
this.stoppable.addEventListener("keydown", event =>
{
if (!this.#areas.every(area => area.element.isConnected)) return
if (event.altKey) return
if (!event.ctrlKey && !event.metaKey) return
if (closestSlotID(event.target)) return
if (event.code === "KeyZ") {
event.preventDefault()
if (event.shiftKey) this.redo()
else this.undo()
}
if (event.code === "keyY") {
if (event.shiftKey) return
event.preventDefault()
this.redo()
}
})
}
redo()
{
if (this.#index >= this.#history.length - 1) return
this.#index++
this.#loading = true
for (let [area, structure] of this.#history[this.#index]) area.load(structure)
this.#loading = false
}
undo()
{
if (this.#index <= 0) return
this.#index--
this.#loading = true
for (let [area, structure] of this.#history[this.#index]) area.load(structure)
this.#loading = false
}
save()
{
if (this.#loading) return
this.#history.length = this.#index + 1
if ([...this.#history[this.#index]].every(([area, structure]) => area.compare?.(structure))) return
this.#history.push(this.#save())
while (this.#history.length > 128) this.#history.shift()
this.#index = this.#history.length - 1
}
#save()
{
let structure = new Map()
for (let area of this.#areas) structure.set(area, area.save())
return structure
}
}
class LibraryCategoryArea {
element = document.createElement("div")
stoppable = new Stoppable()
#blocks = []
#options
constructor(blocks, options = {})
{
this.#blocks = blocks
this.#options = options
this.element.classList.add("area", "library-category")
this.element.append(...this.#options.extra ?? [], ...this.#blocks.map(block => this.#toElement(block)))
let resolve
this.stoppable.addEventListener("pointermove", () => { resolve?.() ; resolve = undefined })
this.stoppable.addEventListener("pointerup", () => resolve = undefined)
this.stoppable.addEventListener("pointerdown", async event =>
{
if (!this.element.contains(event.target)) return
if (event.button !== 0) return
if (held) return
event.target.releasePointerCapture(event.pointerId)
await new Promise(resolve1 => resolve = resolve1)
let id = closestBlockID(event.target)
let i = this.#blocks.findIndex(block => block.id === id)
if (i < 0) return
let rect = query(id, this.element).getBoundingClientRect()
let block = duplicate(this.#blocks[i])
if (block.type.shape === undefined && !block.type.hat) Forever(block)
held = {x: rect.x - event.x, y: rect.y - event.y, blocks: block.parent?.stack?.slice() ?? [block]}
heldElement.append(toElement(held.blocks[0], {getName: this.#options.getName}))
updateHeld(event)
})
this.stoppable.onStopped(new ContextMenu({
element: this.element,
getBlock: id => this.#blocks.find(block => block.id === id),
getName: this.#options.getName,
getItems: this.#options.getItems,
messages: this.#options.messages,
}))
}
#toElement(block)
{
let self = this
function replace1(block0, block1, update)
{
let index = self.#blocks.indexOf(block0)
if (index < 0) replace(block0, block1)
else self.#blocks[index] = block1
if (update) query(block0.id, self.element).replaceWith(self.#toElement(block1).children[0])
}
return toElement(block, {
getName: this.#options.getName,
replace: replace1,
variables: this.#options.variables,
lists: this.#options.lists,
addValue: this.#options.addValue,
getAddMessage: this.#options.getAddMessage,
})
}
getBlock(id)
{
return this.#blocks.find(block => block.id === id)
}
}
export class LibraryArea {
element = document.createElement("div")
#areas = new Map()
#options
#categorise
#blocks
constructor(blocks, categorise, options = {})
{
this.#options = options
this.#blocks = blocks
this.#categorise = categorise
this.element.classList.add("area", "library")
this.update()
}
update()
{
let scroll = this.element.scrollTop
this.element.textContent = ""
let categories = new Map()
for (let block of this.#blocks) {
let which = this.#categorise(block)
if (!categories.has(which)) categories.set(which, [])
categories.get(which).push(block)
}
for (let category of this.#options.extra?.keys() ?? []) {
if (!categories.has(category)) categories.set(category, [])
}
if (this.#options.order) {
let categories1 = []
for (let category of this.#options.order) {
if (!categories.has(category)) continue
categories1.push([category, categories.get(category)])
categories.delete(category)
}
categories1.push(...categories)
categories = categories1
}
for (let [category, area] of [...this.#areas]) {
this.#areas.delete(category)
area.stoppable.stop()
}
for (let [category, blocks] of categories) {
let categoryElement = document.createElement("div")
categoryElement.classList.add("category")
this.element.append(categoryElement)
let nameElement = document.createElement("div")
nameElement.classList.add("category-name")
nameElement.append(this.#options.toName?.(category) ?? category)
nameElement.classList.add("category-name-" + category)
let area = new LibraryCategoryArea(blocks, {
extra: this.#options.extra?.get(category) ?? [],
getName: this.#options.getName,
getItems: this.#options.getItems,
variables: this.#options.variables,
lists: this.#options.lists,
messages: this.#options.messages,
addValue: this.#options.addValue,
getAddMessage: this.#options.getAddMessage,
})
categoryElement.append(nameElement, area.element)
this.#areas.set(category, area)
}
this.element.scrollTo({top: scroll, behavior: "instant"})
}
stop()
{
for (let area of this.#areas.values()) area.stoppable.stop()
}
getBlock(id)
{
return [...this.#areas.values()].map(area => area.getBlock(id)).find(Boolean)
}
}
export class ProgramArea {
element = document.createElement("div")
stoppable = new Stoppable()
#options
#blocks
#updateListeners = []
#pasteListeners = []
#id = Symbol()
constructor(blocks, options = {})
{
this.#options = options
this.#blocks = blocks
this.element.classList.add("area", "program")
this.stoppable.onStopped(new Dettachable({
id: this.#id,
element: this.element,
blocks: this.#blocks,
toElement: block => this.#toElement(block),
canDettach: (block, id) => this.#canDettach(block, id),
onDettached: (block, id) =>
{
if (id !== this.#id) this.#options.onDettached?.(block)
this.#updateListeners.forEach(fn => fn())
},
save: () => this.save(),
load: structure => this.load(structure),
getName: this.#options.getName,
}))
this.stoppable.onStopped(new Attachable({
id: this.#id,
element: this.element,
blocks: this.#blocks,
toElement: block => this.#toElement(block),
canAttach: block => this.#canAttach(block),
isTop: () => true,
onAttached: (block, id) =>
{
if (id !== this.#id) this.#options.onAttached?.(block)
this.#updateListeners.forEach(fn => fn())
},
getName: this.#options.getName,
}))
this.stoppable.onStopped(new ContextMenu({
element: this.element,
getBlock: id => this.#blocks.flatMap(block => flatten(block.parent ?? block)).find(block => block.id === id),
blocks: this.#blocks,
fromSyntax: this.#options.fromSyntax,
update: () =>
{
this.update()
for (let fn of this.#pasteListeners) fn()
},
getItems: this.#options.getItems,
getName: this.#options.getName,
messages: this.#options.messages,
}))
this.update()
}
update()
{
let scroll = this.element.scrollTop
this.element.textContent = ""
for (let block of this.#blocks) this.element.append(this.#toElement(block))
this.element.scrollTo({top: scroll, behavior: "instant"})
for (let fn of this.#updateListeners) fn()
}
onUpdate(fn)
{
this.#updateListeners.push(fn)
}
onPaste(fn)
{
this.#pasteListeners.push(fn)
}
save()
{
let structure = save(this.#blocks)
return {structure, blocks: this.#blocks.slice()}
}
load({structure, blocks})
{
load(structure)
this.#blocks.length = 0
this.#blocks.push(...blocks)
this.update()
}
compare({structure, blocks})
{
let other = this.save()
if (!compare(structure, other.structure)) return false
if (blocks.length !== other.blocks.length) return false
if (!other.blocks.every((block, i) => block === blocks[i])) return false
return true
}
#toElement(block)
{
let replace1
let frozen = this.#options.frozen?.()
if (!frozen) {
replace1 = (block0, block1, update) =>
{
let index = this.#blocks.indexOf(block0)
if (index < 0) replace(block0, block1)
else this.#blocks[index] = block1
if (update) query(block0.id, this.element).replaceWith(this.#toElement(block1).children[0])
}
}
return toElement(block, {
frozen,
replace: replace1,
variables: this.#options.variables,
lists: this.#options.lists,
getName: this.#options.getName,
addValue: this.#options.addValue,
getAddMessage: this.#options.getAddMessage,
})
}
#canDettach(block, id)
{
if (id === this.#id) return true
if (block.type !== Define.type) return true
for (let other of this.#blocks.flatMap(flatten)) {
if (other.type.category !== "custom") continue
if (other === block) continue
if (compareName(other.names[0], block.names[0])) return false
}
return true
}
#canAttach(block)
{
if (block.type === Define.type) {
for (let other of this.#blocks.flatMap(flatten)) {
if (other.type !== Define.type) continue
if (compareName(other.names[0], block.names[0])) continue
return
}
}
return true
}
}
class ContextMenu {
#options
constructor(options = {})
{
this.#options = options
this.#options.element.addEventListener("contextmenu", event => this.#handle(event))
}
#handle(event)
{
event.preventDefault()
if (held) return
let id = closestBlockID(event.target)
let blockElement = id && query(id, this.#options.element)
let block = id && this.#options.getBlock(id)
let items = []
if (block && this.#options.update && block.type.shape === undefined && block.type.category === "custom") {
items.push({
label: "edit block",
run: async () =>
{
let define = this.#options.blocks.find(other => other.type === Define.type && compareName(other.names[0], block.names[0]))
let renamed = await addBlock({
name: structuredClone(define.value),
atomic: define.atomic,
scripts: this.#options.blocks,
block: define,
getName: this.#options.getName,
messages: this.#options.messages,
})
if (!renamed) return
let usages = this.#options.blocks.flatMap(flatten).filter(other => other.type.shape === undefined && other.type.categories === "custom" && compareName(other.names[0], block.names[0]))
define.value = renamed.value
define.atomic = renamed.atomic
for (let block of usages) {
let other = Custom(define.names[0], ...block.inputs)
replace(block, other)
let index = this.#options.blocks.indexOf(block)
if (index >= 0) this.#options.blocks[index] = other
}
this.#options.update()
},
})
}
if (block) {
items.push({
label: "duplicate",
run: () =>
{
let index = block.parent?.stack?.indexOf(block) ?? -1
let blocks = index < 0 ? [block] : block.parent.stack.slice(index)
blocks = blocks.map(duplicate)
if (block.type.shape === undefined) Forever(...blocks)
let rect = blockElement.getBoundingClientRect()
held = {x: rect.x - event.x, y: rect.y - event.y, blocks}
heldElement.append(toElement(blocks[0], {getName: this.#options.getName}))
updateHeld(event)
}
}, {
label: block.type.shape === undefined ? "copy stack" : "copy block",
run: () => navigator.clipboard.writeText(toSyntax(block, {getName: this.#options.getName})),
})
}
if (this.#options.blocks) {
items.push({
label: "copy all",
run: () => navigator.clipboard.writeText(this.#options.blocks.map(block => toSyntax(block, {getName: this.#options.getName})).join("\n\n")),
disabled: this.#options.blocks.length === 0,
})
}
if (this.#options.update && !block) {
items.push({
label: "paste",
run: async () =>
{
await this.#fromClipboard()
this.#options.update()
},
})
}
if (this.#options.update && block) {
items.push({
label: "paste",
run: async () =>
{
await this.#fromClipboard(block)
this.#options.update()
},
})
if (block.type === If.type) {
if (!block.complement) {
items.push({
label: `add "else"`,
run: () =>
{
complement(block, Else())
this.#options.update()
},
})
}
else {
items.push({
label: `remove "else"`,
run: () =>
{
block.complement = undefined
this.#options.update()
},
disabled: block.complement.stack.length > 0,
})
}
}
if (block.type === "until") {
items.push({
label: `change to "wait until"`,
run: () => this.#replace(block, WaitUntil(block.inputs[0])),
disabled: block.stack.length > 0,
})
}
if (block.type === "wait-until") {
items.push({
label: `change to "repeat until"`,
run: () => this.#replace(block, RepeatUntil(block.inputs[0])),
})
}
}
items.push(...this.#options.getItems?.(block) ?? [])
if (items.length === 0) return
let element = document.createElement("div")
element.style.setProperty("top", `${event.y}px`)
element.style.setProperty("left", `${event.x}px`)
element.classList.add("ctx-menu")
element.addEventListener("focusout", event => element.contains(event.relatedTarget) || element.remove())
element.addEventListener("click", () => element.remove())
element.addEventListener("contextmenu", event => event.preventDefault())
document.body.append(element)
for (let item of items) {
let button = document.createElement("button")
element.append(button)
button.append(item.label)
button.addEventListener("click", () => item.run())
if (item.disabled) button.disabled = true
}
let button = element.querySelector("button:not(:disabled)")
if (button) button.focus()
else element.remove()
}
async #fromClipboard(block)
{
let blocks = this.#options.fromSyntax(await navigator.clipboard.readText().catch(() => ""))
let index = 0
if (block) {
let block0 = block
while (!this.#options.blocks.includes(block0.parent?.stack?.[0] ?? block0)) block0 = block0.parent
index = this.#options.blocks.indexOf(block0.parent?.stack?.[0] ?? block0)
if (index < 0) index = this.#options.blocks.length
}
this.#options.blocks.splice(index, 0, ...blocks)
this.#options.update?.()
}
#replace(block0, block1)
{
replace(block0, block1)
let index = this.#options.blocks.indexOf(block0)
if (index >= 0) this.#options.blocks[index] = block1
this.#options.update()
}
}
export function addList(options = {})
{
return addVariable({title: options.messages?.getList?.() ?? "make a list", ...options})
}
export function addVariable(options = {})
{
function update()
{
let name = getName()
let names = options.names ?? (globalInput.checked ? options.globals : options.locals) ?? []
saveButton.disabled = name === "" || names.includes(name)
}
function getName()
{
return input.value.normalize().replace(/\s+/ug, " ").trim()
}
let dialogue = document.createElement("dialog")
let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve() }))
let form = document.createElement("form")
form.method = "dialog"
dialogue.append(form)
let p1 = document.createElement("p")
let p2 = document.createElement("p")
let p3 = document.createElement("p")
let p4 = document.createElement("p")
let p5 = document.createElement("p")
let saveButton = document.createElement("button")
saveButton.append(options.messages?.getOK?.() ?? "OK")
saveButton.disabled = true
saveButton.classList.add("ok")
saveButton.addEventListener("click", () =>
{
if (globalInput.checked) options.addGlobal(getName())
else options.addLocal(getName())
})
let cancelButton = document.createElement("button")
cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
cancelButton.classList.add("cancel")
let input = document.createElement("input")
input.maxLength = 80
input.addEventListener("input", update)
let strong = document.createElement("strong")
strong.append(options.title ?? options.messages?.getVariable?.() ?? "make a variable")
let globalInput = document.createElement("input")
globalInput.type = "radio"
globalInput.name = "scope"
let localInput = document.createElement("input")
localInput.type = "radio"
localInput.name = "scope"
if (options.addGlobal) globalInput.checked = true
else localInput.checked = true
let global = document.createElement("label")
let local = document.createElement("label")
global.append(globalInput, options.messages?.getGlobal?.() ?? "for all sprites")
local.append(localInput, options.messages?.getLocal?.() ?? "for this sprite only")
global.addEventListener("checked", update)
local.addEventListener("checked", update)
global.classList.add("choose-scope", "choose-global")
local.classList.add("choose-scope", "choose-local")
p1.append(strong)
p2.append(input)
p3.append(global)
p4.append(local)
p5.append(saveButton, " ", cancelButton)
form.append(p1, p2, p3, p4, p5)
if (!options.addGlobal) {
p3.remove()
p4.remove()
}
document.body.append(dialogue)
dialogue.showModal()
return promise
}
export function addBlock(options = {})
{
function update()
{
let element = toElement(block, {getName: options.getName})
p2.textContent = ""
p2.append(element)
addLabelButton.disabled = true
for (let partElement of element.querySelectorAll(".define > .line > .custom > .line > span")) {
let {index} = closestNamePartIndex(partElement)
let input = document.createElement("input")
if (typeof block.names[0][index] === "string") {
input.value = block.names[0][index]
partElement.replaceWith(input)
}
else {
addLabelButton.disabled = false
input.value = block.names[0][index].name
partElement.textContent = ""
partElement.append(input)
}
resize(input)
input.addEventListener("input", () => resize(input))
input.addEventListener("blur", () =>
{
let value = input.value.normalize().replace(/\s+/ug, " ").trim()
if (typeof block.names[0][index] === "string") {
block.names[0][index] = value
update()
return
}
if (value !== "") {
block.names[0][index].name = value
update()
return
}
let [{}, text] = block.names[0].splice(index, 2)
if (text) block.names[0][index - 1] += " " + text
update()
})
}
saveButton.disabled = false
let names = block.names[0].filter(part => typeof part !== "string").map(part => part.name + (part.type === "text" ? "(s)" : "(b)"))
if (names.length !== new Set(names).size) {
saveButton.disabled = true
return
}
if (block.names[0].length === 1 && block.names[0][0] === "") {
saveButton.disabled = true
return
}
let name = block.names[0].filter(Boolean)
for (let block of options.scripts) {
if (block.type === Define.type && block !== options.block && compareName(block.names[0], name)) {
saveButton.disabled = true
return
}
}
}
function selectInput(n)
{
let inputs = form.querySelector(".line").querySelectorAll("input")
let input = inputs[inputs.length - n]
if (!input) return
input.focus()
}
let dialogue = document.createElement("dialog")
let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve(result) }))
let result
let form = document.createElement("form")
form.method = "dialog"
dialogue.append(form)
let p1 = document.createElement("p")
let p2 = document.createElement("p")
let p3 = document.createElement("p")
let p4 = document.createElement("p")
let p5 = document.createElement("p")
let p6 = document.createElement("p")
let p7 = document.createElement("p")
let block = options.name ? Define(options.names[0]) : Define([options.messages?.getBlockName?.() ?? "block name"])
block.atomic = Boolean(options.atomic)
let saveButton = document.createElement("button")
saveButton.append(options.messages?.getOK?.() ?? "OK")
saveButton.disabled = true
saveButton.classList.add("ok")
saveButton.addEventListener("click", () => { block.names[0] = block.names[0].filter(Boolean) ; result = block })
let cancelButton = document.createElement("button")
cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
cancelButton.classList.add("cancel")
let strong = document.createElement("strong")
strong.append(options.title ?? options.messages?.getBlock?.() ?? "make a block")
let addTextInputButton = document.createElement("button")
addTextInputButton.addEventListener("click", () => { block.names[0].push({type: "text", name: options.messages?.getTextInputName?.() ?? "my input"}, "") ; update() ; selectInput(2) })
addTextInputButton.append(options.messages?.getTextInput?.() ?? "add number/text input")
addTextInputButton.classList.add("add-input", "add-text-input")
addTextInputButton.type = "button"
let addBooleanInputButton = document.createElement("button")
addBooleanInputButton.addEventListener("click", () => { block.names[0].push({type: "boolean", name: options.messages?.getBooleanInputName?.() ?? "my input"}, "") ; update() ; selectInput(2) })
addBooleanInputButton.append(options.messages?.getBooleanInput?.() ?? "add boolean input")
addBooleanInputButton.classList.add("add-input", "add-boolean-input")
addBooleanInputButton.type = "button"
let addLabelButton = document.createElement("button")
addLabelButton.addEventListener("click", () => selectInput(1))
addLabelButton.append(options.messages?.getLabel?.() ?? "add label")
addLabelButton.classList.add("add-input", "add-label")
addLabelButton.type = "button"
let atomicInput = document.createElement("input")
atomicInput.type = "checkbox"
atomicInput.checked = block.atomic
let atomicLabel = document.createElement("label")
atomicLabel.append(atomicInput, options.messages?.getAtomic?.() ?? "run without screen refresh")
atomicLabel.classList.add("atomic-message")
atomicLabel.addEventListener("change", () => { block.atomic = atomicInput.checked ; update() })
p1.append(strong)
p3.append(addTextInputButton)
p4.append(addBooleanInputButton)
p5.append(addLabelButton)
p6.append(atomicLabel)
p7.append(saveButton, " ", cancelButton)
form.append(p1, p2, p3, p4, p5, p6, p7)
document.body.append(dialogue)
dialogue.showModal()
update()
p2.querySelector("input").focus()
return promise
}
export function addValue(options = {})
{
let dialogue = document.createElement("dialog")
let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve(result) }))
let result
let form = document.createElement("form")
form.method = "dialog"
dialogue.append(form)
let p1 = document.createElement("p")
let p2 = document.createElement("p")
let p3 = document.createElement("p")
let saveButton = document.createElement("button")
saveButton.append(options.messages?.getOK?.() ?? "OK")
saveButton.classList.add("ok")
saveButton.addEventListener("click", () => result = input.value.normalize().replace(/\s+/ug, " ").trim())
let cancelButton = document.createElement("button")
cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
cancelButton.classList.add("cancel")
let input = document.createElement("input")
input.maxLength = 80
let strong = document.createElement("strong")
strong.append(options.title ?? options.messages?.getAddMessage?.() ?? "add a message")
p1.append(strong)
p2.append(input)
p3.append(saveButton, " ", cancelButton)
form.append(p1, p2, p3)
document.body.append(dialogue)
dialogue.showModal()
return promise
}
function resize(input)
{
let span = document.createElement("span")
span.style.setProperty("white-space", "pre")
span.append(input.value)
span.style.setProperty("position", "fixed")
span.style.setProperty("width", "max-content")
input.after(span)
let width = span.offsetWidth
span.remove()
input.style.setProperty("width", `${width}px`)
}
let css = `
.held {
position: fixed;
z-index: 5000;
filter: drop-shadow(2px 4px 8px #0004);
}
.held, .held *, .held ::before, .held ::after,
.ghost, .ghost *, .ghost ::before, .ghost ::after {
pointer-events: none !important;
}
.ghost > .line, .ghost > .mouth::before, .ghost.reporter {
filter: brightness(0) invert(85%);
z-index: 2;
}
.ghost > .input, .ghost.input, select.ghost {
box-shadow: 0 0 0.75em #FFF, 0 0 0.75em #FFF;
}
.ghost.slot {
box-shadow: 0 0 1em #FFFC inset;
}
.ctx-menu {
position: fixed;
display: grid;
z-index: 5000;
background: #FFF;
border: 1px solid #888;
border-radius: 0.25em;
padding: 0.25em 0;
box-shadow: 0 0 8px #0002;
}
.ctx-menu:dir(rtl) {
transform: translate(-100%);
}
.ctx-menu button {
margin: 0;
border: 0;
color: #444;
background: none;
font-size: 1em;
border-radius: 0;
padding: 0 0.5em;
transition: none;
}
.ctx-menu button:hover {
background: #DEF;
}
.ctx-menu button:disabled {
background: #0000;
color: #AAA;
}
.line > input, .parameter > input {
color: inherit;
font: inherit;
background: none;
border: none;
padding: 0;
margin: 0;
min-width: 0.5em;
}
`
let style = document.createElement("style")
style.textContent = css
document.head.append(style)