diff --git a/core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt b/core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt index b9fd8d09..a27af3fa 100644 --- a/core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt +++ b/core/src/jsMain/kotlin/world/phantasmal/core/JsExtensions.kt @@ -1,11 +1,12 @@ package world.phantasmal.core +import kotlinx.coroutines.await import world.phantasmal.core.externals.browser.WritableStream -inline fun S.use(block: (S) -> R): R { +suspend inline fun S.use(block: (S) -> R): R { try { return block(this) } finally { - close() + close().await() } } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/files/FilesExtensions.kt b/web/src/main/kotlin/world/phantasmal/web/core/files/FilesExtensions.kt index c4861cad..63ba17aa 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/files/FilesExtensions.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/files/FilesExtensions.kt @@ -1,9 +1,16 @@ package world.phantasmal.web.core.files +import kotlinx.coroutines.await +import world.phantasmal.core.use import world.phantasmal.lib.Endianness +import world.phantasmal.lib.buffer.Buffer import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.cursor import world.phantasmal.webui.files.FileHandle suspend fun FileHandle.cursor(endianness: Endianness): Cursor = arrayBuffer().cursor(endianness) + +suspend fun FileHandle.Fsaa.writeBuffer(buffer: Buffer) { + writableStream().use { it.write(buffer.arrayBuffer).await() } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarController.kt index cac5cccf..e32c8cbb 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarController.kt @@ -1,7 +1,6 @@ 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 @@ -15,6 +14,7 @@ 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.files.writeBuffer import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.questEditor.models.AreaModel import world.phantasmal.web.questEditor.stores.AreaStore @@ -37,10 +37,14 @@ 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?>(null) private val _saveAsDialogVisible = mutableVal(false) + private val saving = mutableVal(false) + + // We mainly disable saving while a save is underway for visual feedback that a save is + // happening/has happened. + private val savingEnabled = questEditorStore.currentQuest.isNotNull() and !saving private val files: MutableListVal = mutableListVal() private val _filename = mutableVal("") private val _version = mutableVal(Version.BB) @@ -59,12 +63,13 @@ class QuestEditorToolbarController( // Save as - val saveEnabled: Val = questLoaded and files.notEmpty and BrowserFeatures.fileSystemApi + val saveEnabled: Val = + savingEnabled and files.notEmpty and BrowserFeatures.fileSystemApi val saveTooltip: Val = value( if (BrowserFeatures.fileSystemApi) "Save changes (Ctrl-S)" else "This browser doesn't support saving to an existing file" ) - val saveAsEnabled: Val = questLoaded + val saveAsEnabled: Val = savingEnabled val saveAsDialogVisible: Val = _saveAsDialogVisible val filename: Val = _filename val version: Val = _version @@ -170,7 +175,7 @@ class QuestEditorToolbarController( if (binFile == null || datFile == null) { setResult(Failure(listOf(Problem( Severity.Error, - "Please select a .qst file or one .bin and one .dat file." + "Please select a .qst file or one .bin and one .dat file.", )))) return } @@ -198,44 +203,46 @@ class QuestEditorToolbarController( } suspend fun save() { - if (saveEnabled.value) { - try { - val quest = questEditorStore.currentQuest.value ?: return - val headerFilename = filename.value.trim() + if (!saveEnabled.value) return - files.value.find { it.extension().equals("qst", ignoreCase = true) } - ?.let { qstFile -> - val buffer = writeQuestToQst( - convertQuestFromModel(quest), - headerFilename, - version.value, - online = true, - ) + try { + saving.value = true - qstFile.writableStream()!!.use { - it.write(buffer.arrayBuffer).await() - } - } + val quest = questEditorStore.currentQuest.value ?: return + val headerFilename = filename.value.trim() + val files = files.value.filterIsInstance() - 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(logger) - .addProblem(Severity.Error, "Couldn't save file.", cause = e) - .failure() + files.find { it.extension().equals("qst", ignoreCase = true) }?.let { qstFile -> + val buffer = writeQuestToQst( + convertQuestFromModel(quest), + headerFilename, + version.value, + online = true, ) + + qstFile.writeBuffer(buffer) } + + val binFile = files.find { it.extension().equals("bin", ignoreCase = true) } + val datFile = files.find { it.extension().equals("dat", ignoreCase = true) } + + if (binFile != null && datFile != null) { + val (bin, dat) = writeQuestToBinDat( + convertQuestFromModel(quest), + version.value, + ) + + binFile.writeBuffer(bin) + datFile.writeBuffer(dat) + } + } catch (e: Throwable) { + setResult( + PwResult.build(logger) + .addProblem(Severity.Error, "Couldn't save file.", cause = e) + .failure() + ) + } finally { + saving.value = false } } @@ -254,32 +261,42 @@ class QuestEditorToolbarController( } fun saveAsDialogSave() { + if (!saveAsEnabled.value) return + val quest = questEditorStore.currentQuest.value ?: return - val headerFilename = filename.value.trim() - val filename = - if (headerFilename.endsWith(".qst")) headerFilename - else "$headerFilename.qst" - - val buffer = writeQuestToQst( - convertQuestFromModel(quest), - headerFilename, - version.value, - online = true, - ) - - val a = document.createElement("a") as HTMLAnchorElement - val url = URL.createObjectURL( - Blob( - arrayOf(buffer.arrayBuffer), - obj { type = "application/octet-stream" }, - ) - ) try { - a.href = url - a.download = filename - document.body?.appendChild(a) - a.click() + saving.value = true + + val headerFilename = filename.value.trim() + val filename = + if (headerFilename.endsWith(".qst")) headerFilename + else "$headerFilename.qst" + + val buffer = writeQuestToQst( + convertQuestFromModel(quest), + headerFilename, + version.value, + online = true, + ) + + val a = document.createElement("a") as HTMLAnchorElement + val url = URL.createObjectURL( + Blob( + arrayOf(buffer.arrayBuffer), + obj { type = "application/octet-stream" }, + ) + ) + + try { + a.href = url + a.download = filename + document.body?.appendChild(a) + a.click() + } finally { + URL.revokeObjectURL(url) + document.body?.removeChild(a) + } } catch (e: Throwable) { setResult( PwResult.build(logger) @@ -287,11 +304,9 @@ class QuestEditorToolbarController( .failure() ) } finally { - URL.revokeObjectURL(url) - document.body?.removeChild(a) + dismissSaveAsDialog() + saving.value = false } - - dismissSaveAsDialog() } fun dismissSaveAsDialog() { diff --git a/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt b/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt index a22f7b78..fc7c8064 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt @@ -18,21 +18,9 @@ import world.phantasmal.webui.BrowserFeatures import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.obj import kotlin.coroutines.resume -import kotlin.js.Promise -@OptIn(ExperimentalCoroutinesApi::class) -class FileHandle private constructor( - private val handle: FileSystemFileHandle?, - private val file: File?, -) { - constructor(file: File) : this(null, file) - constructor(handle: FileSystemFileHandle) : this(handle, null) - - val name: String = handle?.name ?: file!!.name - - init { - require((handle == null) xor (file == null)) - } +sealed class FileHandle { + abstract val name: String /** * Returns the filename without extension if there is one. @@ -44,20 +32,29 @@ 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 = + getFile().arrayBuffer().await() - suspend fun arrayBuffer(): ArrayBuffer = suspendCancellableCoroutine { cont -> - getFile() - .then { it.arrayBuffer() } - .then({ cont.resume(it) {} }, cont::cancel) + protected abstract suspend fun getFile(): File + + /** + * File system access API file handle. + */ + class Fsaa(private val handle: FileSystemFileHandle) : FileHandle() { + override val name: String = handle.name + + suspend fun writableStream(): FileSystemWritableFileStream = + handle.createWritable().await() + + override suspend fun getFile(): File = + handle.getFile().await() } - private fun getFile(): Promise = - handle?.getFile() ?: Promise.resolve(file!!) + class Simple(private val file: File) : FileHandle() { + override val name: String = file.name + + override suspend fun getFile(): File = file + } } class FileType( @@ -86,7 +83,7 @@ suspend fun showFilePicker(types: List, multiple: Boolean = false): Li }.toTypedArray() }).await() - fileHandles.map(::FileHandle) + fileHandles.map(FileHandle::Fsaa) } catch (e: Throwable) { // Ensure we return null when the user cancels. if (e.asDynamic().name == "AbortError") { @@ -103,7 +100,7 @@ suspend fun showFilePicker(types: List, multiple: Boolean = false): Li el.multiple = multiple el.onchange = { - cont.resume(el.files!!.asList().map(::FileHandle)) + cont.resume(el.files!!.asList().map(FileHandle::Simple)) } // Ensure we return null when the user cancels.