Dim diff option

This commit is contained in:
2020-05-04 19:14:04 +02:00
parent 2b851e7887
commit 7677cbcc7c
11 changed files with 347 additions and 178 deletions

View File

@@ -0,0 +1,195 @@
package nl.astraeus.komp
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.events.Event
import org.w3c.dom.get
object DiffPatch {
fun updateNode(oldNode: Node, newNode: Node): Node {
if (oldNode is HTMLElement && newNode is HTMLElement) {
if (oldNode.nodeName == newNode.nodeName) {
if (oldNode.getAttribute("data-komp-hash") != null &&
oldNode.getAttribute("data-komp-hash") == newNode.getAttribute("data-komp-hash")) {
if (Komponent.logReplaceEvent) {
console.log("Skip node, hash equals", oldNode, newNode)
}
return oldNode
} else {
if (Komponent.logReplaceEvent) {
console.log("Update attributes", oldNode.innerHTML, newNode.innerHTML)
}
updateAttributes(oldNode, newNode);
if (Komponent.logReplaceEvent) {
console.log("Update children", oldNode.innerHTML, newNode.innerHTML)
}
updateChildren(oldNode, newNode)
updateEvents(oldNode, newNode)
return oldNode
}
} else {
if (Komponent.logReplaceEvent) {
console.log("Replace node ee", oldNode.innerHTML, newNode.innerHTML)
}
replaceNode(oldNode, newNode)
return newNode
}
} else {
if (oldNode.nodeType == newNode.nodeType && oldNode.nodeType == 3.toShort()) {
if (oldNode.textContent != newNode.textContent) {
if (Komponent.logReplaceEvent) {
console.log("Updating text content", oldNode, newNode)
}
oldNode.textContent = newNode.textContent
return oldNode
}
}
if (Komponent.logReplaceEvent) {
console.log("Replace node", oldNode, newNode)
}
replaceNode(oldNode, newNode)
return newNode
}
}
private fun updateAttributes(oldNode: HTMLElement, newNode: HTMLElement) {
// removed attributes
for (index in 0 until oldNode.attributes.length) {
val attr = oldNode.attributes[index]
if (attr != null && newNode.attributes[attr.name] == null) {
oldNode.removeAttribute(attr.name)
}
}
for (index in 0 until newNode.attributes.length) {
val attr = newNode.attributes[index]
if (attr != null) {
val oldAttr = oldNode.attributes[attr.name]
if (oldAttr == null || oldAttr.value != attr.value) {
oldNode.setAttribute(attr.name, attr.value)
}
}
}
}
private fun updateChildren(oldNode: HTMLElement, newNode: HTMLElement) {
// todo: add 1 look ahead/back
var oldIndex = 0
var newIndex = 0
if (Komponent.logReplaceEvent) {
console.log("updateChildren old/new count", oldNode.childNodes.length, newNode.childNodes.length)
}
while(newIndex < newNode.childNodes.length) {
if (Komponent.logReplaceEvent) {
console.log(">>> updateChildren old/new count", oldNode.childNodes, newNode.childNodes)
console.log("Update Old/new", oldIndex, newIndex)
}
val newChildNode = newNode.childNodes[newIndex]
if (oldIndex < oldNode.childNodes.length) {
val oldChildNode = oldNode.childNodes[oldIndex]
if (oldChildNode != null && newChildNode != null) {
if (Komponent.logReplaceEvent) {
console.log("Update node Old/new", oldChildNode, newChildNode)
}
updateNode(oldChildNode, newChildNode)
if (Komponent.logReplaceEvent) {
console.log("--- Updated Old/new", oldNode.children, newNode.children)
}
} else {
if (Komponent.logReplaceEvent) {
console.log("Null node", oldChildNode, newChildNode)
}
}
} else {
if (Komponent.logReplaceEvent) {
console.log("Append Old/new/node", oldIndex, newIndex, newChildNode)
}
oldNode.append(newChildNode)
}
if (Komponent.logReplaceEvent) {
console.log("<<< Updated Old/new", oldNode.children, newNode.children)
}
oldIndex++
newIndex++
}
while(oldIndex < oldNode.childNodes.length) {
oldNode.childNodes[oldIndex]?.also {
if (Komponent.logReplaceEvent) {
console.log("Remove old node", it)
}
oldNode.removeChild(it)
}
oldIndex++
}
}
private fun updateEvents(oldNode: HTMLElement, newNode: HTMLElement) {
val oldEvents = mutableListOf<String>()
oldEvents.addAll((oldNode.getAttribute("data-komp-events") ?: "").split(","))
val newEvents = (newNode.getAttribute("data-komp-events") ?: "").split(",")
for (event in newEvents) {
if (event.isNotBlank()) {
val oldNodeEvent = oldNode.asDynamic()["event-$event"]
val newNodeEvent = newNode.asDynamic()["event-$event"]
if (oldNodeEvent != null) {
oldNode.removeEventListener(event, oldNodeEvent as ((Event) -> Unit), null)
}
if (newNodeEvent != null) {
oldNode.addEventListener(event, newNodeEvent as ((Event) -> Unit), null)
oldNode.asDynamic()["event-$event"] = newNodeEvent
}
oldEvents.remove(event)
}
}
for (event in oldEvents) {
if (event.isNotBlank()) {
val oldNodeEvent = oldNode.asDynamic()["event-$event"]
if (oldNodeEvent != null) {
oldNode.removeEventListener(event, oldNodeEvent as ((Event) -> Unit), null)
}
}
}
newNode.getAttribute("data-komp-events")?.also {
oldNode.setAttribute("data-komp-events", it)
}
}
private fun replaceNode(oldNode: Node, newNode: Node) {
oldNode.parentNode?.also { parent ->
val clone = newNode.cloneNode(true)
if (newNode is HTMLElement) {
val events = (newNode.getAttribute("data-komp-events") ?: "").split(",")
for (event in events) {
val foundEvent = newNode.asDynamic()["event-$event"]
if (foundEvent != null) {
clone.addEventListener(event, foundEvent as ((Event) -> Unit), null)
}
}
}
parent.replaceChild(clone, oldNode)
}
}
}

