diff --git a/build.gradle b/build.gradle index 3bc9e43..2fe6ed2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group 'nl.astraeus' -version '0.1.9-SNAPSHOT' +version '0.1.9' apply plugin: 'kotlin2js' apply plugin: 'kotlin-dce-js' diff --git a/build.gradle b/build.gradle index 3bc9e43..2fe6ed2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group 'nl.astraeus' -version '0.1.9-SNAPSHOT' +version '0.1.9' apply plugin: 'kotlin2js' apply plugin: 'kotlin-dce-js' diff --git a/komp.iml b/komp.iml index dc22d6a..8a310ad 100644 --- a/komp.iml +++ b/komp.iml @@ -1,5 +1,5 @@ - + @@ -34,8 +34,8 @@ - - + + diff --git a/build.gradle b/build.gradle index 3bc9e43..2fe6ed2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group 'nl.astraeus' -version '0.1.9-SNAPSHOT' +version '0.1.9' apply plugin: 'kotlin2js' apply plugin: 'kotlin-dce-js' diff --git a/komp.iml b/komp.iml index dc22d6a..8a310ad 100644 --- a/komp.iml +++ b/komp.iml @@ -1,5 +1,5 @@ - + @@ -34,8 +34,8 @@ - - + + diff --git a/komp.ipr b/komp.ipr index b9e8c37..5a220e0 100644 --- a/komp.ipr +++ b/komp.ipr @@ -72,6 +72,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle b/build.gradle index 3bc9e43..2fe6ed2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group 'nl.astraeus' -version '0.1.9-SNAPSHOT' +version '0.1.9' apply plugin: 'kotlin2js' apply plugin: 'kotlin-dce-js' diff --git a/komp.iml b/komp.iml index dc22d6a..8a310ad 100644 --- a/komp.iml +++ b/komp.iml @@ -1,5 +1,5 @@ - + @@ -34,8 +34,8 @@ - - + + diff --git a/komp.ipr b/komp.ipr index b9e8c37..5a220e0 100644 --- a/komp.ipr +++ b/komp.ipr @@ -72,6 +72,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt b/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt new file mode 100644 index 0000000..740a1e9 --- /dev/null +++ b/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt @@ -0,0 +1,169 @@ +package nl.astraeus.komp + +import kotlinx.html.DefaultUnsafe +import kotlinx.html.Entities +import kotlinx.html.Tag +import kotlinx.html.TagConsumer +import kotlinx.html.Unsafe +import kotlinx.html.consumers.onFinalize +import org.w3c.dom.Document +import org.w3c.dom.HTMLElement +import org.w3c.dom.Node +import org.w3c.dom.asList +import org.w3c.dom.events.Event + +@Suppress("NOTHING_TO_INLINE") +private inline fun HTMLElement.setEvent(name: String, noinline callback : (Event) -> Unit) : Unit { + asDynamic()[name] = callback +} + +interface HtmlConsumer : TagConsumer { + fun append(node: Node) +} + +class HtmlBuilder(val document : Document) : HtmlConsumer { + private val path = arrayListOf() + private var lastLeaved : HTMLElement? = null + + override fun onTagStart(tag: Tag) { + val element: HTMLElement = when { + tag.namespace != null -> document.createElementNS(tag.namespace!!, tag.tagName).asDynamic() + else -> document.createElement(tag.tagName) as HTMLElement + } + + tag.attributesEntries.forEach { + element.setAttribute(it.key, it.value) + } + + if (path.isNotEmpty()) { + path.last().appendChild(element) + } + + path.add(element) + } + + override fun onTagAttributeChange(tag: Tag, attribute: String, value: String?) { + when { + path.isEmpty() -> throw IllegalStateException("No current tag") + path.last().tagName.toLowerCase() != tag.tagName.toLowerCase() -> throw IllegalStateException("Wrong current tag") + else -> path.last().let { node -> + if (value == null) { + node.removeAttribute(attribute) + } else { + node.setAttribute(attribute, value) + } + } + } + } + + override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) { + when { + path.isEmpty() -> throw IllegalStateException("No current tag") + path.last().tagName.toLowerCase() != tag.tagName.toLowerCase() -> throw IllegalStateException("Wrong current tag") + else -> path.last().setEvent(event, value) + } + } + + override fun onTagEnd(tag: Tag) { + if (path.isEmpty() || path.last().tagName.toLowerCase() != tag.tagName.toLowerCase()) { + throw IllegalStateException("We haven't entered tag ${tag.tagName} but trying to leave") + } + + lastLeaved = path.removeAt(path.lastIndex) + } + + override fun onTagContent(content: CharSequence) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + path.last().appendChild(document.createTextNode(content.toString())) + } + + override fun onTagContentEntity(entity: Entities) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + // stupid hack as browsers doesn't support createEntityReference + val s = document.createElement("span") as HTMLElement + s.innerHTML = entity.text + path.last().appendChild(s.childNodes.asList().filter { it.nodeType == Node.TEXT_NODE }.first()) + + // other solution would be + // pathLast().innerHTML += entity.text + } + + override fun append(node: Node) { + path.last().appendChild(node) + } + + override fun onTagContentUnsafe(block: Unsafe.() -> Unit) { + with(DefaultUnsafe()) { + block() + + path.last().innerHTML += toString() + } + } + + override fun onTagComment(content: CharSequence) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + path.last().appendChild(document.createComment(content.toString())) + } + + override fun finalize(): HTMLElement = lastLeaved?.asR() ?: throw IllegalStateException("We can't finalize as there was no tags") + + @Suppress("UNCHECKED_CAST") + private fun HTMLElement.asR(): HTMLElement = this.asDynamic() + +} + +fun Document.createTree() : TagConsumer = HtmlBuilder(this) +val Document.create : TagConsumer + get() = HtmlBuilder(this) + +fun Node.append(block: TagConsumer.() -> Unit): List = + ArrayList().let { result -> + ownerDocumentExt.createTree().onFinalize { it, partial -> + if (!partial) { + result.add(it); appendChild(it) + } + }.block() + + result + } + +fun Node.prepend(block: TagConsumer.() -> Unit): List = + ArrayList().let { result -> + ownerDocumentExt.createTree().onFinalize { it, partial -> + if (!partial) { + result.add(it) + insertBefore(it, firstChild) + } + }.block() + + result + } + +val HTMLElement.append: TagConsumer + get() = ownerDocumentExt.createTree().onFinalize { element, partial -> + if (!partial) { + this@append.appendChild(element) + } + } + +val HTMLElement.prepend: TagConsumer + get() = ownerDocumentExt.createTree().onFinalize { element, partial -> + if (!partial) { + this@prepend.insertBefore(element, this@prepend.firstChild) + } + } + +private val Node.ownerDocumentExt: Document + get() = when { + this is Document -> this + else -> ownerDocument ?: throw IllegalStateException("Node has no ownerDocument") + } diff --git a/build.gradle b/build.gradle index 3bc9e43..2fe6ed2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group 'nl.astraeus' -version '0.1.9-SNAPSHOT' +version '0.1.9' apply plugin: 'kotlin2js' apply plugin: 'kotlin-dce-js' diff --git a/komp.iml b/komp.iml index dc22d6a..8a310ad 100644 --- a/komp.iml +++ b/komp.iml @@ -1,5 +1,5 @@ - + @@ -34,8 +34,8 @@ - - + + diff --git a/komp.ipr b/komp.ipr index b9e8c37..5a220e0 100644 --- a/komp.ipr +++ b/komp.ipr @@ -72,6 +72,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt b/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt new file mode 100644 index 0000000..740a1e9 --- /dev/null +++ b/src/main/kotlin/nl/astraeus/komp/HtmlBuilder.kt @@ -0,0 +1,169 @@ +package nl.astraeus.komp + +import kotlinx.html.DefaultUnsafe +import kotlinx.html.Entities +import kotlinx.html.Tag +import kotlinx.html.TagConsumer +import kotlinx.html.Unsafe +import kotlinx.html.consumers.onFinalize +import org.w3c.dom.Document +import org.w3c.dom.HTMLElement +import org.w3c.dom.Node +import org.w3c.dom.asList +import org.w3c.dom.events.Event + +@Suppress("NOTHING_TO_INLINE") +private inline fun HTMLElement.setEvent(name: String, noinline callback : (Event) -> Unit) : Unit { + asDynamic()[name] = callback +} + +interface HtmlConsumer : TagConsumer { + fun append(node: Node) +} + +class HtmlBuilder(val document : Document) : HtmlConsumer { + private val path = arrayListOf() + private var lastLeaved : HTMLElement? = null + + override fun onTagStart(tag: Tag) { + val element: HTMLElement = when { + tag.namespace != null -> document.createElementNS(tag.namespace!!, tag.tagName).asDynamic() + else -> document.createElement(tag.tagName) as HTMLElement + } + + tag.attributesEntries.forEach { + element.setAttribute(it.key, it.value) + } + + if (path.isNotEmpty()) { + path.last().appendChild(element) + } + + path.add(element) + } + + override fun onTagAttributeChange(tag: Tag, attribute: String, value: String?) { + when { + path.isEmpty() -> throw IllegalStateException("No current tag") + path.last().tagName.toLowerCase() != tag.tagName.toLowerCase() -> throw IllegalStateException("Wrong current tag") + else -> path.last().let { node -> + if (value == null) { + node.removeAttribute(attribute) + } else { + node.setAttribute(attribute, value) + } + } + } + } + + override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) { + when { + path.isEmpty() -> throw IllegalStateException("No current tag") + path.last().tagName.toLowerCase() != tag.tagName.toLowerCase() -> throw IllegalStateException("Wrong current tag") + else -> path.last().setEvent(event, value) + } + } + + override fun onTagEnd(tag: Tag) { + if (path.isEmpty() || path.last().tagName.toLowerCase() != tag.tagName.toLowerCase()) { + throw IllegalStateException("We haven't entered tag ${tag.tagName} but trying to leave") + } + + lastLeaved = path.removeAt(path.lastIndex) + } + + override fun onTagContent(content: CharSequence) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + path.last().appendChild(document.createTextNode(content.toString())) + } + + override fun onTagContentEntity(entity: Entities) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + // stupid hack as browsers doesn't support createEntityReference + val s = document.createElement("span") as HTMLElement + s.innerHTML = entity.text + path.last().appendChild(s.childNodes.asList().filter { it.nodeType == Node.TEXT_NODE }.first()) + + // other solution would be + // pathLast().innerHTML += entity.text + } + + override fun append(node: Node) { + path.last().appendChild(node) + } + + override fun onTagContentUnsafe(block: Unsafe.() -> Unit) { + with(DefaultUnsafe()) { + block() + + path.last().innerHTML += toString() + } + } + + override fun onTagComment(content: CharSequence) { + if (path.isEmpty()) { + throw IllegalStateException("No current DOM node") + } + + path.last().appendChild(document.createComment(content.toString())) + } + + override fun finalize(): HTMLElement = lastLeaved?.asR() ?: throw IllegalStateException("We can't finalize as there was no tags") + + @Suppress("UNCHECKED_CAST") + private fun HTMLElement.asR(): HTMLElement = this.asDynamic() + +} + +fun Document.createTree() : TagConsumer = HtmlBuilder(this) +val Document.create : TagConsumer + get() = HtmlBuilder(this) + +fun Node.append(block: TagConsumer.() -> Unit): List = + ArrayList().let { result -> + ownerDocumentExt.createTree().onFinalize { it, partial -> + if (!partial) { + result.add(it); appendChild(it) + } + }.block() + + result + } + +fun Node.prepend(block: TagConsumer.() -> Unit): List = + ArrayList().let { result -> + ownerDocumentExt.createTree().onFinalize { it, partial -> + if (!partial) { + result.add(it) + insertBefore(it, firstChild) + } + }.block() + + result + } + +val HTMLElement.append: TagConsumer + get() = ownerDocumentExt.createTree().onFinalize { element, partial -> + if (!partial) { + this@append.appendChild(element) + } + } + +val HTMLElement.prepend: TagConsumer + get() = ownerDocumentExt.createTree().onFinalize { element, partial -> + if (!partial) { + this@prepend.insertBefore(element, this@prepend.firstChild) + } + } + +private val Node.ownerDocumentExt: Document + get() = when { + this is Document -> this + else -> ownerDocument ?: throw IllegalStateException("Node has no ownerDocument") + } diff --git a/src/main/kotlin/nl/astraeus/komp/Komponent.kt b/src/main/kotlin/nl/astraeus/komp/Komponent.kt index e5d7564..cb21766 100644 --- a/src/main/kotlin/nl/astraeus/komp/Komponent.kt +++ b/src/main/kotlin/nl/astraeus/komp/Komponent.kt @@ -1,52 +1,52 @@ package nl.astraeus.komp -import kotlinx.html.HtmlBlockTag -import kotlinx.html.TagConsumer -import kotlinx.html.dom.create +import kotlinx.html.Tag import org.w3c.dom.HTMLElement import org.w3c.dom.Node import kotlin.browser.document -fun HtmlBlockTag.include(component: Komponent) { - component.element = component.render(this.consumer as TagConsumer) +fun Tag.include(component: Komponent) { + component.update() + + val consumer = this.consumer + val element = component.element + + if (consumer is HtmlBuilder && element != null) { + consumer.append(element) + } } abstract class Komponent { var element: Node? = null open fun create(): HTMLElement { - val result = render(document.create) + val consumer = HtmlBuilder(document) + val result = render(consumer) element = result return result } - abstract fun render(consumer: TagConsumer): HTMLElement + abstract fun render(consumer: HtmlBuilder): HTMLElement + + open fun update() = refresh() open fun refresh() { - if (element == null) { - console.log("Unable to refresh, element == null", this) + val oldElement = element + if (logRenderEvent) { + console.log("Rendering", this) } - element?.let { element -> - if (logRenderEvent) { - console.log("Rendering", this) - } + val newElement = create() - val oldElement = element - val newElement = create() - - if (logReplaceEvent) { - console.log("Replacing", oldElement, newElement) - } - element.parentNode?.replaceChild(newElement, oldElement) + if (oldElement != null) { + if (logReplaceEvent) { + console.log("Replacing", oldElement, newElement) + } + oldElement.parentNode?.replaceChild(newElement, oldElement) } } - open fun update() { - refresh() - } - @JsName("remove") fun remove() { element?.let { @@ -61,7 +61,6 @@ } companion object { - var logRenderEvent = false var logReplaceEvent = false