"Save" now works as expected after "Saving as".

This commit is contained in:
Daan Vanden Bosch 2021-04-16 20:47:02 +02:00
parent a823e96f68
commit 0112281b1a
8 changed files with 210 additions and 107 deletions

View File

@ -45,18 +45,28 @@ external class FileSystemWritableFileStream : WritableStream {
fun seek(position: Int): Promise<Unit>
}
external interface ShowOpenFilePickerOptionsType {
external interface ShowFilePickerOptionsType {
var description: String
var accept: dynamic
}
external interface ShowOpenFilePickerOptions {
var multiple: Boolean
external interface ShowFilePickerOptions {
var excludeAcceptAllOption: Boolean
var types: Array<ShowOpenFilePickerOptionsType>
var types: Array<ShowFilePickerOptionsType>
}
external interface ShowOpenFilePickerOptions : ShowFilePickerOptions {
var multiple: Boolean
}
fun Window.showOpenFilePicker(
options: ShowOpenFilePickerOptions,
): Promise<Array<FileSystemFileHandle>> =
asDynamic().showOpenFilePicker(options).unsafeCast<Promise<Array<FileSystemFileHandle>>>()
external interface ShowSaveFilePickerOptions : ShowFilePickerOptions
fun Window.showSaveFilePicker(
options: ShowSaveFilePickerOptions,
): Promise<FileSystemFileHandle> =
asDynamic().showSaveFilePicker(options).unsafeCast<Promise<FileSystemFileHandle>>()

View File

@ -11,6 +11,6 @@ import world.phantasmal.webui.files.FileHandle
suspend fun FileHandle.cursor(endianness: Endianness): Cursor =
arrayBuffer().cursor(endianness)
suspend fun FileHandle.Fsaa.writeBuffer(buffer: Buffer) {
suspend fun FileHandle.System.writeBuffer(buffer: Buffer) {
writableStream().use { it.write(buffer.arrayBuffer).await() }
}

View File

@ -1,32 +1,25 @@
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
import org.w3c.files.Blob
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.*
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.models.QuestModel
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.UserAgentFeatures
import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.files.FileHandle
import world.phantasmal.webui.files.FileType
import world.phantasmal.webui.files.showFilePicker
import world.phantasmal.webui.obj
import world.phantasmal.webui.files.*
private val logger = KotlinLogging.logger {}
@ -45,7 +38,7 @@ class QuestEditorToolbarController(
// happening/has happened.
private val savingEnabled = questEditorStore.currentQuest.isNotNull() and !saving
private val _saveAsDialogVisible = mutableVal(false)
private val files: MutableListVal<FileHandle> = mutableListVal()
private val fileHolder = mutableVal<FileHolder?>(null)
private val _filename = mutableVal("")
private val _version = mutableVal(Version.BB)
@ -64,17 +57,18 @@ class QuestEditorToolbarController(
// Saving
val saveEnabled: Val<Boolean> =
and(
savingEnabled,
questEditorStore.canSaveChanges,
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"
)
savingEnabled and questEditorStore.canSaveChanges and UserAgentFeatures.fileSystemApi
val saveTooltip: Val<String> =
if (UserAgentFeatures.fileSystemApi) {
questEditorStore.canSaveChanges.map {
(if (it) "Save changes" else "No changes to save") + " (Ctrl-S)"
}
} else {
value("This browser doesn't support saving changes to existing files")
}
val saveAsEnabled: Val<Boolean> = savingEnabled
val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible
val showSaveAsDialogNameField: Boolean = !UserAgentFeatures.fileSystemApi
val filename: Val<String> = _filename
val version: Val<Version> = _version
@ -123,7 +117,7 @@ class QuestEditorToolbarController(
init {
addDisposables(
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-O") {
openFiles(showFilePicker(supportedFileTypes, multiple = true))
openFiles(showOpenFilePicker(supportedFileTypes, multiple = true))
},
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-S") {
@ -149,15 +143,11 @@ class QuestEditorToolbarController(
}
suspend fun createNewQuest(episode: Episode) {
setFilename("")
setVersion(Version.BB)
questEditorStore.setDefaultQuest(episode)
setCurrentQuest(fileHolder = null, Version.BB, questEditorStore.getDefaultQuest(episode))
}
suspend fun openFiles(newFiles: List<FileHandle>?) {
try {
files.clear()
if (newFiles.isNullOrEmpty()) return
val qstFile = newFiles.find { it.extension().equals("qst", ignoreCase = true) }
@ -167,10 +157,11 @@ class QuestEditorToolbarController(
setResult(parseResult)
if (parseResult is Success) {
setFilename(filenameBase(qstFile.name) ?: qstFile.name)
setVersion(parseResult.value.version)
setCurrentQuest(parseResult.value.quest)
files.replaceAll(listOf(qstFile))
setCurrentQuest(
FileHolder.Qst(qstFile),
parseResult.value.version,
parseResult.value.quest,
)
}
} else {
val binFile = newFiles.find { it.extension().equals("bin", ignoreCase = true) }
@ -191,10 +182,11 @@ class QuestEditorToolbarController(
setResult(parseResult)
if (parseResult is Success) {
setFilename(binFile.basename() ?: datFile.basename() ?: binFile.name)
setVersion(Version.BB)
setCurrentQuest(parseResult.value)
files.replaceAll(listOf(binFile, datFile))
setCurrentQuest(
FileHolder.BinDat(binFile, datFile),
Version.BB,
parseResult.value,
)
}
}
} catch (e: Throwable) {
@ -214,9 +206,10 @@ class QuestEditorToolbarController(
val quest = questEditorStore.currentQuest.value ?: return
val headerFilename = filename.value.trim()
val files = files.value.filterIsInstance<FileHandle.Fsaa>()
files.find { it.extension().equals("qst", ignoreCase = true) }?.let { qstFile ->
when (val holder = fileHolder.value) {
is FileHolder.Qst -> {
if (holder.file is FileHandle.System) {
val buffer = writeQuestToQst(
convertQuestFromModel(quest),
headerFilename,
@ -224,23 +217,33 @@ class QuestEditorToolbarController(
online = true,
)
qstFile.writeBuffer(buffer)
holder.file.writeBuffer(buffer)
questEditorStore.questSaved()
return
}
}
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) {
is FileHolder.BinDat -> {
if (holder.binFile is FileHandle.System &&
holder.datFile is FileHandle.System
) {
val (bin, dat) = writeQuestToBinDat(
convertQuestFromModel(quest),
version.value,
)
binFile.writeBuffer(bin)
datFile.writeBuffer(dat)
}
holder.binFile.writeBuffer(bin)
holder.datFile.writeBuffer(dat)
questEditorStore.questSaved()
return
}
}
}
// When there's no existing file that can be saved, default to "Save as...".
_saveAsDialogVisible.value = true
} catch (e: Throwable) {
setResult(
PwResult.build<Nothing>(logger)
@ -266,7 +269,7 @@ class QuestEditorToolbarController(
_version.value = version
}
fun saveAsDialogSave() {
suspend fun saveAsDialogSave() {
if (!saveAsEnabled.value) return
val quest = questEditorStore.currentQuest.value ?: return
@ -286,25 +289,22 @@ class QuestEditorToolbarController(
online = true,
)
val a = document.createElement("a") as HTMLAnchorElement
val url = URL.createObjectURL(
Blob(
arrayOf(buffer.arrayBuffer),
obj { type = "application/octet-stream" },
)
)
if (UserAgentFeatures.fileSystemApi) {
val fileHandle = showSaveFilePicker(listOf(
FileType("Quest file", mapOf("application/pw-quest" to setOf(".qst")))
))
try {
a.href = url
a.download = filename
document.body?.appendChild(a)
a.click()
} finally {
URL.revokeObjectURL(url)
document.body?.removeChild(a)
}
if (fileHandle != null) {
fileHandle.writableStream().use { it.write(buffer.arrayBuffer).await() }
setFileHolder(FileHolder.Qst(fileHandle))
questEditorStore.questSaved()
}
} else {
val fileHandle = downloadFile(buffer.arrayBuffer, filename)
setFileHolder(FileHolder.Qst(fileHandle))
questEditorStore.questSaved()
}
} catch (e: Throwable) {
setResult(
PwResult.build<Nothing>(logger)
@ -341,8 +341,36 @@ class QuestEditorToolbarController(
questEditorStore.setShowCollisionGeometry(show)
}
private suspend fun setCurrentQuest(quest: Quest) {
questEditorStore.setCurrentQuest(convertQuestToModel(quest, areaStore::getVariant))
private fun setFileHolder(fileHolder: FileHolder?) {
setFilename(when (fileHolder) {
is FileHolder.Qst -> fileHolder.file.basename() ?: fileHolder.file.name
is FileHolder.BinDat ->
fileHolder.binFile.basename()
?: fileHolder.datFile.basename()
?: fileHolder.binFile.name
null -> ""
})
this.fileHolder.value = fileHolder
}
private suspend fun setCurrentQuest(
fileHolder: FileHolder?,
version: Version,
quest: QuestModel,
) {
setFileHolder(fileHolder)
setVersion(version)
questEditorStore.setCurrentQuest(quest)
}
private suspend fun setCurrentQuest(
fileHolder: FileHolder?,
version: Version,
quest: Quest,
) {
setCurrentQuest(fileHolder, version, convertQuestToModel(quest, areaStore::getVariant))
}
private fun setResult(result: PwResult<*>) {
@ -352,4 +380,9 @@ class QuestEditorToolbarController(
_resultDialogVisible.value = true
}
}
private sealed class FileHolder {
class Qst(val file: FileHandle) : FileHolder()
class BinDat(val binFile: FileHandle, val datFile: FileHandle) : FileHolder()
}
}

View File

@ -58,6 +58,10 @@ class QuestEditorStore(
val firstUndo: Val<Action?> = undoManager.firstUndo
val canRedo: Val<Boolean> = questEditingEnabled and undoManager.canRedo
val firstRedo: Val<Action?> = undoManager.firstRedo
/**
* True if there have been changes since the last save.
*/
val canSaveChanges: Val<Boolean> = !undoManager.allAtSavePoint
val showCollisionGeometry: Val<Boolean> = _showCollisionGeometry
@ -93,7 +97,7 @@ class QuestEditorStore(
}
}
scope.launch { setDefaultQuest(Episode.I) }
scope.launch { setCurrentQuest(getDefaultQuest(Episode.I)) }
}
override fun dispose() {
@ -143,11 +147,8 @@ class QuestEditorStore(
}
}
suspend fun setDefaultQuest(episode: Episode) {
setCurrentQuest(
suspend fun getDefaultQuest(episode: Episode): QuestModel =
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
)
}
private fun setSectionOnQuestEntities(
entities: List<QuestEntityModel<*, *>>,

View File

@ -87,6 +87,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
div {
className = "pw-quest-editor-toolbar-save-as"
if (ctrl.showSaveAsDialogNameField) {
val filenameInput = TextInput(
label = "File name:",
value = ctrl.filename,
@ -94,6 +95,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
)
addWidget(filenameInput.label!!)
addWidget(filenameInput)
}
val versionSelect = Select(
label = "Version:",
@ -116,7 +118,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
footer = {
addWidget(Button(
text = "Save",
onClick = { ctrl.saveAsDialogSave() },
onClick = { scope.launch { ctrl.saveAsDialogSave() } },
))
addWidget(Button(
text = "Cancel",
@ -128,7 +130,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
saveAsDialog.dialogElement.addEventListener("keydown", { e ->
if ((e as KeyboardEvent).key == "Enter") {
ctrl.saveAsDialogSave()
scope.launch { ctrl.saveAsDialogSave() }
}
})

View File

@ -4,7 +4,7 @@ import kotlinx.browser.window
import org.w3c.files.File
import world.phantasmal.core.filenameExtension
object BrowserFeatures {
object UserAgentFeatures {
val fileSystemApi: Boolean = window.asDynamic().showOpenFilePicker != null
}

View File

@ -6,15 +6,18 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.await
import kotlinx.coroutines.suspendCancellableCoroutine
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.asList
import org.w3c.dom.events.Event
import org.w3c.dom.url.URL
import org.w3c.files.Blob
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.UserAgentFeatures
import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.obj
import kotlin.coroutines.resume
@ -40,7 +43,7 @@ sealed class FileHandle {
/**
* File system access API file handle.
*/
class Fsaa(private val handle: FileSystemFileHandle) : FileHandle() {
class System(private val handle: FileSystemFileHandle) : FileHandle() {
override val name: String = handle.name
suspend fun writableStream(): FileSystemWritableFileStream =
@ -66,13 +69,16 @@ class FileType(
)
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): List<FileHandle>? =
if (BrowserFeatures.fileSystemApi) {
suspend fun showOpenFilePicker(
types: List<FileType>,
multiple: Boolean = false,
): List<FileHandle>? =
if (UserAgentFeatures.fileSystemApi) {
try {
val fileHandles = window.showOpenFilePicker(obj {
this.multiple = multiple
this.types = types.map {
obj<ShowOpenFilePickerOptionsType> {
obj<ShowFilePickerOptionsType> {
description = it.description
accept = obj {
for ((mimeType, extensions) in it.accept) {
@ -83,7 +89,7 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
}.toTypedArray()
}).await()
fileHandles.map(FileHandle::Fsaa)
fileHandles.map(FileHandle::System)
} catch (e: Throwable) {
// Ensure we return null when the user cancels.
if (e.asDynamic().name == "AbortError") {
@ -120,3 +126,54 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
el.click()
}
}
suspend fun showSaveFilePicker(types: List<FileType>): FileHandle.System? {
require(UserAgentFeatures.fileSystemApi) {
"Save file picker is not supported by this user agent."
}
try {
val fileHandle = window.showSaveFilePicker(obj {
this.types = types.map {
obj<ShowFilePickerOptionsType> {
description = it.description
accept = obj {
for ((mimeType, extensions) in it.accept) {
this[mimeType] = extensions.toTypedArray()
}
}
}
}.toTypedArray()
}).await()
return FileHandle.System(fileHandle)
} catch (e: Throwable) {
// Ensure we return null when the user cancels.
if (e.asDynamic().name == "AbortError") {
return null
} else {
throw e
}
}
}
fun downloadFile(data: ArrayBuffer, filename: String): FileHandle.Simple {
val a = document.createElement("a") as HTMLAnchorElement
val blob = Blob(
arrayOf(data),
obj { type = "application/octet-stream" },
)
val url = URL.createObjectURL(blob)
try {
a.href = url
a.download = filename
document.body?.appendChild(a)
a.click()
} finally {
URL.revokeObjectURL(url)
document.body?.removeChild(a)
}
return FileHandle.Simple(File(arrayOf(blob), filename))
}

View File

@ -8,7 +8,7 @@ import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.files.FileHandle
import world.phantasmal.webui.files.FileType
import world.phantasmal.webui.files.showFilePicker
import world.phantasmal.webui.files.showOpenFilePicker
class FileButton(
visible: Val<Boolean> = trueVal(),
@ -29,7 +29,7 @@ class FileButton(
if (filesSelected != null) {
element.onclick = {
scope.launch {
filesSelected.invoke(showFilePicker(types, multiple))
filesSelected.invoke(showOpenFilePicker(types, multiple))
}
}
}