Started working on quest editor. Added a DockWidget based on GoldenLayout.

This commit is contained in:
Daan Vanden Bosch 2020-10-12 23:06:56 +02:00
parent 8a27364237
commit 3114f69429
8 changed files with 655 additions and 1 deletions

View File

@ -6,11 +6,20 @@ plugins {
kotlin {
js {
browser {
webpackTask {
cssSupport.enabled = true
}
runTask {
devServer = devServer!!.copy(
open = false,
port = 1623
)
cssSupport.enabled = true
}
testTask {
useKarma {
webpackConfig.cssSupport.enabled = true
}
}
}
binaries.executable()
@ -29,6 +38,7 @@ dependencies {
implementation("io.ktor:ktor-client-core-js:$ktorVersion")
implementation("io.ktor:ktor-client-serialization-js:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-js:1.0.0")
implementation(npm("golden-layout", "1.5.9"))
testImplementation(kotlin("test-js"))
}

View File

@ -0,0 +1,266 @@
package golden_layout
import org.w3c.dom.Element
@JsModule("golden-layout")
@JsNonModule
external open class GoldenLayout(configuration: Config, container: Element = definedExternally) {
open fun init()
open fun updateSize(width: Double, height: Double)
open fun registerComponent(name: String, component: Any)
open fun destroy()
interface Settings {
var hasHeaders: Boolean?
get() = definedExternally
set(value) = definedExternally
var constrainDragToContainer: Boolean?
get() = definedExternally
set(value) = definedExternally
var reorderEnabled: Boolean?
get() = definedExternally
set(value) = definedExternally
var selectionEnabled: Boolean?
get() = definedExternally
set(value) = definedExternally
var popoutWholeStack: Boolean?
get() = definedExternally
set(value) = definedExternally
var blockedPopoutsThrowError: Boolean?
get() = definedExternally
set(value) = definedExternally
var closePopoutsOnUnload: Boolean?
get() = definedExternally
set(value) = definedExternally
var showPopoutIcon: Boolean?
get() = definedExternally
set(value) = definedExternally
var showMaximiseIcon: Boolean?
get() = definedExternally
set(value) = definedExternally
var showCloseIcon: Boolean?
get() = definedExternally
set(value) = definedExternally
}
interface Dimensions {
var borderWidth: Number?
get() = definedExternally
set(value) = definedExternally
var minItemHeight: Number?
get() = definedExternally
set(value) = definedExternally
var minItemWidth: Number?
get() = definedExternally
set(value) = definedExternally
var headerHeight: Number?
get() = definedExternally
set(value) = definedExternally
var dragProxyWidth: Number?
get() = definedExternally
set(value) = definedExternally
var dragProxyHeight: Number?
get() = definedExternally
set(value) = definedExternally
}
interface Labels {
var close: String?
get() = definedExternally
set(value) = definedExternally
var maximise: String?
get() = definedExternally
set(value) = definedExternally
var minimise: String?
get() = definedExternally
set(value) = definedExternally
var popout: String?
get() = definedExternally
set(value) = definedExternally
}
interface ItemConfig {
var type: String
var content: Array<ItemConfig>?
get() = definedExternally
set(value) = definedExternally
var width: Number?
get() = definedExternally
set(value) = definedExternally
var height: Number?
get() = definedExternally
set(value) = definedExternally
var id: dynamic /* String? | Array<String>? */
get() = definedExternally
set(value) = definedExternally
var isClosable: Boolean?
get() = definedExternally
set(value) = definedExternally
var title: String?
get() = definedExternally
set(value) = definedExternally
}
interface ComponentConfig : ItemConfig {
var componentName: String
var componentState: Any?
get() = definedExternally
set(value) = definedExternally
}
interface ReactComponentConfig : ItemConfig {
var component: String
var props: Any?
get() = definedExternally
set(value) = definedExternally
}
interface Config {
var settings: Settings?
get() = definedExternally
set(value) = definedExternally
var dimensions: Dimensions?
get() = definedExternally
set(value) = definedExternally
var labels: Labels?
get() = definedExternally
set(value) = definedExternally
var content: Array<dynamic /* ItemConfig | ComponentConfig | ReactComponentConfig */>?
get() = definedExternally
set(value) = definedExternally
}
interface ContentItem : EventEmitter {
var config: dynamic /* ItemConfig | ComponentConfig | ReactComponentConfig */
get() = definedExternally
set(value) = definedExternally
var type: String
var contentItems: Array<ContentItem>
var parent: ContentItem
var id: String
var isInitialised: Boolean
var isMaximised: Boolean
var isRoot: Boolean
var isRow: Boolean
var isColumn: Boolean
var isStack: Boolean
var isComponent: Boolean
var layoutManager: Any
var element: Container
var childElementContainer: Container
fun addChild(itemOrItemConfig: ContentItem, index: Number = definedExternally)
fun addChild(itemOrItemConfig: ItemConfig, index: Number = definedExternally)
fun addChild(itemOrItemConfig: ComponentConfig, index: Number = definedExternally)
fun addChild(itemOrItemConfig: ReactComponentConfig, index: Number = definedExternally)
fun removeChild(contentItem: Config, keepChild: Boolean = definedExternally)
fun replaceChild(oldChild: ContentItem, newChild: ContentItem)
fun replaceChild(oldChild: ContentItem, newChild: ItemConfig)
fun replaceChild(oldChild: ContentItem, newChild: ComponentConfig)
fun replaceChild(oldChild: ContentItem, newChild: ReactComponentConfig)
fun setSize()
fun setTitle(title: String)
fun callDownwards(
functionName: String,
functionArguments: Array<Any> = definedExternally,
bottomUp: Boolean = definedExternally,
skipSelf: Boolean = definedExternally,
)
fun emitBubblingEvent(name: String)
fun remove()
fun toggleMaximise()
fun select()
fun deselect()
fun hasId(id: String): Boolean
fun setActiveContentItem(contentItem: ContentItem)
fun getActiveContentItem(): ContentItem
fun addId(id: String)
fun removeId(id: String)
fun getItemsByFilter(filterFunction: (contentItem: ContentItem) -> Boolean): Array<ContentItem>
fun getItemsById(id: String): Array<ContentItem>
fun getItemsById(id: Array<String>): Array<ContentItem>
fun getItemsByType(type: String): Array<ContentItem>
fun getComponentsByName(componentName: String): Any
}
interface Container : EventEmitter {
var width: Number
var height: Number
var parent: ContentItem
var tab: Tab
var title: String
var layoutManager: GoldenLayout
var isHidden: Boolean
fun setState(state: Any)
fun extendState(state: Any)
fun getState(): Any
/**
* Returns jQuery-wrapped element.
*/
fun getElement(): dynamic
fun hide(): Boolean
fun show(): Boolean
fun setSize(width: Number, height: Number): Boolean
fun setTitle(title: String)
fun close(): Boolean
}
interface Header {
var layoutManager: GoldenLayout
var parent: ContentItem
var tabs: Array<Tab>
var activeContentItem: ContentItem
var element: Any
var tabsContainer: Any
var controlsContainer: Any
fun setActiveContentItem(contentItem: ContentItem)
fun createTab(contentItem: ContentItem, index: Number = definedExternally)
fun removeTab(contentItem: ContentItem)
}
interface Tab {
var isActive: Boolean
var header: Header
var contentItem: ContentItem
var element: Any
var titleElement: Any
var closeElement: Any
fun setTitle(title: String)
fun setActive(isActive: Boolean)
}
interface EventEmitter {
fun on(eventName: String, callback: Function<*>, context: Any = definedExternally)
fun emit(
eventName: String,
arg1: Any = definedExternally,
arg2: Any = definedExternally,
vararg argN: Any,
)
fun trigger(
eventName: String,
arg1: Any = definedExternally,
arg2: Any = definedExternally,
vararg argN: Any,
)
fun unbind(
eventName: String,
callback: Function<*> = definedExternally,
context: Any = definedExternally,
)
fun off(
eventName: String,
callback: Function<*> = definedExternally,
context: Any = definedExternally,
)
}
companion object {
fun minifyConfig(config: Any): Any
fun unminifyConfig(minifiedConfig: Any): Any
}
}

View File

@ -17,6 +17,7 @@ import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizer
import world.phantasmal.web.questEditor.QuestEditor
import world.phantasmal.webui.dom.disposableListener
class Application(
@ -51,9 +52,12 @@ class Application(
ApplicationWidget(
addDisposable(NavigationWidget(navigationController)),
addDisposable(MainContentWidget(mainContentController, mapOf(
PwTool.QuestEditor to {
addDisposable(QuestEditor(scope, uiStore)).widget
},
PwTool.HuntOptimizer to {
addDisposable(HuntOptimizer(scope, assetLoader, uiStore)).widget
}
},
))),
),
)

View File

@ -0,0 +1,4 @@
package golden_layout.world.phantasmal.web.core
fun <T> newJsObject(block: T.() -> Unit): T =
js("{}").unsafeCast<T>().apply(block)

View File

@ -0,0 +1,218 @@
package golden_layout.world.phantasmal.web.core.widgets
import golden_layout.GoldenLayout
import golden_layout.world.phantasmal.web.core.newJsObject
import org.w3c.dom.Node
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
private const val HEADER_HEIGHT = 24
private const val DEFAULT_HEADER_HEIGHT = 20
/**
* This value is used to work around a bug in GoldenLayout related to headerHeight.
*/
private const val HEADER_HEIGHT_DIFF = HEADER_HEIGHT - DEFAULT_HEADER_HEIGHT
sealed class DockedItem(val flex: Int?)
sealed class DockedContainer(flex: Int?, val items: List<DockedItem>) : DockedItem(flex)
class DockedRow(
flex: Int? = null,
items: List<DockedItem> = emptyList(),
) : DockedContainer(flex, items)
class DockedColumn(
flex: Int? = null,
items: List<DockedItem> = emptyList(),
) : DockedContainer(flex, items)
class DockedStack(
flex: Int? = null,
items: List<DockedItem> = emptyList(),
) : DockedContainer(flex, items)
class DocketWidget(
val id: String,
val title: String,
flex: Int? = null,
val createWidget: () -> Widget,
) : DockedItem(flex)
class DockWidget(
hidden: Val<Boolean> = falseVal(),
private val item: DockedItem,
) : Widget(::style, hidden) {
private lateinit var goldenLayout: GoldenLayout
init {
js("""require("golden-layout/src/css/goldenlayout-base.css");""")
observeResize()
}
override fun Node.createElement() = div(className = "pw-core-dock") {
val idToCreate = mutableMapOf<String, () -> Widget>()
val config = newJsObject<GoldenLayout.Config> {
settings = newJsObject<GoldenLayout.Settings> {
showPopoutIcon = false
showMaximiseIcon = false
showCloseIcon = false
}
dimensions = newJsObject<GoldenLayout.Dimensions> {
headerHeight = HEADER_HEIGHT
}
content = arrayOf(
toConfigContent(item, idToCreate)
)
}
// Temporarily set width and height so GoldenLayout initializes correctly.
style.width = "1000px"
style.height = "700px"
goldenLayout = GoldenLayout(config, this)
idToCreate.forEach { (id, create) ->
goldenLayout.registerComponent(id) { container: GoldenLayout.Container ->
container.getElement().append(create().element)
}
}
goldenLayout.init()
style.width = ""
style.height = ""
}
override fun resized(width: Double, height: Double) {
goldenLayout.updateSize(width, height)
}
private fun toConfigContent(
item: DockedItem,
idToCreate: MutableMap<String, () -> Widget>,
): GoldenLayout.ItemConfig {
val itemType = when (item) {
is DockedRow -> "row"
is DockedColumn -> "column"
is DockedStack -> "stack"
is DocketWidget -> "component"
}
return when (item) {
is DocketWidget -> {
idToCreate[item.id] = item.createWidget
newJsObject<GoldenLayout.ComponentConfig> {
title = item.title
type = "component"
componentName = item.id
isClosable = false
if (item.flex != null) {
width = item.flex
height = item.flex
}
}
}
is DockedContainer ->
newJsObject {
type = itemType
content = Array(item.items.size) { toConfigContent(item.items[it], idToCreate) }
if (item.flex != null) {
width = item.flex
height = item.flex
}
}
}
}
}
// Use div.pw-core-dock for higher specificity than the default GoldenLayout CSS.
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
// language=css
private fun style() = """
div.pw-core-dock .lm_header {
box-sizing: border-box;
padding: 3px 0 0 0;
border-bottom: var(--pw-border);
}
div.pw-core-dock .lm_tabs {
padding: 0 3px;
}
div.pw-core-dock .lm_tab {
cursor: default;
display: inline-flex;
align-items: center;
height: 23px;
padding: 0 10px;
border: var(--pw-border);
margin: 0 1px -1px 1px;
background-color: hsl(0, 0%, 12%);
color: hsl(0, 0%, 75%);
font-size: 13px;
}
div.pw-core-dock .lm_tab:hover {
background-color: hsl(0, 0%, 18%);
color: hsl(0, 0%, 85%);
}
div.pw-core-dock .lm_tab.lm_active {
background-color: var(--pw-bg-color);
color: hsl(0, 0%, 90%);
border-bottom-color: var(--pw-bg-color);
}
div.pw-core-dock .lm_header .lm_controls > li {
cursor: default;
}
div.pw-core-dock .lm_header .lm_controls .lm_close {
/* a white 9x9 X shape */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAQUlEQVR4nHXOQQ4AMAgCQeT/f6aXpsGK3jSTuCVJAAr7iBdoAwCKd0nwfaAdHbYERw5b44+E8JoBjEYGMBq5gAYP3usUDu2IvoUAAAAASUVORK5CYII=);
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
opacity: 0.4;
transition: opacity 300ms ease;
}
div.pw-core-dock .lm_header .lm_controls .lm_close:hover {
opacity: 1;
}
div.pw-core-dock .lm_content > * {
width: 100%;
/* Subtract HEADER_HEIGHT_DIFF px as workaround for bug related to headerHeight. */
height: calc(100% - ${HEADER_HEIGHT_DIFF}px);
}
div.pw-core-dock .lm_splitter {
box-sizing: border-box;
background-color: hsl(0, 0%, 20%);
}
div.pw-core-dock .lm_splitter.lm_vertical {
border-top: var(--pw-border);
border-bottom: var(--pw-border);
}
div.pw-core-dock .lm_splitter.lm_horizontal {
border-left: var(--pw-border);
border-right: var(--pw-border);
}
body .lm_dropTargetIndicator {
box-sizing: border-box;
background-color: hsla(0, 0%, 100%, 0.2);
}
"""

View File

@ -0,0 +1,10 @@
package world.phantasmal.web.questEditor
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.core.disposable.DisposableContainer
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.questEditor.widgets.QuestEditorWidget
class QuestEditor(scope: CoroutineScope, uiStore: UiStore) : DisposableContainer() {
val widget = QuestEditorWidget()
}

View File

@ -0,0 +1,100 @@
package world.phantasmal.web.questEditor.widgets
import golden_layout.world.phantasmal.web.core.widgets.*
import org.w3c.dom.Node
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
// TODO: Remove TestWidget.
private class TestWidget : Widget() {
override fun Node.createElement() = div {
textContent = "Test ${++count}"
}
companion object {
private var count = 0
}
}
open class QuestEditorWidget : Widget(::style) {
override fun Node.createElement() = div(className = "pw-quest-editor-quest-editor") {
addChild(DockWidget(
item = DockedRow(
items = listOf(
DockedColumn(
flex = 2,
items = listOf(
DockedStack(
items = listOf(
DocketWidget(
title = "Info",
id = "info",
createWidget = ::TestWidget
),
DocketWidget(
title = "NPC Counts",
id = "npc_counts",
createWidget = ::TestWidget
),
)
),
DocketWidget(
title = "Entity",
id = "entity_info",
createWidget = ::TestWidget
),
)
),
DockedStack(
flex = 9,
items = listOf(
DocketWidget(
title = "3D View",
id = "quest_renderer",
createWidget = ::TestWidget
),
DocketWidget(
title = "Script",
id = "asm_editor",
createWidget = ::TestWidget
),
)
),
DockedStack(
flex = 2,
items = listOf(
DocketWidget(
title = "NPCs",
id = "npc_list_view",
createWidget = ::TestWidget
),
DocketWidget(
title = "Objects",
id = "object_list_view",
createWidget = ::TestWidget
),
DocketWidget(
title = "Events",
id = "events_view",
createWidget = ::TestWidget
),
)
),
)
)
))
}
}
@Suppress("CssUnusedSymbol")
// language=css
private fun style() = """
.pw-quest-editor-quest-editor {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pw-quest-editor-quest-editor > * {
flex-grow: 1;
}
"""

View File

@ -2,10 +2,12 @@ package world.phantasmal.webui.widgets
import kotlinx.browser.document
import kotlinx.dom.appendText
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.Node
import world.phantasmal.core.disposable.DisposableContainer
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.Observable
import world.phantasmal.observable.Observer
import world.phantasmal.observable.value.Val
@ -20,6 +22,8 @@ abstract class Widget(
) : DisposableContainer() {
private val _ancestorHidden = mutableVal(false)
private val _children = mutableListOf<Widget>()
private var initResizeObserverRequested = false
private var resizeObserverInitialized = false
private val elementDelegate = lazy {
// Add CSS declarations to stylesheet if this is the first time we're instantiating this
@ -35,6 +39,10 @@ abstract class Widget(
children.forEach { setAncestorHidden(it, hidden || ancestorHidden.value) }
}
if (initResizeObserverRequested) {
initResizeObserver(el)
}
el
}
@ -156,6 +164,40 @@ abstract class Widget(
removeDisposable(child)
}
/**
* Called whenever [element] is resized.
* Must be initialized with [observeResize].
*/
protected open fun resized(width: Double, height: Double) {}
protected fun observeResize() {
if (elementDelegate.isInitialized()) {
initResizeObserver(element)
} else {
initResizeObserverRequested = true
}
}
private fun initResizeObserver(element: Element) {
if (resizeObserverInitialized) return
resizeObserverInitialized = true
@Suppress("UNUSED_VARIABLE")
val resize = ::resizeCallback
val observer = js("new ResizeObserver(resize);")
observer.observe(element)
addDisposable(disposable { observer.disconnect().unsafeCast<Unit>() })
}
private fun resizeCallback(entries: Array<dynamic>) {
entries.forEach { entry ->
resized(
entry.contentRect.width.unsafeCast<Double>(),
entry.contentRect.height.unsafeCast<Double>()
)
}
}
companion object {
private val STYLE_EL by lazy {
val el = document.createElement("style") as HTMLStyleElement