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

import kotlinx.browser.window
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.css.CSSStyleDeclaration
import org.w3c.dom.get
import kotlin.reflect.KProperty

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)

enum class UnsafeMode {
  UNSAFE_ALLOWED,
  UNSAFE_DISABLED,
  UNSAFE_SVG_ONLY
}

abstract class Komponent {
  val createIndex = getNextCreateIndex()
  private var dirty: Boolean = true
  private var lastMemoizeHash: Int? = null

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

  open fun create(parent: Element, childIndex: Int? = null) {
    onBeforeUpdate()
    val builder = HtmlBuilder(
      parent,
      childIndex ?: parent.childElementCount
    )

    builder.render()
    element = builder.root
    lastMemoizeHash = generateMemoizeHash()
    onAfterUpdate()
  }

  fun memoizeChanged() = lastMemoizeHash != null && lastMemoizeHash != generateMemoizeHash()

  abstract fun HtmlBuilder.render()

  /**
   * This method is called after the Komponent is updated
   *
   * note: it's also called at first render
   */
  open fun onAfterUpdate() {}

  /**
   * This method is called before the Komponent is updated
   * and before memoizeHash is checked
   *
   * note: it's also called at first render
   */
  open fun onBeforeUpdate() {}

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

  /**
   * Request an immediate update of this Komponent
   *
   * This will run immediately, make sure Komponents are not rendered multiple times
   * Any scheduled updates will be run as well
   */
  fun requestImmediateUpdate() {
    dirty = true
    runUpdateImmediately(this)
  }

  /**
   * This function can be overwritten if you know how to update the Komponent yourself
   *
   * HTMLBuilder.render() is called 1st time the component is rendered, after that this
   * method will be called
   */
  open fun update() {
    refresh()
  }

  /**
   * If this function returns a value it will be stored and on the next render it will be compared.
   *
   * The render will only happen if the hash is not null and has changed
   */
  open fun generateMemoizeHash(): Int? = null

  internal fun refresh() {
    val currentElement = element

    check(currentElement != null) {
      error("element is null")
    }

    val parent = currentElement.parentElement as? HTMLElement ?: error("parent is null!?")
    var childIndex = 0
    for (index in 0 until parent.children.length) {
      if (parent.children[index] == currentElement) {
        childIndex = index
      }
    }
    val consumer = HtmlBuilder(parent, childIndex)
    consumer.root = null
    consumer.render()
    element = consumer.root
    dirty = false
  }

  override fun toString(): String {
    return "${this::class.simpleName}"
  }

  companion object {
    private var nextCreateIndex: Int = 1
    private var updateCallback: Int? = null
    private var scheduledForUpdate = mutableSetOf<Komponent>()
    private var interceptor: (Komponent, () -> Unit) -> Unit = { _, block -> block() }

    var logRenderEvent = false
    var logReplaceEvent = false
    var unsafeMode = UnsafeMode.UNSAFE_DISABLED

    fun create(parent: HTMLElement, component: Komponent, insertAsFirst: Boolean = false) {
      component.create(parent)
    }

    fun setUpdateInterceptor(block: (Komponent, () -> Unit) -> Unit) {
      interceptor = block
    }

    private fun getNextCreateIndex() = nextCreateIndex++

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

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

    private fun runUpdateImmediately(komponent: Komponent) {
      scheduledForUpdate.add(komponent)
      runUpdate()
    }

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

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

      todo.forEach { next ->
        interceptor(next) {
          val element = next.element

          if (element is HTMLElement) {
            if (next.dirty) {
              if (logRenderEvent) {
                console.log("Update dirty ${next.createIndex}")
              }
              val memoizeHash = next.generateMemoizeHash()

              if (memoizeHash == null || next.lastMemoizeHash != memoizeHash) {
                next.onBeforeUpdate()
                next.update()
                next.lastMemoizeHash = memoizeHash
                next.onAfterUpdate()
              } else if (logRenderEvent) {
                console.log("Skipped render, memoizeHash is equal $next-[$memoizeHash]")
              }
            } else {
              if (logRenderEvent) {
                console.log("Skip ${next.createIndex}")
              }
            }
          } else {
            console.log("Komponent element is null", next, element)
          }
        }
      }

      scheduledForUpdate.clear()
      updateCallback = null
    }
  }

}