shithub: scrax

ref: 81d1e4e01d7001e6a0107792e6167136787295d6
dir: /areas.js/

View raw version
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)