shithub: scrax

ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /view.js/

View raw version
import {BooleanSlot, Variable, List, TextParameter, BooleanParameter, Define} from "./core.js"

export let categories = ["motion", "looks", "sound", "events", "control", "sensing", "operators", "variables", "lists", "custom", "extensions"]

function toChildElement(block, child, options)
{
	if (child instanceof Array) child = child[0]
	if (typeof child === "string") child = document.createTextNode(child)
	if (child instanceof Node) return child
	return toSelectElement(block, child.options, options)
}

function toChildrenElement(block, options)
{
	let inputs = []
	
	for (let [i, reference] of block.type.references?.entries() ?? []) {
		inputs.push(toReferenceElement(block, options[reference + "s"] ?? [block.names[0]], i, options))
	}
	
	for (let child of block.inputs) {
		inputs.push(toReporterElement(child, options))
	}
	
	let name = options.getName?.(block.type, ...inputs) ?? block.type.name(...inputs)
	return name.map(child => toChildElement(block, child, options))
}

function resizeSelect(element)
{
	let div = document.createElement("div")
	div.classList.add("block", "stack")
	div.style.setProperty("white-space", "pre")
	div.style.setProperty("position", "fixed")
	div.style.setProperty("width", "max-content")
	let select = document.createElement("select")
	let option = document.createElement("option")
	option.append(element.selectedOptions[0]?.textContent ?? "")
	select.append(option)
	div.append(select)
	document.body.append(div)
	let width = select.offsetWidth
	div.remove()
	element.style.setProperty("width", `${width}px`)
}

function resizeInput(input)
{
	let span = document.createElement("span")
	span.classList.add("input")
	span.style.setProperty("white-space", "pre")
	span.append(input.value)
	span.style.setProperty("position", "fixed")
	span.style.setProperty("width", "max-content")
	document.body.append(span)
	let width = span.offsetWidth
	span.remove()
	input.style.setProperty("width", `${width}px`)
}

function toReferenceElement(block, names, index, options)
{
	let element = document.createElement("select")
	
	for (let name of names) {
		let option = document.createElement("option")
		element.append(option)
		option.append(name)
		if (name === block.names[index]) option.selected = true
	}
	
	if (options.frozen) element.disabled = true
	
	element.addEventListener("change", () =>
	{
		resizeSelect(element)
		block.names[index] = element.value
	})
	
	resizeSelect(element)
	
	return element
}

function toReporterElement(block, options)
{
	if (block.type.shape === "slot") return toSlotElement(block, options)
	
	let element = document.createElement("span")
	element.classList.add("block", "reporter", categories.includes(block.type.category) ? block.type.category : "extensions")
	if (block.type.shape === "reporter" && block.type.output === "boolean") {
		element.classList.add("boolean")
	}
	if ([Variable, List, TextParameter, BooleanParameter].some(fn => fn.type === block.type)) {
		element.append(block.names[0])
	}
	else {
		let children = toChildrenElement(block, options)
		element.append(...children)
	}
	
	element.dataset.id = block.id
	return element
}

function toSlotElement(block, options)
{
	if (block.type === BooleanSlot.type) {
		let span = document.createElement("span")
		span.classList.add("slot")
		span.dataset.id = block.id
		return span
	}
	
	if (block.type.toElement) return block.type.toElement(block, {frozen: options.frozen})
	if (block.type.options) return toEnumeratedElement(block, options)
	
	let input = document.createElement("input")
	input.classList.add("input")
	input.value = block.value
	if (options.frozen) input.disabled = true
	
	resizeInput(input)
	
	input.addEventListener("input", () => resizeInput(input))
	input.addEventListener("blur", () =>
	{
		let value = block.type.normalise(input.value)
		input.value = value
		block.value = value
		resizeInput(input)
	})
	
	let span = document.createElement("span")
	span.append(input)
	span.dataset.id = block.id
	return span
}

