Files
kotlin-komponent/src/jsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt
rnentjes a1021e5cda
Some checks are pending
Gradle CI / build (push) Waiting to run
Version 1.2.4
Add WASM support and optimize event handling
2024-10-23 15:35:05 +02:00

406 lines
11 KiB
Kotlin

package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.DefaultUnsafe
import kotlinx.html.Entities
import kotlinx.html.FlowOrMetaDataOrPhrasingContent
import kotlinx.html.Tag
import kotlinx.html.TagConsumer
import kotlinx.html.Unsafe
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSpanElement
import org.w3c.dom.Node
import org.w3c.dom.asList
import org.w3c.dom.get
private var currentElement: Element? = null
interface HtmlConsumer : TagConsumer<Element> {
fun append(node: Element)
fun include(komponent: Komponent)
fun debug(block: HtmlConsumer.() -> Unit)
}
fun FlowOrMetaDataOrPhrasingContent.currentElement(): Element =
currentElement ?: error("No current element defined!")
private fun Node.asElement() = this as? HTMLElement
class HtmlBuilder(
private val komponent: Komponent?,
parent: Element,
childIndex: Int = 0
) : HtmlConsumer {
private var currentPosition = arrayListOf<ElementIndex>()
private var inDebug = false
private var exceptionThrown = false
private var currentNode: Node? = null
private var firstTag: Boolean = true
var root: Element? = null
init {
currentPosition.add(ElementIndex(parent, childIndex))
}
override fun include(komponent: Komponent) {
if (
komponent.element != null &&
!komponent.memoizeChanged()
) {
currentPosition.replace(komponent.element!!)
if (Komponent.logRenderEvent) {
console.log(
"Skipped include $komponent, memoize hasn't changed"
)
}
} else {
// current element should become parent
/*
val ce = komponent.element
if (ce != null) {
append(ce as Element)
}
*/
komponent.create(
currentPosition.last().parent as Element,
currentPosition.last().childIndex
)
}
currentPosition.nextElement()
}
override fun append(node: Element) {
currentPosition.replace(node)
currentPosition.nextElement()
}
override fun debug(block: HtmlConsumer.() -> Unit) {
val enableAssertions = Komponent.enableAssertions
Komponent.enableAssertions = true
inDebug = true
try {
block.invoke(this)
} finally {
inDebug = false
Komponent.enableAssertions = enableAssertions
}
}
private fun logReplace(msg: () -> String) {
if (Komponent.logReplaceEvent && inDebug) {
console.log(msg.invoke())
}
}
override fun onTagStart(tag: Tag) {
logReplace {
"onTagStart, [${tag.tagName}, ${tag.namespace ?: ""}], currentPosition: $currentPosition"
}
currentNode = currentPosition.currentElement()
if (currentNode == null) {
logReplace { "onTagStart, currentNode1: $currentNode" }
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
logReplace { "onTagStart, currentElement1.1: $currentNode" }
currentPosition.currentParent().appendChild(currentNode!!)
} else if (
!currentNode?.asElement()?.tagName.equals(tag.tagName, true) ||
(
tag.namespace != null &&
!currentNode?.asElement()?.namespaceURI.equals(tag.namespace, true)
)
) {
logReplace {
"onTagStart, currentElement, namespace: ${currentNode?.asElement()?.namespaceURI} -> ${tag.namespace}"
}
logReplace {
"onTagStart, currentElement, replace: ${currentNode?.asElement()?.tagName} -> ${tag.tagName}"
}
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
currentPosition.replace(currentNode!!)
}
currentElement = currentNode as? Element ?: currentElement
if (currentNode is Element) {
if (firstTag) {
logReplace { "Setting root: $currentNode" }
root = currentNode as Element
firstTag = false
}
currentElement?.clearKompEvents()
// if currentElement = checkbox make sure it's cleared
(currentElement as? HTMLInputElement)?.checked = false
currentPosition.lastOrNull()?.setAttr?.clear()
for (entry in tag.attributesEntries) {
currentElement!!.setKompAttribute(entry.key, entry.value)
currentPosition.lastOrNull()?.setAttr?.add(entry.key)
}
}
currentPosition.push(currentNode!!)
}
private fun checkTag(source: String, tag: Tag) {
check(currentElement != null) {
js("debugger;")
"No current tag ($source)"
}
check(currentElement?.tagName.equals(tag.tagName, ignoreCase = true)) {
js("debugger;")
"Wrong current tag ($source), got: ${tag.tagName} expected ${currentElement?.tagName}"
}
}
override fun onTagAttributeChange(
tag: Tag,
attribute: String,
value: String?
) {
logReplace { "onTagAttributeChange, ${tag.tagName} [$attribute, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagAttributeChange", tag)
}
currentElement?.setKompAttribute(attribute, value)
if (value == null || value.isEmpty()) {
currentPosition.currentPosition()?.setAttr?.remove(attribute)
} else {
currentPosition.currentPosition()?.setAttr?.add(attribute)
}
}
override fun onTagEvent(
tag: Tag,
event: String,
value: (kotlinx.html.org.w3c.dom.events.Event) -> Unit
) {
logReplace { "onTagEvent, ${tag.tagName} [$event, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagEvent", tag)
}
currentElement?.setKompEvent(event.lowercase(), value)
}
override fun onTagEnd(tag: Tag) {
logReplace {
"onTagEnd, [${tag.tagName}, ${tag.namespace}], currentPosition: $currentPosition"
}
if (exceptionThrown) {
return
}
while (currentPosition.currentElement() != null) {
currentPosition.currentElement()?.let {
it.parentElement?.removeChild(it)
}
}
if (Komponent.enableAssertions) {
checkTag("onTagEnd", tag)
}
if (currentElement != null) {
val setAttrs: Set<String> = currentPosition.currentPosition()?.setAttr ?: setOf()
// remove attributes that where not set
val element = currentElement
if (element?.hasAttributes() == true) {
for (index in 0 until element.attributes.length) {
val attribute = element.attributes[index]
if (attribute?.name != null) {
val attr = attribute.name
if (
!setAttrs.contains(attr) &&
attr != "style"
) {
element.setKompAttribute(attr, null)
}
}
}
}
}
currentPosition.pop()
currentNode = currentPosition.currentElement()
currentElement = currentNode as? Element ?: currentElement
currentPosition.nextElement()
currentElement = currentElement?.parentElement as? HTMLElement
//logReplace"onTagEnd, popped: $currentElement")
}
override fun onTagContent(content: CharSequence) {
//logReplace"onTagContent, [$content]")
check(currentElement != null) {
"No current DOM node"
}
//logReplace"Tag content: $content")
if (
currentElement?.nodeType != Node.TEXT_NODE ||
currentElement?.textContent != content.toString()
) {
currentElement?.textContent = content.toString()
}
currentPosition.nextElement()
}
override fun onTagContentEntity(entity: Entities) {
//logReplace"onTagContentEntity, [${entity.text}]")
check(currentElement != null) {
"No current DOM node"
}
val s = document.createElement("span") as HTMLSpanElement
s.innerHTML = entity.text
currentPosition.replace(
s.childNodes.asList().firstOrNull() ?: document.createTextNode(entity.text)
)
currentPosition.nextElement()
}
override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
with(DefaultUnsafe()) {
block()
val textContent = toString()
//logReplace"onTagContentUnsafe, [$textContent]")
var namespace: String? = null
if (currentPosition.currentParent().nodeType == 1.toShort()) {
val element = currentPosition.currentParent() as Element
namespace = when (Komponent.unsafeMode) {
UnsafeMode.UNSAFE_ALLOWED -> {
element.namespaceURI
}
UnsafeMode.UNSAFE_SVG_ONLY -> {
if (element.namespaceURI == "http://www.w3.org/2000/svg") {
element.namespaceURI
} else {
null
}
}
else -> {
null
}
}
}
//logReplace"onTagContentUnsafe, namespace: [$namespace]")
if (Komponent.unsafeMode == UnsafeMode.UNSAFE_ALLOWED ||
(
Komponent.unsafeMode == UnsafeMode.UNSAFE_SVG_ONLY &&
namespace == "http://www.w3.org/2000/svg"
)
) {
currentElement?.innerHTML += textContent.trim()
} else if (currentElement?.textContent != textContent) {
currentElement?.textContent = textContent
}
currentPosition.nextElement()
}
}
override fun onTagComment(content: CharSequence) {
//logReplace"onTagComment, [$content]")
check(currentElement != null) {
"No current DOM node"
}
currentElement?.appendChild(
document.createComment(content.toString())
)
currentPosition.nextElement()
}
fun onTagError(tag: Tag, exception: Throwable) {
exceptionThrown = true
if (exception !is KomponentException) {
val position = mutableListOf<Element>()
var ce = currentElement
while (ce != null) {
position.add(ce)
ce = ce.parentElement
}
val builder = StringBuilder()
for (element in position.reversed()) {
builder.append("> ")
builder.append(element.tagName)
builder.append("[")
builder.append(element.findElementIndex())
builder.append("]")
if (element.hasAttribute("class")) {
builder.append("(")
builder.append(element.getAttribute("class"))
builder.append(")")
}
builder.append(" ")
}
throw KomponentException(
komponent,
currentElement,
tag,
builder.toString(),
exception.message ?: "error",
exception
)
} else {
throw exception
}
}
override fun finalize(): Element {
//logReplace"finalize, currentPosition: $currentPosition")
return root ?: throw IllegalStateException(
"We can't finalize as there was no tags"
)
}
companion object {
fun create(content: HtmlBuilder.() -> Unit): Element {
val container = document.createElement("div") as HTMLElement
val consumer = HtmlBuilder(null, container)
content.invoke(consumer)
return consumer.root ?: error("No root element found after render!")
}
}
}