Newer
Older
komp / src / jsMain / kotlin / nl / astraeus / komp / Komponent.kt
package nl.astraeus.komp

import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.css.CSSStyleDeclaration
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.html.div
import kotlin.reflect.KProperty

const val KOMP_KOMPONENT = "komp-komponent"

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) {
  if (Komponent.updateStrategy == UpdateStrategy.REPLACE) {
    if (component.element != null) {
      component.update()
    } else {
      component.refresh()
    }

    component.element?.also {
      append(it)
    }
  } else {
    append(component.create())
  }
}

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

enum class UpdateStrategy {
  REPLACE,
  DOM_DIFF
}

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

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

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

    element = result
    element.asDynamic()[KOMP_KOMPONENT] = this

    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 = element

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

    if (oldElement != null) {
      element = if (updateStrategy == UpdateStrategy.REPLACE) {
          if (logReplaceEvent) {
            console.log("Replacing", oldElement, newElement)
          }
          oldElement.parentNode?.replaceChild(newElement, oldElement)
        newElement
      } else {
          if (logReplaceEvent) {
            console.log("DomDiffing", oldElement, newElement)
          }
        DiffPatch.updateNode(oldElement, newElement)
      }
    }

    dirty = false
  }

  @JsName("remove")
  fun remove() {
    check(updateStrategy == UpdateStrategy.REPLACE) {
      "remote only works with UpdateStrategy.REPLACE"
    }
    element?.let {
      val parent = it.parentElement ?: throw IllegalArgumentException("Element has no parent!?")

      if (logReplaceEvent) {
        console.log("Remove", it)
      }

      parent.removeChild(it)
    }
  }

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

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

    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
        console.log("update element", element)
        if (element is HTMLElement) {
          console.log("by id", document.getElementById(element.id))
          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}")
              }
            }
          }
        }
      }

      scheduledForUpdate.clear()
      updateCallback = null
    }
  }

}