Newer
Older
komp / src / jsMain / kotlin / nl / astraeus / komp / HtmlBuilder.kt
rnentjes on 5 Apr 2021 7 KB VDom implementation
package nl.astraeus.komp

import kotlinx.browser.document
import kotlinx.html.*
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.events.Event
import kotlin.collections.MutableList
import kotlin.collections.MutableMap
import kotlin.collections.arrayListOf
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.isNotEmpty
import kotlin.collections.iterator
import kotlin.collections.last
import kotlin.collections.lastIndex
import kotlin.collections.mutableListOf
import kotlin.collections.mutableMapOf
import kotlin.collections.set
import kotlin.collections.withIndex

private fun attributeHash(key: String, value: String): Int =
  3 * key.hashCode() +
      5 * value.hashCode()

private fun MutableMap<String, String>.kompHash(): Int {
  var result = 0

  for ((name, value) in this) {
    result += attributeHash(name, value)
  }

  return result
}

private fun MutableMap<String, (Event) -> Unit>.kompHash(): Int {
  var result = 0

  for ((name, event) in this) {
    result += attributeHash(name, event.toString())
  }

  return result
}

private fun MutableList<VDOMElement>.kompHash(): Int {
  var result = 0

  for (vdom in this) {
    result += 3 * vdom.hash.hashCode()
  }

  return result
}

enum class VDOMElementType {
  TAG,
  TEXT,
  ENTITY,
  UNSAFE,
  COMMENT
}

class VDOMElementHash(
  var baseHash: Int,
  var contentHash: Int,
  var typeHash: Int,
  var namespaceHash: Int = 0,
  var attributesHash: Int = 0,
  var eventsHash: Int = 0,
  var childNodesHash: Int = 0
) {

  override fun hashCode(): Int = baseHash +
      3 * contentHash +
      5 * typeHash +
      7 * namespaceHash +
      11 * attributesHash +
      13 * eventsHash +
      15 * childNodesHash

  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other !is VDOMElementHash) return false

    return other.hashCode() == this.hashCode()
  }

}

class VDOMElement(
  val baseHash: Int,
  var content: String,
  var namespace: String? = null,
  var type: VDOMElementType = VDOMElementType.TAG,
) {
  val attributes: MutableMap<String, String> = mutableMapOf()
  val events: MutableMap<String, (Event) -> Unit> = mutableMapOf()
  val childNodes: MutableList<VDOMElement> = mutableListOf()

  val hash = VDOMElementHash(
    baseHash,
    content.hashCode(),
    type.hashCode()
  )

  var id: String = ""
    set(value) {
      field = value
      attributes["id"] = value
    }
  var komponent: Komponent? = null

  fun setKompEvent(event: String, value: (Event) -> Unit) {
    val eventName = if (event.startsWith("on")) {
      event.substring(2)
    } else {
      event
    }
    val recalculate = events.containsKey(eventName)
    events[eventName] = value
    if (recalculate) {
      hash.eventsHash = events.kompHash()
    } else {
      hash.eventsHash += attributeHash(eventName, value.toString())
    }
  }

  fun appendChild(element: VDOMElement) {
    childNodes.add(element)
    //hash.childNodesHash += element.hash.hashCode()
  }

  fun updateChildHash() {
    hash.childNodesHash = childNodes.kompHash()
  }

  fun removeAttribute(attr: String) {
    if (attributes.containsKey(attr)) {
      hash.attributesHash -= attributeHash(attr, attributes[attr] ?: "")
    }
    attributes.remove(attr)
  }

  fun setAttribute(attr: String, value: String) {
    if (attributes.containsKey(attr)) {
      hash.attributesHash -= attributeHash(attr, attributes[attr] ?: "")
    }
    if (attr.toLowerCase() == "id") {
      id = value
    }
    attributes[attr] = value
    hash.attributesHash += attributeHash(attr, value)
  }

  fun findNodeHashIndex(hash: Int): Int {
    for ((index, node) in this.childNodes.withIndex()) {
      if (node.type == VDOMElementType.TAG && node.hash.hashCode() == hash) {
        return index
      }
    }

    return -2
  }

  fun createElement(): Node {
    val result = when (type) {
      VDOMElementType.TAG -> {
        val result: Element = if (namespace != null) {
          document.createElementNS(namespace, content)
        } else {
          document.createElement(content)
        }

        for ((name, value) in attributes) {
          result.setAttribute(name, value)
        }

        for ((name, value) in events) {
          result.addEventListener(name, value)
        }

        for (child in childNodes) {
          result.appendChild(child.createElement())
        }

        result
      }
      VDOMElementType.ENTITY,
      VDOMElementType.UNSAFE,
      VDOMElementType.TEXT -> {
        document.createTextNode(content)
      }
      VDOMElementType.COMMENT -> {
        document.createComment(content)
      }
    }

    komponent?.also {
      it.element = result
    }

    return result
  }
}

interface HtmlConsumer : TagConsumer<VDOMElement> {
  fun append(node: VDOMElement)
}

class HtmlBuilder(
  val baseHash: Int
) : HtmlConsumer {
  private val path = arrayListOf<VDOMElement>()
  private var lastLeaved: VDOMElement? = null

  override fun onTagStart(tag: Tag) {
    val element = VDOMElement(baseHash, tag.tagName, tag.namespace)

    for (entry in tag.attributesEntries) {
      element.setAttribute(entry.key, entry.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().content.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().content.toLowerCase() != tag.tagName.toLowerCase() -> throw IllegalStateException("Wrong current tag")
      else -> path.last().setKompEvent(event, value)
    }
  }

  override fun onTagEnd(tag: Tag) {
    if (path.isEmpty() || path.last().content.toLowerCase() != tag.tagName.toLowerCase()) {
      throw IllegalStateException("We haven't entered tag ${tag.tagName} but trying to leave")
    }

    lastLeaved = path.removeAt(path.lastIndex)
    lastLeaved?.updateChildHash()
  }

  override fun onTagContent(content: CharSequence) {
    if (path.isEmpty()) {
      throw IllegalStateException("No current DOM node")
    }

    path.last().appendChild(VDOMElement(baseHash, content.toString(), type = VDOMElementType.TEXT))
  }

  override fun onTagContentEntity(entity: Entities) {
    if (path.isEmpty()) {
      throw IllegalStateException("No current DOM node")
    }

    // stupid hack as browsers don't support createEntityReference
    path.last().appendChild(VDOMElement(baseHash, entity.text, type = VDOMElementType.ENTITY))
  }

  override fun append(node: VDOMElement) {
    path.last().appendChild(node)
  }

  override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
    with(DefaultUnsafe()) {
      block()

      path.last().appendChild(VDOMElement(baseHash, toString(), type = VDOMElementType.UNSAFE))
    }
  }

  override fun onTagComment(content: CharSequence) {
    if (path.isEmpty()) {
      throw IllegalStateException("No current DOM node")
    }

    path.last().appendChild(VDOMElement(baseHash, content.toString(), type = VDOMElementType.COMMENT))
  }

  override fun finalize(): VDOMElement {
    return lastLeaved ?: throw IllegalStateException("We can't finalize as there was no tags")
  }

  companion object {
    fun create(content: HtmlBuilder.() -> Unit): VDOMElement {
      val komponent = DummyKomponent()
      val consumer = HtmlBuilder(komponent.hashCode())
      content.invoke(consumer)
      return consumer.finalize()
    }
  }
}