View File

@@ -0,0 +1,196 @@
package nl.astraeus.komp
import kotlinx.html.*
import org.w3c.dom.*
import org.w3c.dom.css.CSSStyleDeclaration
import org.w3c.dom.events.Event
import kotlin.browser.document
@Suppress("NOTHING_TO_INLINE")
private inline fun HTMLElement.setEvent(name: String, noinline callback : (Event) -> Unit) : Unit {
val eventName = if (name.startsWith("on")) { name.substring(2) } else { name }
addEventListener(eventName, callback, null)
//asDynamic()[name] = callback
val events = getAttribute("data-komp-events") ?: ""
setAttribute("data-komp-events", if (events.isBlank()) { eventName } else { "$events,$eventName" })
asDynamic()["event-$eventName"] = callback
}
interface HtmlConsumer : TagConsumer<HTMLElement> {
fun append(node: Node)
}
fun HTMLElement.setStyles(cssStyle: CSSStyleDeclaration) {
for (index in 0 until cssStyle.length) {
val propertyName = cssStyle.item(index)
style.setProperty(propertyName, cssStyle.getPropertyValue(propertyName))
}
}
class HtmlBuilder(
val komponent: Komponent,
val document : Document
) : HtmlConsumer {
private val path = arrayListOf<HTMLElement>()
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
}
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) {
var hash = 0
if (path.isEmpty() || path.last().tagName.toLowerCase() != tag.tagName.toLowerCase()) {
throw IllegalStateException("We haven't entered tag ${tag.tagName} but trying to leave")
}
val element = path.last()
for (index in 0 until element.childNodes.length) {
val child = element.childNodes[index]
if (child is HTMLElement) {
hash = hash * 37 + (child.getAttribute("data-komp-hash")?.toInt() ?: 0)
} else {
hash = hash * 37 + (child?.textContent?.hashCode() ?: 0)
}
}
tag.attributesEntries.forEach {
hash = hash * 37 + it.key.hashCode()
hash = hash * 37 + it.value.hashCode()
if (it.key == "class") {
val classes = it.value.split(Regex("\\s+"))
val classNames = StringBuilder()
for (cls in classes) {
val cssStyle = komponent.declaredStyles[cls]
if (cssStyle != null) {
if (cls.endsWith(":hover")) {
val oldOnMouseOver = element.onmouseover
val oldOnMouseOut = element.onmouseout
element.onmouseover = {
element.setStyles(cssStyle)
oldOnMouseOver?.invoke(it)
}
element.onmouseout = {
cls.split(':').firstOrNull()?.let {
komponent.declaredStyles[it]?.let { cssStyle ->
element.setStyles(cssStyle)
}
}
oldOnMouseOut?.invoke(it)
}
} else {
element.setStyles(cssStyle)
}
} else {
classNames.append(cls)
classNames.append(" ")
}
}
element.className = classNames.toString()
} else {
element.setAttribute(it.key, it.value)
}
}
element.setAttribute("data-komp-hash", hash.toString())
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().first { it.nodeType == Node.TEXT_NODE })
// 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()
companion object {
fun create(content: HtmlBuilder.() -> Unit) : HTMLElement {
val consumer = HtmlBuilder(DummyKomponent(), document)
content.invoke(consumer)
return consumer.finalize()
}
}
}

