ref: 81d1e4e01d7001e6a0107792e6167136787295d6
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)