function toEnumeratedElement(block, options)
{
	let element = document.createElement("select")
	element.dataset.id = block.id
	if (!block.type.canFit) element.classList.add("allow-block")
	
	if (block.type.dynamic) {
		let optionElement = document.createElement("option")
		element.append(optionElement)
		optionElement.append(options.getAddMessage?.(block.type) ?? "add another")
		if (!options.addValue) optionElement.disabled = true
	}
	
	for (let option of block.type.options) {
		let optionElement = document.createElement("option")
		element.append(optionElement)
		optionElement.append(options.getName?.(block.type, option) ?? block.type.name?.(option) ?? option)
		optionElement.value = option
	}
	
	if (options.frozen) element.disabled = true
	if (element.querySelectorAll("option:enabled").length <= 1) element.disabled = true
	
	element.addEventListener("change", () =>
	{
		if (block.type.dynamic && element.selectedIndex === 0) {
			options.addValue?.(block)
			element.value = block.value
			return
		}
		block.value = element.value
		resizeSelect(element)
	})
	
	element.value = block.value
	resizeSelect(element)
	return element
}

function toSelectElement(block, options, options0)
{
	let element = document.createElement("select")
	let actions = []
	
	for (let option of options) {
		
		let optionElement = document.createElement("option")
		element.append(optionElement)
		
		optionElement.append(option.label)
		
		actions.push(() =>
		{
			let other = option.morph(block)
			if (other.type.cap && block.parent && block.parent.stack.indexOf(block) !== block.parent.stack.length - 1) {
				for (let [i, option] of options.entries()) {
					if (option.type === block.type) {
						element.children[i].selected = true
						break
					}
				}
				return
			}
			let element0 = element.closest(`[data-id="${block.id}"]`)
			element0.dataset.id = other.id
			element0.classList.toggle("cap", Boolean(other.type.cap))
			options0.replace(block, other, Boolean(option.update))
			block = other
		})
		
		if (block.type === option.type) optionElement.selected = true
	}
	
	if (!options0.replace || options0.frozen) element.disabled = true
	if (element.querySelectorAll("option:enabled").length <= 1) element.disabled = true
	
	element.addEventListener("change", () =>
	{
		actions[element.selectedIndex]()
		resizeSelect(element)
	})
	
	resizeSelect(element)
	return element
}

function toSingleElement(block, options)
{
	if (block.type.shape === "reporter") return toReporterElement(block, options)
	if (block.type === Define.type) return toDefineElement(block, options)
	if (block.type.category === "custom") return toCustomBlockElement(block, options)
	
	let div = document.createElement("div")
	div.dataset.id = block.id
	div.classList.add("block", "stack", categories.includes(block.type.category) ? block.type.category : "extensions")
	
	let line = document.createElement("div")
	line.classList.add("line")
	line.append(...toChildrenElement(block, options))
	
	div.append(line)
	
	if (block.type.hat) {
		let div2 = document.createElement("div")
		div2.append(div)
		div.classList.add("hat")
		for (let child of block.stack) div2.append(toSingleElement(child, options))
		return div2
	}
	else {
		if (block.stack) {
			div.classList.add("c")
			let mouth = document.createElement("div")
			mouth.classList.add("mouth")
			div.append(mouth)
			for (let child of block.stack) mouth.append(toSingleElement(child, options))
		}
	}
	
	while (block.complement) {
		block = block.complement
		let line = document.createElement("div")
		line.classList.add("line")
		line.dataset.id = block.id
		div.append(line)
		line.append(...toChildrenElement(block, options))
		if (block.stack) {
			let mouth = document.createElement("div")
			mouth.classList.add("mouth")
			div.append(mouth)
			for (let child of block.stack) mouth.append(toSingleElement(child, options))
		}
	}
	
	if (block.stack) {
		let line = document.createElement("div")
		line.classList.add("line")
		div.append(line)
		if (block.type.loop) line.append(getIcon("loop-arrow"))
	}
	
	if (block.type.cap) div.classList.add("cap")
	
	return div
}

function toDefineElement(block, options)
{
	let div = document.createElement("div")
	div.dataset.id = block.id
	div.classList.add("block", "stack", "define", "custom")
	
	let line = document.createElement("div")
	line.classList.add("line")
	div.append(line)
	
	let div1 = document.createElement("div")
	div1.append(div)
	for (let child of block.stack) div1.append(toSingleElement(child, options))
	
	let div2 = document.createElement("div")
	div2.classList.add("block", "stack", "custom", "prototype")
	if (block.atomic) div2.append(getIcon("bolt"))
	
	let line2 = document.createElement("div")
	line2.classList.add("line")
	div2.append(line2)
	
	line.append(...options.getName?.(block.type, div2) ?? block.type.name(div2))
	
	for (let part of block.names[0]) {
		
		if (typeof part === "string") {
			let span = document.createElement("span")
			span.append(part)
			line2.append(span)
			continue
		}
		
		let element = document.createElement("span")
		element.classList.add("block", "reporter", "custom", "parameter")
		if (part.type === "boolean") element.classList.add("boolean")
		element.append(part.name)
		line2.append(element)
	}
	
	return div1
}

