"Save" and "Save as..." buttons are now disabled while saving, mainly as visual feedback that a save happened.

This commit is contained in:
Daan Vanden Bosch 2021-04-15 21:40:43 +02:00
parent 4b8241ba80
commit 5133235040
4 changed files with 114 additions and 94 deletions

View File

@ -1,11 +1,12 @@
package world.phantasmal.core package world.phantasmal.core
import kotlinx.coroutines.await
import world.phantasmal.core.externals.browser.WritableStream import world.phantasmal.core.externals.browser.WritableStream
inline fun <S : WritableStream, R> S.use(block: (S) -> R): R { suspend inline fun <S : WritableStream, R> S.use(block: (S) -> R): R {
try { try {
return block(this) return block(this)
} finally { } finally {
close() close().await()
} }
} }

View File

@ -1,9 +1,16 @@
package world.phantasmal.web.core.files package world.phantasmal.web.core.files
import kotlinx.coroutines.await
import world.phantasmal.core.use
import world.phantasmal.lib.Endianness 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.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
import world.phantasmal.webui.files.FileHandle import world.phantasmal.webui.files.FileHandle
suspend fun FileHandle.cursor(endianness: Endianness): Cursor = suspend fun FileHandle.cursor(endianness: Endianness): Cursor =
arrayBuffer().cursor(endianness) arrayBuffer().cursor(endianness)
suspend fun FileHandle.Fsaa.writeBuffer(buffer: Buffer) {
writableStream().use { it.write(buffer.arrayBuffer).await() }
}

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.questEditor.controllers package world.phantasmal.web.questEditor.controllers
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.await
import mu.KotlinLogging import mu.KotlinLogging
import org.w3c.dom.HTMLAnchorElement import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.url.URL 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.observable.value.list.mutableListVal
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.files.cursor 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.core.stores.UiStore
import world.phantasmal.web.questEditor.models.AreaModel import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
@ -37,10 +37,14 @@ class QuestEditorToolbarController(
private val areaStore: AreaStore, private val areaStore: AreaStore,
private val questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
) : Controller() { ) : Controller() {
private val questLoaded = questEditorStore.currentQuest.isNotNull()
private val _resultDialogVisible = mutableVal(false) private val _resultDialogVisible = mutableVal(false)
private val _result = mutableVal<PwResult<*>?>(null) private val _result = mutableVal<PwResult<*>?>(null)
private val _saveAsDialogVisible = mutableVal(false) 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<FileHandle> = mutableListVal() private val files: MutableListVal<FileHandle> = mutableListVal()
private val _filename = mutableVal("") private val _filename = mutableVal("")
private val _version = mutableVal(Version.BB) private val _version = mutableVal(Version.BB)
@ -59,12 +63,13 @@ class QuestEditorToolbarController(
// Save as // Save as
val saveEnabled: Val<Boolean> = questLoaded and files.notEmpty and BrowserFeatures.fileSystemApi val saveEnabled: Val<Boolean> =
savingEnabled and files.notEmpty and BrowserFeatures.fileSystemApi
val saveTooltip: Val<String> = value( val saveTooltip: Val<String> = value(
if (BrowserFeatures.fileSystemApi) "Save changes (Ctrl-S)" if (BrowserFeatures.fileSystemApi) "Save changes (Ctrl-S)"
else "This browser doesn't support saving to an existing file" else "This browser doesn't support saving to an existing file"
) )
val saveAsEnabled: Val<Boolean> = questLoaded val saveAsEnabled: Val<Boolean> = savingEnabled
val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible
val filename: Val<String> = _filename val filename: Val<String> = _filename
val version: Val<Version> = _version val version: Val<Version> = _version
@ -170,7 +175,7 @@ class QuestEditorToolbarController(
if (binFile == null || datFile == null) { if (binFile == null || datFile == null) {
setResult(Failure(listOf(Problem( setResult(Failure(listOf(Problem(
Severity.Error, 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 return
} }
@ -198,44 +203,46 @@ class QuestEditorToolbarController(
} }
suspend fun save() { suspend fun save() {
if (saveEnabled.value) { if (!saveEnabled.value) return
try {
val quest = questEditorStore.currentQuest.value ?: return
val headerFilename = filename.value.trim()
files.value.find { it.extension().equals("qst", ignoreCase = true) } try {
?.let { qstFile -> saving.value = true
val buffer = writeQuestToQst(
convertQuestFromModel(quest),
headerFilename,
version.value,
online = true,
)
qstFile.writableStream()!!.use { val quest = questEditorStore.currentQuest.value ?: return
it.write(buffer.arrayBuffer).await() val headerFilename = filename.value.trim()
} val files = files.value.filterIsInstance<FileHandle.Fsaa>()
}
val binFile = files.value.find { it.extension().equals("bin", ignoreCase = true) } files.find { it.extension().equals("qst", ignoreCase = true) }?.let { qstFile ->
val datFile = files.value.find { it.extension().equals("dat", ignoreCase = true) } val buffer = writeQuestToQst(
convertQuestFromModel(quest),
if (binFile != null && datFile != null) { headerFilename,
val (bin, dat) = writeQuestToBinDat( version.value,
convertQuestFromModel(quest), online = true,
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()
) )
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<Nothing>(logger)
.addProblem(Severity.Error, "Couldn't save file.", cause = e)
.failure()
)
} finally {
saving.value = false
} }
} }
@ -254,32 +261,42 @@ class QuestEditorToolbarController(
} }
fun saveAsDialogSave() { fun saveAsDialogSave() {
if (!saveAsEnabled.value) return
val quest = questEditorStore.currentQuest.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 { try {
a.href = url saving.value = true
a.download = filename
document.body?.appendChild(a) val headerFilename = filename.value.trim()
a.click() 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) { } catch (e: Throwable) {
setResult( setResult(
PwResult.build<Nothing>(logger) PwResult.build<Nothing>(logger)
@ -287,11 +304,9 @@ class QuestEditorToolbarController(
.failure() .failure()
) )
} finally { } finally {
URL.revokeObjectURL(url) dismissSaveAsDialog()
document.body?.removeChild(a) saving.value = false
} }
dismissSaveAsDialog()
} }
fun dismissSaveAsDialog() { fun dismissSaveAsDialog() {

View File

@ -18,21 +18,9 @@ import world.phantasmal.webui.BrowserFeatures
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.obj import world.phantasmal.webui.obj
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.js.Promise
@OptIn(ExperimentalCoroutinesApi::class) sealed class FileHandle {
class FileHandle private constructor( abstract val name: String
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))
}
/** /**
* Returns the filename without extension if there is one. * Returns the filename without extension if there is one.
@ -44,20 +32,29 @@ class FileHandle private constructor(
*/ */
fun extension(): String? = filenameExtension(name) fun extension(): String? = filenameExtension(name)
/** suspend fun arrayBuffer(): ArrayBuffer =
* Returns a writable stream if this [FileHandle] represents a [FileSystemFileHandle]. getFile().arrayBuffer().await()
*/
suspend fun writableStream(): FileSystemWritableFileStream? =
handle?.createWritable()?.await()
suspend fun arrayBuffer(): ArrayBuffer = suspendCancellableCoroutine { cont -> protected abstract suspend fun getFile(): File
getFile()
.then { it.arrayBuffer() } /**
.then({ cont.resume(it) {} }, cont::cancel) * 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<File> = class Simple(private val file: File) : FileHandle() {
handle?.getFile() ?: Promise.resolve(file!!) override val name: String = file.name
override suspend fun getFile(): File = file
}
} }
class FileType( class FileType(
@ -86,7 +83,7 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
}.toTypedArray() }.toTypedArray()
}).await() }).await()
fileHandles.map(::FileHandle) fileHandles.map(FileHandle::Fsaa)
} catch (e: Throwable) { } catch (e: Throwable) {
// Ensure we return null when the user cancels. // Ensure we return null when the user cancels.
if (e.asDynamic().name == "AbortError") { if (e.asDynamic().name == "AbortError") {
@ -103,7 +100,7 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
el.multiple = multiple el.multiple = multiple
el.onchange = { 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. // Ensure we return null when the user cancels.