Added Dialog and ResultDialog.

This commit is contained in:
Daan Vanden Bosch 2020-11-16 20:00:26 +01:00
parent c82396326c
commit d98b565766
22 changed files with 539 additions and 88 deletions

View File

@ -3,14 +3,25 @@ package world.phantasmal.core
/**
* Returns the given filename without the file extension.
*/
fun basename(filename: String): String {
val dotIdx = filename.lastIndexOf(".")
// < 0 means filename doesn't contain any "."
// Also skip index 0 because that would mean the basename is empty.
if (dotIdx > 1) {
return filename.substring(0, dotIdx)
fun filenameBase(filename: String): String? =
when (val dotIdx = filename.lastIndexOf(".")) {
// Empty basename.
0 -> null
// No extension.
-1 -> filename
// Has a basename and extension.
else -> filename.substring(0, dotIdx)
}
return filename
}
/**
* Returns the extension of the given filename.
*/
fun filenameExtension(filename: String): String? =
when (val dotIdx = filename.lastIndexOf(".")) {
// No extension.
-1 -> null
// Empty extension.
filename.lastIndex -> null
// Has an extension.
else -> filename.substring(dotIdx + 1)
}

View File

@ -4,7 +4,7 @@ import mu.KotlinLogging
import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
import world.phantasmal.core.Success
import world.phantasmal.core.basename
import world.phantasmal.core.filenameBase
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.cursor.Cursor
@ -227,7 +227,7 @@ private fun parseHeaders(cursor: Cursor): List<QstHeader> {
if (
prevQuestId != null &&
prevFilename != null &&
(questId != prevQuestId || basename(filename) != basename(prevFilename!!))
(questId != prevQuestId || filenameBase(filename) != filenameBase(prevFilename!!))
) {
cursor.seek(-headerSize)
return@repeat

View File

@ -53,4 +53,10 @@ interface Val<out T> : Observable<T> {
fun <R> flatMapNull(transform: (T) -> Val<R>?): Val<R?> =
FlatMappedVal(listOf(this)) { transform(value) ?: nullVal() }
fun isNull(): Val<Boolean> =
map { it == null }
fun isNotNull(): Val<Boolean> =
map { it != null }
}

View File

@ -3,6 +3,7 @@ package world.phantasmal.observable.value
private val TRUE_VAL: Val<Boolean> = StaticVal(true)
private val FALSE_VAL: Val<Boolean> = StaticVal(false)
private val NULL_VALL: Val<Nothing?> = StaticVal(null)
private val EMPTY_STRING_VAL: Val<String> = StaticVal("")
fun <T> value(value: T): Val<T> = StaticVal(value)
@ -12,6 +13,8 @@ fun falseVal(): Val<Boolean> = FALSE_VAL
fun nullVal(): Val<Nothing?> = NULL_VALL
fun emptyStringVal(): Val<String> = EMPTY_STRING_VAL
/**
* Creates a [MutableVal] with initial value [value].
*/

View File

@ -1,23 +1,17 @@
package world.phantasmal.observable.value
infix fun Val<Any?>.eq(value: Any?): Val<Boolean> =
infix fun <T> Val<T>.eq(value: T): Val<Boolean> =
map { it == value }
infix fun Val<Any?>.eq(value: Val<Any?>): Val<Boolean> =
infix fun <T> Val<T>.eq(value: Val<T>): Val<Boolean> =
map(value) { a, b -> a == b }
infix fun Val<Any?>.ne(value: Any?): Val<Boolean> =
infix fun <T> Val<T>.ne(value: T): Val<Boolean> =
map { it != value }
infix fun Val<Any?>.ne(value: Val<Any?>): Val<Boolean> =
infix fun <T> Val<T>.ne(value: Val<T>): Val<Boolean> =
map(value) { a, b -> a != b }
fun Val<Any?>.isNull(): Val<Boolean> =
map { it == null }
fun Val<Any?>.isNotNull(): Val<Boolean> =
map { it != null }
fun <T> Val<T?>.orElse(defaultValue: () -> T): Val<T> =
map { it ?: defaultValue() }
@ -44,3 +38,15 @@ infix fun Val<Boolean>.xor(other: Val<Boolean>): Val<Boolean> =
map(other) { a, b -> a != b }
operator fun Val<Boolean>.not(): Val<Boolean> = map { !it }
fun Val<String>.isEmpty(): Val<Boolean> =
map { it.isEmpty() }
fun Val<String>.isNotEmpty(): Val<Boolean> =
map { it.isNotEmpty() }
fun Val<String>.isBlank(): Val<Boolean> =
map { it.isBlank() }
fun Val<String>.isNotBlank(): Val<Boolean> =
map { it.isNotBlank() }

View File

@ -13,21 +13,31 @@ abstract class RegularValTests : ValTests() {
protected abstract fun <T> createWithValue(value: T): Val<T>
@Test
fun val_any_extensions() = test {
fun val_convenience_methods() = test {
listOf(Any(), null).forEach { any ->
val value = createWithValue(any)
val anyVal = createWithValue(any)
// Test the test setup first.
assertEquals(any, value.value)
assertEquals(any, anyVal.value)
// Test `isNull`.
assertEquals(any == null, value.isNull().value)
assertEquals(any == null, anyVal.isNull().value)
// Test `isNotNull`.
assertEquals(any != null, value.isNotNull().value)
assertEquals(any != null, anyVal.isNotNull().value)
}
}
@Test
fun val_generic_extensions() = test {
listOf(Any(), null).forEach { any ->
val anyVal = createWithValue(any)
// Test the test setup first.
assertEquals(any, anyVal.value)
// Test `orElse`.
assertEquals(any ?: "default", value.orElse { "default" }.value)
assertEquals(any ?: "default", anyVal.orElse { "default" }.value)
}
listOf(10 to 10, 5 to 99, "a" to "a", "x" to "y").forEach { (a, b) ->
val aVal = createWithValue(a)
@ -79,25 +89,47 @@ abstract class RegularValTests : ValTests() {
@Test
fun val_boolean_extensions() = test {
listOf(true, false).forEach { bool ->
val value = createWithValue(bool)
val boolVal = createWithValue(bool)
// Test the test setup first.
assertEquals(bool, value.value)
assertEquals(bool, boolVal.value)
// Test `and`.
assertEquals(bool, (value and trueVal()).value)
assertFalse((value and falseVal()).value)
assertEquals(bool, (boolVal and trueVal()).value)
assertFalse((boolVal and falseVal()).value)
// Test `or`.
assertTrue((value or trueVal()).value)
assertEquals(bool, (value or falseVal()).value)
assertTrue((boolVal or trueVal()).value)
assertEquals(bool, (boolVal or falseVal()).value)
// Test `xor`.
assertEquals(!bool, (value xor trueVal()).value)
assertEquals(bool, (value xor falseVal()).value)
assertEquals(!bool, (boolVal xor trueVal()).value)
assertEquals(bool, (boolVal xor falseVal()).value)
// Test `!` (unary not).
assertEquals(!bool, (!value).value)
assertEquals(!bool, (!boolVal).value)
}
}
@Test
fun val_string_extensions() = test {
listOf("", " ", "\t\t", "non-empty-non-blank").forEach { string ->
val stringVal = createWithValue(string)
// Test the test setup first.
assertEquals(string, stringVal.value)
// Test `isEmpty`.
assertEquals(string.isEmpty(), stringVal.isEmpty().value)
// Test `isNotEmpty`.
assertEquals(string.isNotEmpty(), stringVal.isNotEmpty().value)
// Test `isBlank`.
assertEquals(string.isBlank(), stringVal.isBlank().value)
// Test `isNotBlank`.
assertEquals(string.isNotBlank(), stringVal.isNotBlank().value)
}
}
}

View File

@ -1,7 +1,6 @@
package world.phantasmal.observable.value
import world.phantasmal.core.disposable.use
import world.phantasmal.observable.Observable
import world.phantasmal.observable.ObservableAndEmit
import world.phantasmal.observable.ObservableTests
import kotlin.test.Test

View File

@ -3,7 +3,6 @@ package world.phantasmal.web.questEditor.controllers
import world.phantasmal.core.math.degToRad
import world.phantasmal.core.math.radToDeg
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.value
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.questEditor.actions.RotateEntityAction

View File

@ -2,7 +2,6 @@ package world.phantasmal.web.questEditor.controllers
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.list.emptyListVal
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.emptyStringVal
import world.phantasmal.observable.value.value
import world.phantasmal.web.questEditor.actions.EditIdAction
import world.phantasmal.web.questEditor.actions.EditLongDescriptionAction
@ -16,11 +16,11 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
val episode: Val<String> = store.currentQuest.map { it?.episode?.name ?: "" }
val id: Val<Int> = store.currentQuest.flatMap { it?.id ?: value(0) }
val name: Val<String> = store.currentQuest.flatMap { it?.name ?: value("") }
val name: Val<String> = store.currentQuest.flatMap { it?.name ?: emptyStringVal() }
val shortDescription: Val<String> =
store.currentQuest.flatMap { it?.shortDescription ?: value("") }
store.currentQuest.flatMap { it?.shortDescription ?: emptyStringVal() }
val longDescription: Val<String> =
store.currentQuest.flatMap { it?.longDescription ?: value("") }
store.currentQuest.flatMap { it?.longDescription ?: emptyStringVal() }
fun setId(id: Int) {
if (!enabled.value) return

View File

@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mu.KotlinLogging
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNotNull
import world.phantasmal.web.externals.babylon.AbstractMesh
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.loading.EntityAssetLoader

View File

@ -28,6 +28,7 @@ class Viewer(
// Rendering
val canvas = document.createElement("CANVAS") as HTMLCanvasElement
canvas.style.outline = "none"
val renderer = addDisposable(MeshRenderer(viewerStore, canvas, createEngine(canvas)))
// Main Widget

View File

@ -2,6 +2,7 @@ package world.phantasmal.web.viewer.controller
import mu.KotlinLogging
import org.w3c.files.File
import world.phantasmal.core.Failure
import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
import world.phantasmal.core.Success
@ -13,6 +14,7 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.extension
import world.phantasmal.webui.readFile
private val logger = KotlinLogging.logger {}
@ -23,53 +25,68 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result
val resultMessage: Val<String> = result.map {
if (it is Failure) "An error occurred while opening files."
else "Encountered some problems while opening files."
}
suspend fun openFiles(files: List<File>) {
var modelFileFound = false
val result = PwResult.build<Nothing>(logger)
val result = PwResult.build<Unit>(logger)
var success = false
try {
for (file in files) {
if (file.name.endsWith(".nj", ignoreCase = true)) {
if (modelFileFound) continue
var modelFound = false
var textureFound = false
modelFileFound = true
for (file in files) {
when (file.extension()?.toLowerCase()) {
"nj" -> {
if (modelFound) continue
modelFound = true
val njResult = parseNj(readFile(file).cursor(Endianness.Little))
result.addResult(njResult)
if (njResult is Success) {
store.setCurrentNinjaObject(njResult.value.firstOrNull())
success = true
}
}
} else if (file.name.endsWith(".xj", ignoreCase = true)) {
if (modelFileFound) continue
modelFileFound = true
"xj" -> {
if (modelFound) continue
modelFound = true
val xjResult = parseXj(readFile(file).cursor(Endianness.Little))
result.addResult(xjResult)
if (xjResult is Success) {
store.setCurrentNinjaObject(xjResult.value.firstOrNull())
success = true
}
} else {
}
else -> {
result.addProblem(
Severity.Error,
"""File "${file.name}" has an unsupported file type."""
)
}
}
}
} catch (e: Exception) {
result.addProblem(Severity.Error, "Couldn't parse files.", cause = e)
}
// Set failure result, because setResult doesn't care about the type.
setResult(result.failure())
setResult(if (success) result.success(Unit) else result.failure())
}
private fun setResult(result: PwResult<*>) {
fun dismissResultDialog() {
_resultDialogVisible.value = false
}
private fun setResult(result: PwResult<*>?) {
_result.value = result
if (result.problems.isNotEmpty()) {
_resultDialogVisible.value = true
}
_resultDialogVisible.value = result != null && result.problems.isNotEmpty()
}
}

View File

@ -7,6 +7,7 @@ import world.phantasmal.web.viewer.controller.ViewerToolbarController
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.FileButton
import world.phantasmal.webui.widgets.ResultDialog
import world.phantasmal.webui.widgets.Toolbar
import world.phantasmal.webui.widgets.Widget
@ -31,5 +32,12 @@ class ViewerToolbar(
)
)
))
addDisposable(ResultDialog(
scope,
visible = ctrl.resultDialogVisible,
result = ctrl.result,
message = ctrl.resultMessage,
onDismiss = ctrl::dismissResultDialog,
))
}
}

View File

@ -1,4 +1,10 @@
package world.phantasmal.webui
import org.w3c.files.File
import world.phantasmal.core.filenameExtension
inline fun <T> obj(block: T.() -> Unit): T =
js("{}").unsafeCast<T>().apply(block)
fun File.extension(): String? =
filenameExtension(name)

View File

@ -1,13 +1,12 @@
package world.phantasmal.webui.dom
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.dom.appendText
import org.w3c.dom.AddEventListenerOptions
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.Node
import org.w3c.dom.*
import org.w3c.dom.events.Event
import org.w3c.dom.events.EventTarget
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.disposable
@ -25,6 +24,50 @@ fun <E : Event> disposableListener(
}
}
fun Element.disposablePointerDrag(
onPointerDown: (e: PointerEvent) -> Boolean,
onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean,
onPointerUp: (e: PointerEvent) -> Unit = {},
): Disposable {
var prevPointerX: Int
var prevPointerY: Int
var windowMoveListener: Disposable? = null
var windowUpListener: Disposable? = null
val downListener = disposableListener<PointerEvent>(this, "pointerdown", { downEvent ->
if (onPointerDown(downEvent)) {
prevPointerX = downEvent.clientX
prevPointerY = downEvent.clientY
windowMoveListener =
disposableListener<PointerEvent>(window, "pointermove", { moveEvent ->
val movedX = moveEvent.clientX - prevPointerX
val movedY = moveEvent.clientY - prevPointerY
prevPointerX = moveEvent.clientX
prevPointerY = moveEvent.clientY
if (!onPointerMove(movedX, movedY, moveEvent)) {
windowMoveListener?.dispose()
windowUpListener?.dispose()
}
})
windowUpListener =
disposableListener<PointerEvent>(window, "pointerup", { upEvent ->
onPointerUp(upEvent)
windowMoveListener?.dispose()
windowUpListener?.dispose()
})
}
})
return disposable {
downListener.dispose()
windowMoveListener?.dispose()
windowUpListener?.dispose()
}
}
fun HTMLElement.root(): HTMLElement {
val styleEl = document.createElement("style") as HTMLStyleElement
styleEl.id = "pw-root-styles"
@ -35,6 +78,8 @@ fun HTMLElement.root(): HTMLElement {
return this
}
fun getRoot(): HTMLElement = document.getElementById("pw-root") as HTMLElement
enum class Icon {
ArrowDown,
Eye,

View File

@ -3,6 +3,12 @@ package world.phantasmal.webui.dom
import kotlinx.browser.document
import org.w3c.dom.*
fun <T : Node> dom(block: Node.() -> T): T =
documentFragment().block()
fun documentFragment(): DocumentFragment =
document.createDocumentFragment()
fun Node.button(block: HTMLButtonElement.() -> Unit = {}): HTMLButtonElement =
appendHtmlEl("BUTTON", block)
@ -33,12 +39,18 @@ fun Node.input(block: HTMLInputElement.() -> Unit = {}): HTMLInputElement =
fun Node.label(block: HTMLLabelElement.() -> Unit = {}): HTMLLabelElement =
appendHtmlEl("LABEL", block)
fun Node.li(block: HTMLLIElement.() -> Unit = {}): HTMLLIElement =
appendHtmlEl("LI", block)
fun Node.main(block: HTMLElement.() -> Unit = {}): HTMLElement =
appendHtmlEl("MAIN", block)
fun Node.p(block: HTMLParagraphElement.() -> Unit = {}): HTMLParagraphElement =
appendHtmlEl("P", block)
fun Node.section(block: HTMLElement.() -> Unit = {}): HTMLElement =
appendHtmlEl("SECTION", block)
fun Node.span(block: HTMLSpanElement.() -> Unit = {}): HTMLSpanElement =
appendHtmlEl("SPAN", block)
@ -57,6 +69,9 @@ fun Node.th(block: HTMLTableCellElement.() -> Unit = {}): HTMLTableCellElement =
fun Node.tr(block: HTMLTableRowElement.() -> Unit = {}): HTMLTableRowElement =
appendHtmlEl("TR", block)
fun Node.ul(block: HTMLUListElement.() -> Unit = {}): HTMLUListElement =
appendHtmlEl("UL", block)
fun <T : HTMLElement> Node.appendHtmlEl(tagName: String, block: T.() -> Unit): T =
appendChild(newHtmlEl(tagName, block)).unsafeCast<T>()

View File

@ -0,0 +1,217 @@
package world.phantasmal.webui.widgets
import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.events.Event
import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.get
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isEmpty
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.dom
import world.phantasmal.webui.dom.h1
import world.phantasmal.webui.dom.section
open class Dialog(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
private val title: Val<String>,
private val description: Val<String>,
private val content: Val<Node>,
protected val onDismiss: () -> Unit = {},
) : Widget(scope, visible, enabled) {
private var x = 0
private var y = 0
private var dialogElement = dom {
section {
className = "pw-dialog"
tabIndex = 0
style.width = "${WIDTH}px"
style.maxHeight = "${MAX_HEIGHT}px"
addEventListener("keydown", ::onKeyDown)
h1 {
text(this@Dialog.title)
onDrag(
onPointerDown = { true },
onPointerMove = ::onPointerMove,
onPointerUp = { it.preventDefault() },
)
}
div {
className = "pw-dialog-description"
hidden(description.isEmpty())
text(description)
}
div {
className = "pw-dialog-body"
observe(content) {
textContent = ""
append(it)
}
}
div {
className = "pw-dialog-footer"
addFooterContent(this)
}
}
}
private var overlayElement = dom {
div {
className = "pw-dialog-modal-overlay"
tabIndex = -1
addEventListener("focus", { this@Dialog.focus() })
}
}
init {
observe(visible) {
if (it) {
setPosition(
(window.innerWidth - WIDTH) / 2,
(window.innerHeight - MAX_HEIGHT) / 2,
)
window.document.body!!.append(overlayElement)
window.document.body!!.append(dialogElement)
focus()
} else {
dialogElement.remove()
overlayElement.remove()
}
}
}
override fun Node.createElement() = div { className = "pw-dialog-stub" }
override fun internalDispose() {
dialogElement.remove()
overlayElement.remove()
super.internalDispose()
}
protected open fun addFooterContent(footer: Node) {
// Do nothing.
}
private fun onPointerMove(movedX: Int, movedY: Int, e: PointerEvent): Boolean {
e.preventDefault()
setPosition(this.x + movedX, this.y + movedY)
return true
}
private fun setPosition(x: Int, y: Int) {
this.x = x
this.y = y
dialogElement.style.transform = "translate(${x}px, ${y}px)"
}
override fun focus() {
(firstFocusableChild(dialogElement) ?: dialogElement).focus()
}
private fun firstFocusableChild(parent: HTMLElement): HTMLElement? {
for (i in 0 until parent.children.length) {
val child = parent.children[i]
if (child is HTMLElement) {
if (child.tabIndex >= 0) {
return child
} else {
firstFocusableChild(child)?.let {
return it
}
}
}
}
return null
}
private fun onKeyDown(e: Event) {
e as KeyboardEvent
if (e.key == "Escape") {
onDismiss()
}
}
companion object {
private const val WIDTH = 500
private const val MAX_HEIGHT = 500
init {
@Suppress("CssUnresolvedCustomProperty", "CssUnusedSymbol")
// language=css
style("""
.pw-dialog {
z-index: 20;
display: flex;
flex-direction: column;
outline: none;
position: fixed;
left: 0;
top: 0;
background-color: var(--pw-bg-color);
border: var(--pw-border);
padding: 10px;
box-shadow: black 0 0 10px -2px;
}
.pw-dialog:focus-within {
border: var(--pw-border-focus);
}
.pw-dialog h1 {
font-size: 20px;
margin: 0 0 10px 0;
padding-bottom: 4px;
border-bottom: var(--pw-border);
}
.pw-dialog-description {
user-select: text;
cursor: text;
}
.pw-dialog-body {
flex: 1;
margin: 4px 0;
}
.pw-dialog-footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.pw-dialog-footer > * {
margin-left: 2px;
}
.pw-dialog-modal-overlay {
outline: none;
z-index: 10;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: black;
opacity: 50%;
backdrop-filter: blur(5px);
}
""".trimIndent())
}
}
}

View File

@ -0,0 +1,83 @@
package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.core.Failure
import world.phantasmal.core.PwResult
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.dom
import world.phantasmal.webui.dom.li
import world.phantasmal.webui.dom.ul
/**
* Shows the details of a result if the result failed or succeeded with problems. Shows a "Dismiss"
* button in the footer which triggers [onDismiss].
*/
class ResultDialog(
scope: CoroutineScope,
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
result: Val<PwResult<*>?>,
message: Val<String>,
onDismiss: () -> Unit = {},
) : Dialog(
scope,
visible,
enabled,
title = result.map(::resultToTitle),
description = message,
content = result.map(::resultToContent),
onDismiss,
) {
override fun addFooterContent(footer: Node) {
footer.addChild(Button(
scope,
visible,
enabled,
text = "Dismiss",
onClick = { onDismiss() }
))
}
companion object {
init {
@Suppress("CssUnusedSymbol")
// language=css
style("""
.pw-result-dialog-result {
overflow: auto;
user-select: text;
cursor: text;
height: 100%;
max-height: 400px; /* Workaround for chrome bug. */
}
""".trimIndent())
}
}
}
private fun resultToTitle(result: PwResult<*>?): String =
when {
result is Failure -> "Error"
result?.problems?.isNotEmpty() == true -> "Problems"
else -> ""
}
private fun resultToContent(result: PwResult<*>?): Node =
dom {
div {
className = "pw-result-dialog-result"
result?.let {
ul {
className = "pw-result-dialog-problems"
result.problems.map {
li { textContent = it.uiMessage }
}
}
}
}
}

View File

@ -2,10 +2,7 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.observable.value.value
import world.phantasmal.observable.value.*
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.textarea
@ -17,7 +14,7 @@ class TextArea(
label: String? = null,
labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
private val value: Val<String> = value(""),
private val value: Val<String> = emptyStringVal(),
private val onChange: ((String) -> Unit)? = null,
private val maxLength: Int? = null,
private val fontFamily: String? = null,

View File

@ -2,10 +2,7 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.observable.value.value
import world.phantasmal.observable.value.*
class TextInput(
scope: CoroutineScope,
@ -15,7 +12,7 @@ class TextInput(
label: String? = null,
labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: Val<String> = value(""),
value: Val<String> = emptyStringVal(),
onChange: (String) -> Unit = {},
maxLength: Int? = null,
) : Input<String>(

View File

@ -3,6 +3,7 @@ package world.phantasmal.webui.widgets
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.*
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.observable.Observable
import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.list.ListVal
@ -10,6 +11,8 @@ import world.phantasmal.observable.value.list.ListValChangeEvent
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.HTMLElementSizeVal
import world.phantasmal.webui.dom.Size
import world.phantasmal.webui.dom.disposablePointerDrag
import world.phantasmal.webui.dom.documentFragment
abstract class Widget(
protected val scope: CoroutineScope,
@ -29,7 +32,7 @@ abstract class Widget(
private val _size = HTMLElementSizeVal()
private val elementDelegate = lazy {
val el = document.createDocumentFragment().createElement()
val el = documentFragment().createElement()
observe(visible) { visible ->
el.hidden = !visible
@ -189,6 +192,14 @@ abstract class Widget(
spliceChildren(0, 0, list.value)
}
fun Element.onDrag(
onPointerDown: (e: PointerEvent) -> Boolean,
onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean,
onPointerUp: (e: PointerEvent) -> Unit = {},
) {
addDisposable(disposablePointerDrag(onPointerDown, onPointerMove, onPointerUp))
}
companion object {
private val STYLE_EL by lazy {
val el = document.createElement("style") as HTMLStyleElement