function toCustomBlockElement(block, options)
{
	let div = document.createElement("div")
	div.classList.add("block", "stack", "custom")
	
	let line = document.createElement("div")
	line.classList.add("line")
	div.append(line)
	
	div.dataset.id = block.id
	let i = 0
	for (let part of block.names[0]) {
		if (typeof part === "string") {
			line.append(part)
			continue
		}
		let element = toReporterElement(block.inputs[i], options)
		element.dataset.index = i++
		line.append(element)
	}
	
	return div
}

export function toElement(block, options = {})
{
	let stack
	if (!block.parent || block.type.shape !== undefined) stack = [block]
	else stack = block.parent.stack.slice(block.parent.stack.indexOf(block))
	
	let div = document.createElement("div")
	div.classList.add("script")
	div.append(...stack.map(block => toSingleElement(block, options)))
	return div
}

export function query(id, node = document)
{
	return node.querySelector(`[data-id="${id}"]`)
}

export function closestScriptID(element0)
{
	if (element0.closest(":enabled")) return
	let element = element0.closest(".script")
	if (!element) return
	element = element.querySelector(".hat") ?? element.querySelector(".define")
	if (!element) return
	return Number(element.dataset.id)
}

export function closestBlockID(element0)
{
	if (element0.closest(":enabled")) return
	let element = element0.closest(".block[data-id]")
	if (!element) return
	return Number(element.dataset.id)
}

export function closestParameterIndex(element0)
{
	let element = element0.closest(".parameter:not([data-id])")
	if (!element) return
	let define = element.closest(".define")
	let parameters = [...define.querySelectorAll(".parameter")]
	return {index: parameters.indexOf(element), id: Number(define.dataset.id), element}
}

export function closestSlotID(element0)
{
	let element = element0.closest(".slot")
	if (!element) return
	return Number(element.dataset.id)
}

export function closestNamePartIndex(element0)
{
	let element = element0.closest(".define > .line > .custom > .line > span")
	if (!element) return
	let block = element.closest(".define")
	return {index: [...element.parentNode.childNodes].indexOf(element), id: Number(block.dataset.id)}
}

function getIcon(name)
{
	let span = document.createElement("span")
	span.insertAdjacentHTML("beforeend", icons[name])
	let svg = span.querySelector("svg")
	svg.style.setProperty("height", "1.5em")
	svg.style.setProperty("vertical-align", "middle")
	return span
}

let icons = {
	"loop-arrow": `<svg viewbox="3 0 16 24" fill="#EEE" stroke="#444" class="loop-arrow"><path transform-origin="12 12" transform="rotate(90)" d="M10 9V7.41c0-.89-1.08-1.34-1.71-.71L3.7 11.29c-.39.39-.39 1.02 0 1.41l4.59 4.59c.63.63 1.71.19 1.71-.7V14.9c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>`,
	bolt: `<svg viewbox="6 0 12 24" fill="#EEE" stroke="#444"><path d="M10.67,21L10.67,21c-0.35,0-0.62-0.31-0.57-0.66L11,14H7.5c-0.88,0-0.33-0.75-0.31-0.78c1.26-2.23,3.15-5.53,5.65-9.93 c0.1-0.18,0.3-0.29,0.5-0.29h0c0.35,0,0.62,0.31,0.57,0.66L13.01,10h3.51c0.4,0,0.62,0.19,0.4,0.66c-3.29,5.74-5.2,9.09-5.75,10.05 C11.07,20.89,10.88,21,10.67,21z"/></svg>`,
}