View File

@@ -0,0 +1,120 @@
package nl.astraeus.komp
import kotlinx.html.Tag
import kotlinx.html.div
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.css.CSSStyleDeclaration
import kotlin.browser.document
public typealias CssStyle = CSSStyleDeclaration.() -> Unit
fun Tag.include(component: Komponent) {
if (component.element != null) {
component.update()
} else {
component.refresh()
}
val consumer = this.consumer
val element = component.element
if (consumer is HtmlBuilder && element != null) {
consumer.append(element)
}
}
class DummyKomponent: Komponent() {
override fun HtmlBuilder.render() {
div {
+ "dummy"
}
}
}
enum class UpdateStrategy {
REPLACE,
DOM_DIFF
}
abstract class Komponent {
var element: Node? = null
val declaredStyles: MutableMap<String, CSSStyleDeclaration> = HashMap()
open fun create(): HTMLElement {
val consumer = HtmlBuilder(this, document)
consumer.render()
val result = consumer.finalize()
element = result
return result
}
abstract fun HtmlBuilder.render()
open fun style(className: String, vararg imports: CssStyle, block: CssStyle = {}) {
val style = (document.createElement("div") as HTMLDivElement).style
for (imp in imports) {
imp(style)
}
block(style)
declaredStyles[className] = style
}
open fun update() = refresh()
open fun refresh() {
val oldElement = element
if (logRenderEvent) {
console.log("Rendering", this)
}
val newElement = create()
if (oldElement != null) {
if (updateStrategy == UpdateStrategy.REPLACE) {
if (logReplaceEvent) {
console.log("Replacing", oldElement, newElement)
}
oldElement.parentNode?.replaceChild(newElement, oldElement)
element = newElement
} else {
if (logReplaceEvent) {
console.log("DomDiffing", oldElement, newElement)
}
element = DiffPatch.updateNode(oldElement, newElement)
}
}
}
@JsName("remove")
fun remove() {
element?.let {
val parent = it.parentElement ?: throw IllegalArgumentException("Element has no parent!?")
if (logReplaceEvent) {
console.log("Remove", it)
}
parent.removeChild(it)
}
}
companion object {
var logRenderEvent = false
var logReplaceEvent = false
var updateStrategy = UpdateStrategy.DOM_DIFF
fun create(parent: HTMLElement, component: Komponent, insertAsFirst: Boolean = false) {
val element = component.create()
if (insertAsFirst && parent.childElementCount > 0) {
parent.insertBefore(element, parent.firstChild)
} else {
parent.appendChild(element)
}
}
}
}