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

import kotlinx.browser.document
import kotlinx.browser.window
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.reflect.KProperty

typealias CssStyle = CSSStyleDeclaration.() -> Unit

class StateDelegate<T>(
  val komponent: Komponent,
  initialValue: T
) {
  var value: T = initialValue

  operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
    return value
  }

  operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
    if (this.value?.equals(value) != true) {
      this.value = value
      komponent.requestUpdate()
    }
  }
}

inline fun <reified T> Komponent.state(initialValue: T): StateDelegate<T> = StateDelegate(this, initialValue)

fun HtmlConsumer.include(component: Komponent) {
  append(component.create())
}

class DummyKomponent: Komponent() {
  override fun HtmlBuilder.render() {
    div {
      + "dummy"
    }
  }
}

abstract class Komponent {
  private var createIndex = getNextCreateIndex()
  private var dirty: Boolean = true

  var vdom: VDOMElement? = null
  var element: Node? = null
  val declaredStyles: MutableMap<String, CSSStyleDeclaration> = HashMap()

  open fun create(): VDOMElement {
    val consumer = HtmlBuilder(this.createIndex)
    try {
      consumer.render()
    } catch (e: Throwable) {
      println("Exception occurred in ${this::class.simpleName}.render() call!")

      throw e
    }
    val result = consumer.finalize()

    if (result.id.isBlank()) {
      result.id = "komp_${createIndex}"
    }

    result.komponent = this

    vdom = result

    dirty = false

    return result
  }

  abstract fun HtmlBuilder.render()

  fun requestUpdate() {
    dirty = true
    scheduleForUpdate(this)
  }

  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()
  }

  internal fun refresh() {
    val oldElement = vdom

    if (logRenderEvent) {
      console.log("Rendering", this)
    }
    val newElement = create()

    element = if (oldElement != null && element != null) {
      if (logReplaceEvent) {
        console.log("DomDiffing", oldElement, newElement)
      }
      DiffPatch.updateNode(element!!, oldElement, newElement)
    } else {
      if (logReplaceEvent) {
        console.log("Create", newElement)
      }
      newElement.createElement()
    }

    vdom = newElement
    dirty = false
  }

  companion object {
    private var nextCreateIndex: Int = 1
    private var updateCallback: Int? = null
    private var scheduledForUpdate = mutableSetOf<Komponent>()

    var logRenderEvent = false
    var logReplaceEvent = false

    fun create(parent: HTMLElement, component: Komponent, insertAsFirst: Boolean = false) {
      val vdomElement = component.create()
      val element = vdomElement.createElement()
      component.element = element

      if (insertAsFirst && parent.childElementCount > 0) {
        parent.insertBefore(element, parent.firstChild)
      } else {
        parent.appendChild(element)
      }
    }

    private fun getNextCreateIndex() = nextCreateIndex++

    private fun scheduleForUpdate(komponent: Komponent) {
      scheduledForUpdate.add(komponent)

      if (updateCallback == null) {
        window.setTimeout({
          runUpdate()
        }, 0)
      }
    }

    private fun runUpdate() {
      val todo = scheduledForUpdate.sortedBy { komponent -> komponent.createIndex }

      if (logRenderEvent) {
        console.log("runUpdate")
      }

      todo.forEach { next ->
        val element = next.element

        if (element is HTMLElement) {
          if (document.getElementById(element.id) != null) {
            if (next.dirty) {
              if (logRenderEvent) {
                console.log("Update dirty ${next.createIndex}")
              }
              next.update()
            } else {
              if (logRenderEvent) {
                console.log("Skip ${next.createIndex}")
              }
            }
          } else {
            console.log("Komponent element has no id, ", next, element)
          }
        } else {
          console.log("Komponent element is null", next)
        }
      }

      scheduledForUpdate.clear()
      updateCallback = null
    }
  }

}