let css = `
.block {
	color: #444;
	pointer-events: none;
	white-space: nowrap;
}

.line, .mouth::before, .reporter {
	background: var(--color);
	border: 2px solid #0006;
	border-color: oklab(from var(--color) calc(l - 0.25) a b);
	background-origin: border-box;
	pointer-events: auto;
	touch-action: none;
	cursor: move;
}

.stack {
	width: max-content;
	position: relative;
}

.mouth {
	width: 0;
	margin-inline-start: 1.125em;
	position: relative;
	min-height: 2em;
}

.line {
	padding: 0.5em;
	border-radius: 0.5em;
	margin-bottom: -2px;
	position: relative;
	min-height: 2em;
	display: grid;
	grid-auto-flow: column;
	align-items: baseline;
	gap: 0.5em;
	justify-content: start;
	min-width: 4em;
}

:not(.prototype) > .line:not(:first-child):last-child {
	padding: 0 0.5em;
	justify-content: end;
	align-content: center;
}

.c > .line:first-child, :not(.c) > .line, .reporter {
	background-image: linear-gradient(#FFF4, #0000 75%);
}

.c > .line:not(first-child):last-child {
	background-image: linear-gradient(#0000 25%, #0001);
}

.c > .line:not(first-child):last-child::after {
	background-image: linear-gradient(#0001, #0001);
}

.c > .line:not(:first-child):not(:dir(rtl)) {
	border-top-left-radius: 0;
}

.c > .line:not(:last-child):not(:dir(rtl)) {
	border-bottom-left-radius: 0;
}

.c > .line:not(:first-child):dir(rtl) {
	border-top-right-radius: 0;
}

.c > .line:not(:last-child):dir(rtl) {
	border-bottom-right-radius: 0;
}

.c > .line {
	min-width: 10em;
}

.block select {
	background: linear-gradient(#FFF4, #0000 75%) var(--color);
	font: inherit;
	color: inherit;
	padding: 0.25em 0.5em;
	border: 1px solid #0006;
	border-radius: 0.5em;
	cursor: pointer;
}

.block select.allow-block {
	border-radius: 128em;
	background: linear-gradient(#6662, #FFF4);
	border-width: 2px;
}

.block select:disabled, .input:disabled {
	pointer-events: none;
}

.input {
	background: linear-gradient(#EEE, #FFF);
	font: inherit;
	color: #666;
	padding: 0.25em 0.5em;
	border: 2px solid #888;
	border-radius: 128em;
	cursor: text;
	min-width: 2.5em;
	text-align: center;
	overflow: hidden;
}

.input:empty::before {
	content: " ";
	white-space: pre;
}

.line::before, .line::after {
	content: "";
	width: 1.5em;
	height: 0.5em;
	position: absolute;
	inset-inline-start: 1em;
	border-radius: 0 0 calc(0.5em - 2px) calc(0.5em - 2px);
}

.line::before {
	top: 0;
	background: linear-gradient(#0006, #0008);
	background: linear-gradient(#0000, #0004) oklab(from var(--color) calc(l - 0.25) a b);
	background-clip: padding-box;
	width: 1.5em;
	height: calc(0.5em - 2px);
}

.line::after {
	top: 100%;
	background-color: inherit;
	z-index: 1;
	border: 2px solid #0006;
	border-color: oklab(from var(--color) calc(l - 0.25) a b);
	border-top: none;
}

.c > .line:not(:first-child)::before,
.c > .line:not(:last-child)::after {
	inset-inline-start: calc(2em + 2px);
}

.mouth::before {
	content: "";
	position: absolute;
	top: 0;
	bottom: 0;
	inset-inline-start: -1.125em;
	width: 1.25em;
	border-width: 0 2px;
	z-index: 1;
}

.mouth:empty::before {
	bottom: -2px;
}

.hat {
	padding-top: 1em;
}

.hat > .line {
	min-width: 8em;
}

.hat > .line:not(:dir(rtl)) {
	border-top-left-radius: 0;
}

.hat > .line:dir(rtl) {
	border-top-right-radius: 0;
}

.hat > .line:last-child::before {
	content: "";
	background: linear-gradient(#FFF4, #FFF4);
	background-color: inherit;
	border: 2px solid #0006;
	border-color: oklab(from var(--color) calc(l - 0.25) a b);
	border-bottom: none;
	position: absolute;
	top: auto;
	inset-inline-start: -2px;
	bottom: 100%;
	width: 6em;
	height: 1em;
	border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}

.define > .line:last-child::before {
	content: none;
}

.define > .line {
	border-top-left-radius: 1.5em;
	border-top-right-radius: 1.5em;
	padding: 1em;
	padding-inline-start: 0.5em;
}

.prototype {
	display: flex;
	gap: 0.5em;
	align-items: baseline;
}

.cap > .line:last-child::after {
	content: none;
}

.reporter {
	display: grid;
	grid-auto-flow: column;
	align-items: center;
	padding: 0.25em 0.5em;
	border-radius: 100em;
	width: max-content;
	grid-gap: 0.5em;
}

.boolean {
	border-width: 2px 3px;
	border-radius: 0;
	padding: 0.25em 1em;
	clip-path: polygon(0 50%, 8888em -8888em, calc(100% - 8888em) -8888em, 100% 50%, calc(100% - 8888em) 8888em, 8888em 8888em);
	position: relative;
	overflow: hidden;
}

.boolean::before, .boolean::after {
	content: "";
	border: 888em solid #0000;
	border-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="%230006"><path d="M 0 2 2 0 4 2 2 4 M 0 0 0 4 4 4 4 0" /></svg>') 50%;
	position: absolute;
	top: 50%;
	transform: translate(0, -50%);
	pointer-events: none;
	width: 64em;
}

.boolean::before {
	left: 0;
}

.boolean::after {
	right: 0;
}

.slot {
	background: linear-gradient(#0008, #0004);
	clip-path: polygon(0 50%, 88em -88em, calc(100% - 88em) -88em, 100% 50%, calc(100% - 88em) 88em, 88em 88em);
	width: 3em;
	height: 1.5em;
	display: grid;
}

.slot::before {
	content: "x";
	align-self: center;
	color: #0000;
}

.line > input, .parameter > input {
	color: inherit;
	font: inherit;
	background: none;
	border: none;
	padding: 0;
	margin: 0;
	min-width: 0.5em;
}

.loop-arrow:dir(rtl) {
	transform: scale(-1, 1);
}

.control { --color: #FB5; }
.events { --color: #FD6; }
.looks { --color: #CBF; }
.sound { --color: #E9E; }
.variables { --color: #F94; }
.operators { --color: #8C8; }
.lists { --color: #F96; }
.motion { --color: #8BF; }
.sensing { --color: #8CE; }
.custom { --color: #F9A; }
.define, .parameter { --color: #FBC; }
.extensions { --color: #1EA; }
`

