From a1021e5cda50c86e7e97f7c62eb218f09dc94782 Mon Sep 17 00:00:00 2001 From: rnentjes Date: Wed, 23 Oct 2024 15:35:05 +0200 Subject: [PATCH] Version 1.2.4 Add WASM support and optimize event handling --- build.gradle.kts | 61 ++- .../nl/astraeus/komp/ElementExtentions.kt | 11 +- .../kotlin/nl/astraeus/komp/HtmlBuilder.kt | 6 +- .../kotlin/nl/astraeus/komp/Komponent.kt | 2 +- .../nl/astraeus/komp/ElementExtentions.kt | 154 +++++++ .../kotlin/nl/astraeus/komp/ElementIndex.kt | 64 +++ .../kotlin/nl/astraeus/komp/HtmlBuilder.kt | 405 ++++++++++++++++++ .../kotlin/nl/astraeus/komp/Komponent.kt | 272 ++++++++++++ .../nl/astraeus/komp/KomponentException.kt | 18 + .../komp/MutableCollectionStateDelegate.kt | 54 +++ .../kotlin/nl/astraeus/komp/State.kt | 56 +++ 11 files changed, 1075 insertions(+), 28 deletions(-) create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/ElementIndex.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/Komponent.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/KomponentException.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/MutableCollectionStateDelegate.kt create mode 100644 src/wasmJsMain/kotlin/nl/astraeus/komp/State.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6ea3a37..8abfb12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,16 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { - kotlin("multiplatform") version "2.0.0" - id("maven-publish") - id("signing") + kotlin("multiplatform") version "2.0.20" + `maven-publish` + signing id("org.jetbrains.dokka") version "1.5.31" } group = "nl.astraeus" -version = "1.2.4-SNAPSHOT" +version = "1.2.4" repositories { mavenCentral() @@ -18,14 +19,13 @@ repositories { kotlin { js { browser { - testTask { +/* testTask { useKarma { useChromiumHeadless() } - } + }*/ } } -/* @OptIn(ExperimentalWasmDsl::class) wasmJs { //moduleName = project.name browser() @@ -34,7 +34,7 @@ kotlin { groupId = group as String pom { name = "${project.name}-wasm-js" } } - }*/ + } /* @OptIn(ExperimentalKotlinGradlePluginApi::class) @@ -58,9 +58,10 @@ kotlin { val jsMain by getting val jsTest by getting { dependencies { - implementation(kotlin("test-js")) + implementation(kotlin("test")) } } + val wasmJsMain by getting } } @@ -169,22 +170,52 @@ signing { sign(publishing.publications) } -tasks.named("signJsPublication") { - dependsOn(tasks.named("publishKotlinMultiplatformPublicationToMavenLocal")) -} - tasks.named("publishJsPublicationToReleasesRepository") { dependsOn(tasks.named("signKotlinMultiplatformPublication")) } tasks.named("publishKotlinMultiplatformPublicationToMavenLocalRepository") { dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) } tasks.named("publishKotlinMultiplatformPublicationToReleasesRepository") { dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) } tasks.named("publishKotlinMultiplatformPublicationToSonatypeRepository") { dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) +} + +tasks.named("publishJsPublicationToMavenLocalRepository") { + dependsOn(tasks.named("signWasmJsPublication")) +} + +tasks.named("publishWasmJsPublicationToMavenLocalRepository") { + dependsOn(tasks.named("signJsPublication")) +} + +tasks.named("publishWasmJsPublicationToMavenLocal") { + dependsOn(tasks.named("signJsPublication")) +} + + +tasks.named("publishJsPublicationToGiteaRepository") { + dependsOn(tasks.named("signKotlinMultiplatformPublication")) + dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) +} + +tasks.named("publishKotlinMultiplatformPublicationToGiteaRepository") { + dependsOn(tasks.named("signKotlinMultiplatformPublication")) + dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) +} + +tasks.named("publishWasmJsPublicationToGiteaRepository") { + dependsOn(tasks.named("signKotlinMultiplatformPublication")) + dependsOn(tasks.named("signJsPublication")) + dependsOn(tasks.named("signWasmJsPublication")) } diff --git a/src/jsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt b/src/jsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt index 8810ef5..06d997a 100644 --- a/src/jsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt +++ b/src/jsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt @@ -133,15 +133,10 @@ internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) { this.addEventListener(eventName, event) } +private val kompEvents = mutableMapOf Unit>>() + internal fun Element.getKompEvents(): MutableMap Unit> { - var result: MutableMap Unit>? = this.asDynamic()["komp-events"] as MutableMap Unit>? - - if (result == null) { - result = mutableMapOf() - this.asDynamic()["komp-events"] = result - } - - return result + return kompEvents.getOrPut(this) { mutableMapOf() } } internal fun Element.findElementIndex(): Int { diff --git a/src/jsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt b/src/jsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt index 6f2c640..1743237 100644 --- a/src/jsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt +++ b/src/jsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt @@ -200,7 +200,7 @@ class HtmlBuilder( checkTag("onTagEvent", tag) } - currentElement?.setKompEvent(event.lowercase(), value.asDynamic()) + currentElement?.setKompEvent(event.lowercase(), value) } override fun onTagEnd(tag: Tag) { @@ -327,9 +327,7 @@ class HtmlBuilder( namespace == "http://www.w3.org/2000/svg" ) ) { - if (currentElement?.innerHTML != textContent) { - currentElement?.innerHTML += textContent - } + currentElement?.innerHTML += textContent.trim() } else if (currentElement?.textContent != textContent) { currentElement?.textContent = textContent } diff --git a/src/jsMain/kotlin/nl/astraeus/komp/Komponent.kt b/src/jsMain/kotlin/nl/astraeus/komp/Komponent.kt index b0f8fcf..256d9eb 100644 --- a/src/jsMain/kotlin/nl/astraeus/komp/Komponent.kt +++ b/src/jsMain/kotlin/nl/astraeus/komp/Komponent.kt @@ -214,7 +214,7 @@ abstract class Komponent { scheduledForUpdate.add(komponent) if (updateCallback == null) { - window.setTimeout({ + updateCallback = window.setTimeout({ runUpdate() }, 0) } diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt new file mode 100644 index 0000000..e82648e --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementExtentions.kt @@ -0,0 +1,154 @@ +package nl.astraeus.komp + +import org.w3c.dom.AddEventListenerOptions +import org.w3c.dom.events.Event +import org.w3c.dom.Element +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.EventListener +import org.w3c.dom.get + +private fun Int.asSpaces(): String { + val result = StringBuilder() + + repeat(this) { + result.append(" ") + } + return result.toString() +} + +fun Element.printTree(indent: Int = 0): String { + val result = StringBuilder() + + result.append(indent.asSpaces()) + result.append(tagName) + if (this.namespaceURI != "http://www.w3.org/1999/xhtml") { + result.append(" [") + result.append(namespaceURI) + result.append("]") + } + result.append(" (") + var first = true + if (hasAttributes()) { + for (index in 0 until attributes.length) { + if (!first) { + result.append(", ") + } else { + first = false + } + result.append(attributes[index]?.localName) + result.append("=") + result.append(attributes[index]?.value) + } + } + result.append(") {") + result.append("\n") + for ((name, event) in getKompEvents()) { + result.append(indent.asSpaces()) + result.append("on") + result.append(name) + result.append(" -> ") + result.append(event) + result.append("\n") + } + for (index in 0 until childNodes.length) { + childNodes[index]?.let { + if (it is Element) { + result.append(it.printTree(indent + 2)) + } else { + result.append((indent + 2).asSpaces()) + result.append(it.textContent) + result.append("\n") + } + } + } + result.append(indent.asSpaces()) + result.append("}\n") + + return result.toString() +} + +internal fun Element.setKompAttribute(attributeName: String, value: String?) { + //val attributeName = name.lowercase() + if (value == null || value.isBlank()) { + if (this is HTMLInputElement) { + when (attributeName) { + "checked" -> { + checked = false + } +/* + "class" -> { + className = "" + } +*/ + "value" -> { + this.value = "" + } + else -> { + removeAttribute(attributeName) + } + } + } else { + removeAttribute(attributeName) + } + } else { + if (this is HTMLInputElement) { + when (attributeName) { + "checked" -> { + checked = "checked" == value + } +/* + "class" -> { + className = value + } +*/ + "value" -> { + this.value = value + } + else -> { + setAttribute(attributeName, value) + } + } + } else if (this.getAttribute(attributeName) != value) { + setAttribute(attributeName, value) + } + } +} + +internal fun Element.clearKompEvents() { + val events = getKompEvents() + for ((name, event) in getKompEvents()) { + removeEventListener(name, event) + } + events.clear() +} + +internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) { + val eventName: String = if (name.startsWith("on")) { + name.substring(2) + } else { + name + } + + getKompEvents()[eventName] = event + + this.addEventListener(eventName, event) // AddEventListenerOptions(true)) +} + +private val kompEvents = mutableMapOf Unit>>() + +internal fun Element.getKompEvents(): MutableMap Unit> { + return kompEvents.getOrPut(this) { mutableMapOf() } +} + +internal fun Element.findElementIndex(): Int { + val childNodes = parentElement?.children + if (childNodes != null) { + for (index in 0 until childNodes.length) { + if (childNodes[index] == this) { + return index + } + } + } + + return 0 +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementIndex.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementIndex.kt new file mode 100644 index 0000000..ea34fc8 --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/ElementIndex.kt @@ -0,0 +1,64 @@ +package nl.astraeus.komp + +import org.w3c.dom.Node +import org.w3c.dom.get + +data class ElementIndex( + val parent: Node, + var childIndex: Int, + var setAttr: MutableSet = mutableSetOf() +) { + override fun toString(): String { + return "${parent.nodeName}[$childIndex]" + } +} + +fun ArrayList.currentParent(): Node { + this.lastOrNull()?.let { + return it.parent + } + + throw IllegalStateException("currentParent should never be null!") +} + +fun ArrayList.currentElement(): Node? { + this.lastOrNull()?.let { + return it.parent.childNodes[it.childIndex] + } + + return null +} + +fun ArrayList.currentPosition(): ElementIndex? { + return if (this.size < 2) { + null + } else { + this[this.size - 2] + } +} + +fun ArrayList.nextElement() { + this.lastOrNull()?.let { + it.setAttr.clear() + it.childIndex++ + } +} + +fun ArrayList.pop() { + this.removeLast() +} + +fun ArrayList.push(element: Node) { + this.add(ElementIndex(element, 0)) +} + +fun ArrayList.replace(new: Node) { + if (this.currentElement() != null) { + this.currentElement()?.parentElement?.replaceChild( + new, + this.currentElement()!! + ) + } else { + this.last().parent.appendChild(new) + } +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt new file mode 100644 index 0000000..e8d521c --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/HtmlBuilder.kt @@ -0,0 +1,405 @@ +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 { + 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() + 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) { + println( + "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) { + println(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) { + "No current tag ($source)" + } + check(currentElement?.tagName.equals(tag.tagName, ignoreCase = true)) { + "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 = 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" + ) + ) { + if (currentElement?.innerHTML != textContent) { + currentElement?.innerHTML += textContent + } + } 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() + 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!") + } + } +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/Komponent.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/Komponent.kt new file mode 100644 index 0000000..e907060 --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/Komponent.kt @@ -0,0 +1,272 @@ +package nl.astraeus.komp + +import kotlinx.browser.window +import kotlinx.html.FlowOrMetaDataOrPhrasingContent +import org.w3c.dom.Element +import org.w3c.dom.HTMLElement +import org.w3c.dom.get + +private var currentKomponent: Komponent? = null + +fun FlowOrMetaDataOrPhrasingContent.currentKomponent(): Komponent = + currentKomponent ?: error("No current komponent defined! Only call from render code!") + +enum class UnsafeMode { + UNSAFE_ALLOWED, + UNSAFE_DISABLED, + UNSAFE_SVG_ONLY +} + +var Element.memoizeHash: String? + get() { + return getAttribute("memoize-hash") + } + set(value) { + if (value != null) { + setAttribute("memoize-hash", value.toString()) + } else { + removeAttribute("memoize-hash") + } + } + +abstract class Komponent { + val createIndex = getNextCreateIndex() + private var dirty: Boolean = true + + var element: Element? = null + + open fun create(parent: Element, childIndex: Int? = null) { + onBeforeUpdate() + val builder = HtmlBuilder( + this, + parent, + childIndex ?: parent.childNodes.length + ) + + try { + currentKomponent = this + builder.render() + } catch(e: KomponentException) { + errorHandler(e) + } finally { + currentKomponent = null + } + + element = builder.root + updateMemoizeHash() + onAfterUpdate() + } + + fun memoizeChanged() = element?.memoizeHash == null || element?.memoizeHash != fullMemoizeHash() + + fun updateMemoizeHash() { + element?.memoizeHash = fullMemoizeHash() + } + + private fun fullMemoizeHash(): String? { + val generated = generateMemoizeHash() + + return if (generated != null) { + "${this::class.simpleName}:${generateMemoizeHash()}" + } else { + null + } + } + + abstract fun HtmlBuilder.render() + + /** + * This method is called after the Komponent is updated + * + * note: it's also called at first render + */ + open fun onAfterUpdate() {} + + /** + * This method is called before the Komponent is updated + * and before memoizeHash is checked + * + * note: it's also called at first render + */ + open fun onBeforeUpdate() {} + + fun requestUpdate() { + dirty = true + scheduleForUpdate(this) + } + + /** + * Request an immediate update of this Komponent + * + * This will run immediately, make sure Komponents are not rendered multiple times + * Any scheduled updates will be run as well + */ + fun requestImmediateUpdate() { + dirty = true + runUpdateImmediately(this) + } + + /** + * This function can be overwritten if you know how to update the Komponent yourself + * + * HTMLBuilder.render() is called 1st time the component is rendered, after that this + * method will be called + * + * @deprecated + */ + @Deprecated( + "Deprecated to avoid confusing with requestUpdate, use renderUpdate instead", + ReplaceWith("renderUpdate"), + level = DeprecationLevel.WARNING + ) + protected fun update() { + refresh() + } + + /** + * This function can be overwritten if you know how to update the Komponent yourself + * + * HTMLBuilder.render() is called 1st time the component is rendered, after that this + * method will be called + */ + open fun renderUpdate() { + refresh() + } + + /** + * If this function returns a value it will be stored and on the next render it will be compared. + * + * The render will only happen if the hash is not null and has changed + */ + open fun generateMemoizeHash(): Int? = null + + private fun refresh() { + val currentElement = element + + check(currentElement != null) { + error("element is null") + } + + val parent = currentElement.parentElement as? HTMLElement ?: error("parent is null!?") + var childIndex = 0 + for (index in 0 until parent.childNodes.length) { + if (parent.childNodes[index] == currentElement) { + childIndex = index + } + } + val builder = HtmlBuilder(this, parent, childIndex) + + try { + currentKomponent = this + builder.render() + } catch(e: KomponentException) { + errorHandler(e) + } finally { + currentKomponent = null + } + + element = builder.root + dirty = false + } + + override fun toString(): String { + return "${this::class.simpleName}" + } + + companion object { + private var nextCreateIndex: Int = 1 + private var updateCallback: Int? = null + private var errorHandler: (KomponentException) -> Unit = { ke -> + println("Render error in Komponent: $ke") + + ke.element?.innerHTML = """
Render error!
""" + + window.alert(""" + Error in Komponent '${ke.komponent}', ${ke.message} + Tag: ${ke.tag.tagName} + See console log for details + Position: ${ke.position}""".trimIndent() + ) + } + private var scheduledForUpdate = mutableSetOf() + private var interceptor: (Komponent, () -> Unit) -> Unit = { _, block -> block() } + + var logRenderEvent = false + var logReplaceEvent = false + var enableAssertions = false + var unsafeMode = UnsafeMode.UNSAFE_DISABLED + + fun create(parent: HTMLElement, component: Komponent) { + component.create(parent) + } + + fun setErrorHandler(handler: (KomponentException) -> Unit) { + errorHandler = handler + } + + fun setUpdateInterceptor(block: (Komponent, () -> Unit) -> Unit) { + interceptor = block + } + + private fun getNextCreateIndex() = nextCreateIndex++ + + private fun scheduleForUpdate(komponent: Komponent) { + scheduledForUpdate.add(komponent) + + if (updateCallback == null) { + updateCallback = window.setTimeout({ + runUpdate() + }, 0) + } + } + + private fun runUpdateImmediately(komponent: Komponent) { + scheduledForUpdate.add(komponent) + runUpdate() + } + + private fun runUpdate(): JsAny { + val todo = scheduledForUpdate.sortedBy { komponent -> komponent.createIndex } + + if (logRenderEvent) { + println("runUpdate") + } + + todo.forEach { next -> + interceptor(next) { + val element = next.element + + if (element is HTMLElement) { + if (next.dirty) { + if (logRenderEvent) { + println("Update dirty ${next.createIndex}") + } + val memoizeHash = next.generateMemoizeHash() + + if (next.memoizeChanged()) { + next.onBeforeUpdate() + next.renderUpdate() + next.updateMemoizeHash() + next.onAfterUpdate() + } else if (logRenderEvent) { + println("Skipped render, memoizeHash is equal $next-[$memoizeHash]") + } + } else { + if (logRenderEvent) { + println("Skip ${next.createIndex}") + } + } + } else { + println("Komponent element is null $next, $element") + } + } + } + + scheduledForUpdate.clear() + updateCallback = null + + return "JsAny".toJsString() + } + } + +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/KomponentException.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/KomponentException.kt new file mode 100644 index 0000000..a2bc763 --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/KomponentException.kt @@ -0,0 +1,18 @@ +package nl.astraeus.komp + +import kotlinx.html.Tag +import org.w3c.dom.Element + +class KomponentException( + val komponent: Komponent?, + val element: Element?, + val tag: Tag, + val position: String, + message: String, + cause: Throwable +) : RuntimeException(message, cause) { + + override fun toString(): String { + return "KompException(message='$message', tag='$tag', position='$position')" + } +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/MutableCollectionStateDelegate.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/MutableCollectionStateDelegate.kt new file mode 100644 index 0000000..76c63e3 --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/MutableCollectionStateDelegate.kt @@ -0,0 +1,54 @@ +package nl.astraeus.komp + +inline fun Komponent.mutableCollectionState( + initialValue: MutableCollection +): MutableCollection = MutableCollectionStateDelegate( + this, + initialValue +) + +class MutableCollectionStateDelegate( + val komponent: Komponent, + val collection: MutableCollection +): MutableCollection by collection { + + override fun add(element: T): Boolean { + komponent.requestUpdate() + + return collection.add(element) + } + + override fun addAll(elements: Collection): Boolean { + komponent.requestUpdate() + + return collection.addAll(elements) + } + + override fun clear() { + komponent.requestUpdate() + + collection.clear() + } + + // todo: return iterator wrapper to update at changes? + //override fun iterator(): MutableIterator = collection.iterator() + + override fun remove(element: T): Boolean { + komponent.requestUpdate() + + return collection.remove(element) + } + + override fun removeAll(elements: Collection): Boolean { + komponent.requestUpdate() + + return collection.removeAll(elements) + } + + override fun retainAll(elements: Collection): Boolean { + komponent.requestUpdate() + + return collection.retainAll(elements) + } + +} diff --git a/src/wasmJsMain/kotlin/nl/astraeus/komp/State.kt b/src/wasmJsMain/kotlin/nl/astraeus/komp/State.kt new file mode 100644 index 0000000..a479204 --- /dev/null +++ b/src/wasmJsMain/kotlin/nl/astraeus/komp/State.kt @@ -0,0 +1,56 @@ +package nl.astraeus.komp + +import kotlin.reflect.KProperty + +interface Delegate { + + operator fun getValue( + thisRef: Any?, + property: KProperty<*> + ): T + + operator fun setValue( + thisRef: Any?, + property: KProperty<*>, + value: T + ) + +} + +open class StateDelegate( + val komponent: Komponent, + initialValue: T +) : Delegate { + private var value: T = initialValue + + init { + if (value is MutableCollection<*>) { + error("Use mutableList to create a collection!") + } + } + + override operator fun getValue( + thisRef: Any?, + property: KProperty<*> + ): T { + return value + } + + override operator fun setValue( + thisRef: Any?, + property: KProperty<*>, + value: T + ) { + if (this.value?.equals(value) != true) { + this.value = value + komponent.requestUpdate() + } + } +} + +inline fun Komponent.state( + initialValue: T +): Delegate = StateDelegate( + this, + initialValue +)