mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
Existing files can now be saved directly with the "Save" button or Ctrl-S.
This commit is contained in:
parent
329e067a17
commit
4b8241ba80
11
core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt
Normal file
11
core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt
Normal file
@ -0,0 +1,11 @@
|
||||
package world.phantasmal.core
|
||||
|
||||
import world.phantasmal.core.externals.browser.WritableStream
|
||||
|
||||
inline fun <S : WritableStream, R> S.use(block: (S) -> R): R {
|
||||
try {
|
||||
return block(this)
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
package world.phantasmal.webui.externals.browser
|
||||
@file:Suppress("unused")
|
||||
|
||||
package world.phantasmal.core.externals.browser
|
||||
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.khronos.webgl.BufferDataSource
|
||||
import org.w3c.dom.Window
|
||||
import org.w3c.files.Blob
|
||||
import org.w3c.files.File
|
||||
@ -16,6 +19,30 @@ open external class FileSystemHandle {
|
||||
|
||||
external class FileSystemFileHandle : FileSystemHandle {
|
||||
fun getFile(): Promise<File>
|
||||
|
||||
fun createWritable(): Promise<FileSystemWritableFileStream>
|
||||
}
|
||||
|
||||
open external class WritableStream {
|
||||
val locked: Boolean
|
||||
|
||||
fun abort(reason: Any): Promise<Any>
|
||||
|
||||
fun close(): Promise<Unit>
|
||||
}
|
||||
|
||||
external interface FileSystemWritableFileStreamData {
|
||||
var type: String /* "write" | "seek" | "truncate" */
|
||||
var data: dynamic /* BufferDataSource | Blob | String */
|
||||
var position: Int
|
||||
var size: Int
|
||||
}
|
||||
|
||||
external class FileSystemWritableFileStream : WritableStream {
|
||||
fun write(data: BufferDataSource): Promise<Unit>
|
||||
fun write(data: FileSystemWritableFileStreamData): Promise<Unit>
|
||||
|
||||
fun seek(position: Int): Promise<Unit>
|
||||
}
|
||||
|
||||
external interface ShowOpenFilePickerOptionsType {
|
@ -240,9 +240,9 @@ private fun extractScriptEntryPoints(
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a .qst file from [quest].
|
||||
* Returns a .bin and .dat file in that order.
|
||||
*/
|
||||
fun writeQuestToQst(quest: Quest, filename: String, version: Version, online: Boolean): Buffer {
|
||||
fun writeQuestToBinDat(quest: Quest, version: Version): Pair<Buffer, Buffer> {
|
||||
val dat = writeDat(DatFile(
|
||||
objs = quest.objects.mapTo(mutableListOf()) { DatEntity(it.areaId, it.data) },
|
||||
npcs = quest.npcs.mapTo(mutableListOf()) { DatEntity(it.areaId, it.data) },
|
||||
@ -270,6 +270,15 @@ fun writeQuestToQst(quest: Quest, filename: String, version: Version, online: Bo
|
||||
quest.shopItems,
|
||||
))
|
||||
|
||||
return Pair(bin, dat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a .qst file from [quest].
|
||||
*/
|
||||
fun writeQuestToQst(quest: Quest, filename: String, version: Version, online: Boolean): Buffer {
|
||||
val (bin, dat) = writeQuestToBinDat(quest, version)
|
||||
|
||||
val baseFilename = (filenameBase(filename) ?: filename).take(11)
|
||||
val questName = quest.name.take(if (version == Version.BB) 23 else 31)
|
||||
|
||||
|
@ -30,6 +30,9 @@ infix fun <T : Comparable<T>> Val<T>.lt(value: Val<T>): Val<Boolean> =
|
||||
infix fun Val<Boolean>.and(other: Val<Boolean>): Val<Boolean> =
|
||||
map(this, other) { a, b -> a && b }
|
||||
|
||||
infix fun Val<Boolean>.and(other: Boolean): Val<Boolean> =
|
||||
if (other) this else falseVal()
|
||||
|
||||
infix fun Val<Boolean>.or(other: Val<Boolean>): Val<Boolean> =
|
||||
map(this, other) { a, b -> a || b }
|
||||
|
||||
|
@ -9,6 +9,7 @@ import world.phantasmal.observable.Observer
|
||||
import world.phantasmal.observable.value.AbstractVal
|
||||
import world.phantasmal.observable.value.DependentVal
|
||||
import world.phantasmal.observable.value.Val
|
||||
import world.phantasmal.observable.value.not
|
||||
|
||||
abstract class AbstractListVal<E>(
|
||||
private val extractObservables: ObservablesExtractor<E>?,
|
||||
@ -24,6 +25,10 @@ abstract class AbstractListVal<E>(
|
||||
*/
|
||||
protected val listObservers = mutableListOf<ListValObserver<E>>()
|
||||
|
||||
override val empty: Val<Boolean> by lazy { size.map { it == 0 } }
|
||||
|
||||
override val notEmpty: Val<Boolean> by lazy { !empty }
|
||||
|
||||
override fun get(index: Int): E =
|
||||
value[index]
|
||||
|
||||
|
@ -12,6 +12,10 @@ interface ListVal<out E> : Val<List<E>> {
|
||||
|
||||
val size: Val<Int>
|
||||
|
||||
val empty: Val<Boolean>
|
||||
|
||||
val notEmpty: Val<Boolean>
|
||||
|
||||
operator fun get(index: Int): E
|
||||
|
||||
fun observeList(callNow: Boolean = false, observer: ListValObserver<E>): Disposable
|
||||
|
@ -4,14 +4,14 @@ import world.phantasmal.core.disposable.Disposable
|
||||
import world.phantasmal.core.disposable.stubDisposable
|
||||
import world.phantasmal.observable.ChangeEvent
|
||||
import world.phantasmal.observable.Observer
|
||||
import world.phantasmal.observable.value.StaticVal
|
||||
import world.phantasmal.observable.value.Val
|
||||
import world.phantasmal.observable.value.value
|
||||
import world.phantasmal.observable.value.*
|
||||
|
||||
class StaticListVal<E>(private val elements: List<E>) : ListVal<E> {
|
||||
private val firstOrNull = StaticVal(elements.firstOrNull())
|
||||
|
||||
override val size: Val<Int> = value(elements.size)
|
||||
override val empty: Val<Boolean> = if (elements.isEmpty()) trueVal() else falseVal()
|
||||
override val notEmpty: Val<Boolean> = if (elements.isNotEmpty()) trueVal() else falseVal()
|
||||
|
||||
override val value: List<E> = elements
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
package world.phantasmal.web.questEditor.controllers
|
||||
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.coroutines.await
|
||||
import mu.KotlinLogging
|
||||
import org.w3c.dom.HTMLAnchorElement
|
||||
import org.w3c.dom.url.URL
|
||||
@ -9,10 +10,9 @@ import world.phantasmal.core.*
|
||||
import world.phantasmal.lib.Endianness
|
||||
import world.phantasmal.lib.Episode
|
||||
import world.phantasmal.lib.fileFormats.quest.*
|
||||
import world.phantasmal.observable.value.Val
|
||||
import world.phantasmal.observable.value.map
|
||||
import world.phantasmal.observable.value.mutableVal
|
||||
import world.phantasmal.observable.value.value
|
||||
import world.phantasmal.observable.value.*
|
||||
import world.phantasmal.observable.value.list.MutableListVal
|
||||
import world.phantasmal.observable.value.list.mutableListVal
|
||||
import world.phantasmal.web.core.PwToolType
|
||||
import world.phantasmal.web.core.files.cursor
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
@ -21,6 +21,7 @@ import world.phantasmal.web.questEditor.stores.AreaStore
|
||||
import world.phantasmal.web.questEditor.stores.QuestEditorStore
|
||||
import world.phantasmal.web.questEditor.stores.convertQuestFromModel
|
||||
import world.phantasmal.web.questEditor.stores.convertQuestToModel
|
||||
import world.phantasmal.webui.BrowserFeatures
|
||||
import world.phantasmal.webui.controllers.Controller
|
||||
import world.phantasmal.webui.files.FileHandle
|
||||
import world.phantasmal.webui.files.FileType
|
||||
@ -36,9 +37,11 @@ class QuestEditorToolbarController(
|
||||
private val areaStore: AreaStore,
|
||||
private val questEditorStore: QuestEditorStore,
|
||||
) : Controller() {
|
||||
private val questLoaded = questEditorStore.currentQuest.isNotNull()
|
||||
private val _resultDialogVisible = mutableVal(false)
|
||||
private val _result = mutableVal<PwResult<*>?>(null)
|
||||
private val _saveAsDialogVisible = mutableVal(false)
|
||||
private val files: MutableListVal<FileHandle> = mutableListVal()
|
||||
private val _filename = mutableVal("")
|
||||
private val _version = mutableVal(Version.BB)
|
||||
|
||||
@ -56,7 +59,12 @@ class QuestEditorToolbarController(
|
||||
|
||||
// Save as
|
||||
|
||||
val saveAsEnabled: Val<Boolean> = questEditorStore.currentQuest.isNotNull()
|
||||
val saveEnabled: Val<Boolean> = questLoaded and files.notEmpty and BrowserFeatures.fileSystemApi
|
||||
val saveTooltip: Val<String> = value(
|
||||
if (BrowserFeatures.fileSystemApi) "Save changes (Ctrl-S)"
|
||||
else "This browser doesn't support saving to an existing file"
|
||||
)
|
||||
val saveAsEnabled: Val<Boolean> = questLoaded
|
||||
val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible
|
||||
val filename: Val<String> = _filename
|
||||
val version: Val<Version> = _version
|
||||
@ -109,6 +117,10 @@ class QuestEditorToolbarController(
|
||||
openFiles(showFilePicker(supportedFileTypes, multiple = true))
|
||||
},
|
||||
|
||||
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-S") {
|
||||
save()
|
||||
},
|
||||
|
||||
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-Shift-S") {
|
||||
saveAs()
|
||||
},
|
||||
@ -133,11 +145,13 @@ class QuestEditorToolbarController(
|
||||
questEditorStore.setDefaultQuest(episode)
|
||||
}
|
||||
|
||||
suspend fun openFiles(files: List<FileHandle>?) {
|
||||
suspend fun openFiles(newFiles: List<FileHandle>?) {
|
||||
try {
|
||||
if (files.isNullOrEmpty()) return
|
||||
files.clear()
|
||||
|
||||
val qstFile = files.find { it.extension().equals("qst", ignoreCase = true) }
|
||||
if (newFiles.isNullOrEmpty()) return
|
||||
|
||||
val qstFile = newFiles.find { it.extension().equals("qst", ignoreCase = true) }
|
||||
|
||||
if (qstFile != null) {
|
||||
val parseResult = parseQstToQuest(qstFile.cursor(Endianness.Little))
|
||||
@ -147,10 +161,11 @@ class QuestEditorToolbarController(
|
||||
setFilename(filenameBase(qstFile.name) ?: qstFile.name)
|
||||
setVersion(parseResult.value.version)
|
||||
setCurrentQuest(parseResult.value.quest)
|
||||
files.replaceAll(listOf(qstFile))
|
||||
}
|
||||
} else {
|
||||
val binFile = files.find { it.extension().equals("bin", ignoreCase = true) }
|
||||
val datFile = files.find { it.extension().equals("dat", ignoreCase = true) }
|
||||
val binFile = newFiles.find { it.extension().equals("bin", ignoreCase = true) }
|
||||
val datFile = newFiles.find { it.extension().equals("dat", ignoreCase = true) }
|
||||
|
||||
if (binFile == null || datFile == null) {
|
||||
setResult(Failure(listOf(Problem(
|
||||
@ -170,9 +185,10 @@ class QuestEditorToolbarController(
|
||||
setFilename(binFile.basename() ?: datFile.basename() ?: binFile.name)
|
||||
setVersion(Version.BB)
|
||||
setCurrentQuest(parseResult.value)
|
||||
files.replaceAll(listOf(binFile, datFile))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
setResult(
|
||||
PwResult.build<Nothing>(logger)
|
||||
.addProblem(Severity.Error, "Couldn't parse file.", cause = e)
|
||||
@ -181,6 +197,48 @@ class QuestEditorToolbarController(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save() {
|
||||
if (saveEnabled.value) {
|
||||
try {
|
||||
val quest = questEditorStore.currentQuest.value ?: return
|
||||
val headerFilename = filename.value.trim()
|
||||
|
||||
files.value.find { it.extension().equals("qst", ignoreCase = true) }
|
||||
?.let { qstFile ->
|
||||
val buffer = writeQuestToQst(
|
||||
convertQuestFromModel(quest),
|
||||
headerFilename,
|
||||
version.value,
|
||||
online = true,
|
||||
)
|
||||
|
||||
qstFile.writableStream()!!.use {
|
||||
it.write(buffer.arrayBuffer).await()
|
||||
}
|
||||
}
|
||||
|
||||
val binFile = files.value.find { it.extension().equals("bin", ignoreCase = true) }
|
||||
val datFile = files.value.find { it.extension().equals("dat", ignoreCase = true) }
|
||||
|
||||
if (binFile != null && datFile != null) {
|
||||
val (bin, dat) = writeQuestToBinDat(
|
||||
convertQuestFromModel(quest),
|
||||
version.value,
|
||||
)
|
||||
|
||||
binFile.writableStream()!!.use { it.write(bin.arrayBuffer).await() }
|
||||
datFile.writableStream()!!.use { it.write(dat.arrayBuffer).await() }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
setResult(
|
||||
PwResult.build<Nothing>(logger)
|
||||
.addProblem(Severity.Error, "Couldn't save file.", cause = e)
|
||||
.failure()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAs() {
|
||||
if (saveAsEnabled.value) {
|
||||
_saveAsDialogVisible.value = true
|
||||
@ -197,19 +255,18 @@ class QuestEditorToolbarController(
|
||||
|
||||
fun saveAsDialogSave() {
|
||||
val quest = questEditorStore.currentQuest.value ?: return
|
||||
var filename = filename.value.trim()
|
||||
val headerFilename = filename.value.trim()
|
||||
val filename =
|
||||
if (headerFilename.endsWith(".qst")) headerFilename
|
||||
else "$headerFilename.qst"
|
||||
|
||||
val buffer = writeQuestToQst(
|
||||
convertQuestFromModel(quest),
|
||||
filename,
|
||||
headerFilename,
|
||||
version.value,
|
||||
online = true,
|
||||
)
|
||||
|
||||
if (!filename.endsWith(".qst")) {
|
||||
filename += ".qst"
|
||||
}
|
||||
|
||||
val a = document.createElement("a") as HTMLAnchorElement
|
||||
val url = URL.createObjectURL(
|
||||
Blob(
|
||||
@ -223,8 +280,12 @@ class QuestEditorToolbarController(
|
||||
a.download = filename
|
||||
document.body?.appendChild(a)
|
||||
a.click()
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { """Couldn't save file "$filename".""" }
|
||||
} catch (e: Throwable) {
|
||||
setResult(
|
||||
PwResult.build<Nothing>(logger)
|
||||
.addProblem(Severity.Error, "Couldn't save file.", cause = e)
|
||||
.failure()
|
||||
)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
document.body?.removeChild(a)
|
||||
|
@ -34,6 +34,13 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
|
||||
multiple = true,
|
||||
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
|
||||
),
|
||||
Button(
|
||||
text = "Save",
|
||||
iconLeft = Icon.Save,
|
||||
enabled = ctrl.saveEnabled,
|
||||
tooltip = ctrl.saveTooltip,
|
||||
onClick = { scope.launch { ctrl.save() } },
|
||||
),
|
||||
Button(
|
||||
text = "Save as...",
|
||||
iconLeft = Icon.Save,
|
||||
|
@ -3,6 +3,7 @@ package world.phantasmal.webui.files
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.await
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.w3c.dom.HTMLInputElement
|
||||
@ -10,15 +11,13 @@ import org.w3c.dom.asList
|
||||
import org.w3c.dom.events.Event
|
||||
import org.w3c.files.File
|
||||
import world.phantasmal.core.disposable.Disposable
|
||||
import world.phantasmal.core.externals.browser.*
|
||||
import world.phantasmal.core.filenameBase
|
||||
import world.phantasmal.core.filenameExtension
|
||||
import world.phantasmal.webui.BrowserFeatures
|
||||
import world.phantasmal.webui.dom.disposableListener
|
||||
import world.phantasmal.webui.externals.browser.FileSystemFileHandle
|
||||
import world.phantasmal.webui.externals.browser.ShowOpenFilePickerOptionsType
|
||||
import world.phantasmal.webui.externals.browser.arrayBuffer
|
||||
import world.phantasmal.webui.externals.browser.showOpenFilePicker
|
||||
import world.phantasmal.webui.obj
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.js.Promise
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ -45,6 +44,12 @@ class FileHandle private constructor(
|
||||
*/
|
||||
fun extension(): String? = filenameExtension(name)
|
||||
|
||||
/**
|
||||
* Returns a writable stream if this [FileHandle] represents a [FileSystemFileHandle].
|
||||
*/
|
||||
suspend fun writableStream(): FileSystemWritableFileStream? =
|
||||
handle?.createWritable()?.await()
|
||||
|
||||
suspend fun arrayBuffer(): ArrayBuffer = suspendCancellableCoroutine { cont ->
|
||||
getFile()
|
||||
.then { it.arrayBuffer() }
|
||||
@ -65,9 +70,9 @@ class FileType(
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): List<FileHandle>? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
if (BrowserFeatures.fileSystemApi) {
|
||||
window.showOpenFilePicker(obj {
|
||||
try {
|
||||
val fileHandles = window.showOpenFilePicker(obj {
|
||||
this.multiple = multiple
|
||||
this.types = types.map {
|
||||
obj<ShowOpenFilePickerOptionsType> {
|
||||
@ -79,17 +84,29 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
|
||||
}
|
||||
}
|
||||
}.toTypedArray()
|
||||
}).then({ cont.resume(it.map(::FileHandle)) {} }, { cont.resume(null) {} })
|
||||
}).await()
|
||||
|
||||
fileHandles.map(::FileHandle)
|
||||
} catch (e: Throwable) {
|
||||
// Ensure we return null when the user cancels.
|
||||
if (e.asDynamic().name == "AbortError") {
|
||||
null
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} else {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val el = document.createElement("input") as HTMLInputElement
|
||||
el.type = "file"
|
||||
el.accept = types.flatMap { it.accept.values.flatten() }.joinToString()
|
||||
el.multiple = multiple
|
||||
|
||||
el.onchange = {
|
||||
cont.resume(el.files?.asList()?.map(::FileHandle) ?: emptyList()) {}
|
||||
cont.resume(el.files!!.asList().map(::FileHandle))
|
||||
}
|
||||
|
||||
// Ensure we return null when the user cancels.
|
||||
@Suppress("JoinDeclarationAndAssignment")
|
||||
lateinit var focusListener: Disposable
|
||||
|
||||
@ -98,7 +115,7 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
|
||||
|
||||
window.setTimeout({
|
||||
if (cont.isActive) {
|
||||
cont.resume(null) {}
|
||||
cont.resume(null)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user