let paintCSS = `

.line {
	background: none !important;
	border: 2px solid #0000;
	border-radius: 0;
	pointer-events: visiblepainted;
}

.line::before {
	content: none !important;
}

.line::after {
	content: paint(stack-block) !important;
	border: none;
	border-radius: 0;
	pointer-events: visiblepainted;
	top: -2px !important;
	bottom: -0.5em !important;
	left: -2px !important;
	right: -2px !important;
	width: auto;
	height: auto;
	z-index: -1;
	pointer-events: none;
	background: none !important;
}

.line:dir(rtl)::after {
	transform: scale(-1, 1);
}

.boolean {
	clip-path: none;
	background: paint(boolean-block) border-box !important;
	border-color: #0000 !important;
}

.boolean::before, .boolean::after {
	content: none;
}

.hat > .line { --hat: true; }
.cap > .line:last-child { --cap: true; }
.define > .line::after { --define: true; }
.c > .line:not(:last-child) { --mouth-below: true; }
.c > .line:not(:first-child) { --mouth-above: true; }

.hat > .line::after {
	top: calc(-2px - 1em) !important;
}

.c > .line:not(:first-child)::after {
	top: -0.5em !important;
}

.mouth::before {
	z-index: -2;
}

.c > .line:first-child {
	z-index: 4100;
}

.script {
	position: relative;
}
`

if (CSS.paintWorklet) {
	CSS.paintWorklet?.addModule(import.meta.resolve("./worklet.js"))
	css += paintCSS
	for (let i = 0 ; i <= 4000 ; i++) css += `.block.stack:nth-child(${i + 1}) { z-index: ${4000 - i}; }\n`
}

let style = document.createElement("style")
style.textContent = css
document.head.append(style)