mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
"Save" now works as expected after "Saving as".
This commit is contained in:
parent
a823e96f68
commit
0112281b1a
@ -45,18 +45,28 @@ external class FileSystemWritableFileStream : WritableStream {
|
|||||||
fun seek(position: Int): Promise<Unit>
|
fun seek(position: Int): Promise<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
external interface ShowOpenFilePickerOptionsType {
|
external interface ShowFilePickerOptionsType {
|
||||||
var description: String
|
var description: String
|
||||||
var accept: dynamic
|
var accept: dynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
external interface ShowOpenFilePickerOptions {
|
external interface ShowFilePickerOptions {
|
||||||
var multiple: Boolean
|
|
||||||
var excludeAcceptAllOption: Boolean
|
var excludeAcceptAllOption: Boolean
|
||||||
var types: Array<ShowOpenFilePickerOptionsType>
|
var types: Array<ShowFilePickerOptionsType>
|
||||||
|
}
|
||||||
|
|
||||||
|
external interface ShowOpenFilePickerOptions : ShowFilePickerOptions {
|
||||||
|
var multiple: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Window.showOpenFilePicker(
|
fun Window.showOpenFilePicker(
|
||||||
options: ShowOpenFilePickerOptions,
|
options: ShowOpenFilePickerOptions,
|
||||||
): Promise<Array<FileSystemFileHandle>> =
|
): Promise<Array<FileSystemFileHandle>> =
|
||||||
asDynamic().showOpenFilePicker(options).unsafeCast<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>>()
|
||||||
|
@ -11,6 +11,6 @@ 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) {
|
suspend fun FileHandle.System.writeBuffer(buffer: Buffer) {
|
||||||
writableStream().use { it.write(buffer.arrayBuffer).await() }
|
writableStream().use { it.write(buffer.arrayBuffer).await() }
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,25 @@
|
|||||||
package world.phantasmal.web.questEditor.controllers
|
package world.phantasmal.web.questEditor.controllers
|
||||||
|
|
||||||
import kotlinx.browser.document
|
import kotlinx.coroutines.await
|
||||||
import mu.KotlinLogging
|
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.core.*
|
||||||
import world.phantasmal.lib.Endianness
|
import world.phantasmal.lib.Endianness
|
||||||
import world.phantasmal.lib.Episode
|
import world.phantasmal.lib.Episode
|
||||||
import world.phantasmal.lib.fileFormats.quest.*
|
import world.phantasmal.lib.fileFormats.quest.*
|
||||||
import world.phantasmal.observable.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.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.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.models.QuestModel
|
||||||
import world.phantasmal.web.questEditor.stores.AreaStore
|
import world.phantasmal.web.questEditor.stores.AreaStore
|
||||||
import world.phantasmal.web.questEditor.stores.QuestEditorStore
|
import world.phantasmal.web.questEditor.stores.QuestEditorStore
|
||||||
import world.phantasmal.web.questEditor.stores.convertQuestFromModel
|
import world.phantasmal.web.questEditor.stores.convertQuestFromModel
|
||||||
import world.phantasmal.web.questEditor.stores.convertQuestToModel
|
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.controllers.Controller
|
||||||
import world.phantasmal.webui.files.FileHandle
|
import world.phantasmal.webui.files.*
|
||||||
import world.phantasmal.webui.files.FileType
|
|
||||||
import world.phantasmal.webui.files.showFilePicker
|
|
||||||
import world.phantasmal.webui.obj
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -45,7 +38,7 @@ class QuestEditorToolbarController(
|
|||||||
// happening/has happened.
|
// happening/has happened.
|
||||||
private val savingEnabled = questEditorStore.currentQuest.isNotNull() and !saving
|
private val savingEnabled = questEditorStore.currentQuest.isNotNull() and !saving
|
||||||
private val _saveAsDialogVisible = mutableVal(false)
|
private val _saveAsDialogVisible = mutableVal(false)
|
||||||
private val files: MutableListVal<FileHandle> = mutableListVal()
|
private val fileHolder = mutableVal<FileHolder?>(null)
|
||||||
private val _filename = mutableVal("")
|
private val _filename = mutableVal("")
|
||||||
private val _version = mutableVal(Version.BB)
|
private val _version = mutableVal(Version.BB)
|
||||||
|
|
||||||
@ -64,17 +57,18 @@ class QuestEditorToolbarController(
|
|||||||
// Saving
|
// Saving
|
||||||
|
|
||||||
val saveEnabled: Val<Boolean> =
|
val saveEnabled: Val<Boolean> =
|
||||||
and(
|
savingEnabled and questEditorStore.canSaveChanges and UserAgentFeatures.fileSystemApi
|
||||||
savingEnabled,
|
val saveTooltip: Val<String> =
|
||||||
questEditorStore.canSaveChanges,
|
if (UserAgentFeatures.fileSystemApi) {
|
||||||
files.notEmpty
|
questEditorStore.canSaveChanges.map {
|
||||||
) and BrowserFeatures.fileSystemApi
|
(if (it) "Save changes" else "No changes to save") + " (Ctrl-S)"
|
||||||
val saveTooltip: Val<String> = value(
|
}
|
||||||
if (BrowserFeatures.fileSystemApi) "Save changes (Ctrl-S)"
|
} else {
|
||||||
else "This browser doesn't support saving to an existing file"
|
value("This browser doesn't support saving changes to existing files")
|
||||||
)
|
}
|
||||||
val saveAsEnabled: Val<Boolean> = savingEnabled
|
val saveAsEnabled: Val<Boolean> = savingEnabled
|
||||||
val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible
|
val saveAsDialogVisible: Val<Boolean> = _saveAsDialogVisible
|
||||||
|
val showSaveAsDialogNameField: Boolean = !UserAgentFeatures.fileSystemApi
|
||||||
val filename: Val<String> = _filename
|
val filename: Val<String> = _filename
|
||||||
val version: Val<Version> = _version
|
val version: Val<Version> = _version
|
||||||
|
|
||||||
@ -123,7 +117,7 @@ class QuestEditorToolbarController(
|
|||||||
init {
|
init {
|
||||||
addDisposables(
|
addDisposables(
|
||||||
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-O") {
|
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-O") {
|
||||||
openFiles(showFilePicker(supportedFileTypes, multiple = true))
|
openFiles(showOpenFilePicker(supportedFileTypes, multiple = true))
|
||||||
},
|
},
|
||||||
|
|
||||||
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-S") {
|
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-S") {
|
||||||
@ -149,15 +143,11 @@ class QuestEditorToolbarController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createNewQuest(episode: Episode) {
|
suspend fun createNewQuest(episode: Episode) {
|
||||||
setFilename("")
|
setCurrentQuest(fileHolder = null, Version.BB, questEditorStore.getDefaultQuest(episode))
|
||||||
setVersion(Version.BB)
|
|
||||||
questEditorStore.setDefaultQuest(episode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun openFiles(newFiles: List<FileHandle>?) {
|
suspend fun openFiles(newFiles: List<FileHandle>?) {
|
||||||
try {
|
try {
|
||||||
files.clear()
|
|
||||||
|
|
||||||
if (newFiles.isNullOrEmpty()) return
|
if (newFiles.isNullOrEmpty()) return
|
||||||
|
|
||||||
val qstFile = newFiles.find { it.extension().equals("qst", ignoreCase = true) }
|
val qstFile = newFiles.find { it.extension().equals("qst", ignoreCase = true) }
|
||||||
@ -167,10 +157,11 @@ class QuestEditorToolbarController(
|
|||||||
setResult(parseResult)
|
setResult(parseResult)
|
||||||
|
|
||||||
if (parseResult is Success) {
|
if (parseResult is Success) {
|
||||||
setFilename(filenameBase(qstFile.name) ?: qstFile.name)
|
setCurrentQuest(
|
||||||
setVersion(parseResult.value.version)
|
FileHolder.Qst(qstFile),
|
||||||
setCurrentQuest(parseResult.value.quest)
|
parseResult.value.version,
|
||||||
files.replaceAll(listOf(qstFile))
|
parseResult.value.quest,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val binFile = newFiles.find { it.extension().equals("bin", ignoreCase = true) }
|
val binFile = newFiles.find { it.extension().equals("bin", ignoreCase = true) }
|
||||||
@ -191,10 +182,11 @@ class QuestEditorToolbarController(
|
|||||||
setResult(parseResult)
|
setResult(parseResult)
|
||||||
|
|
||||||
if (parseResult is Success) {
|
if (parseResult is Success) {
|
||||||
setFilename(binFile.basename() ?: datFile.basename() ?: binFile.name)
|
setCurrentQuest(
|
||||||
setVersion(Version.BB)
|
FileHolder.BinDat(binFile, datFile),
|
||||||
setCurrentQuest(parseResult.value)
|
Version.BB,
|
||||||
files.replaceAll(listOf(binFile, datFile))
|
parseResult.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@ -214,33 +206,44 @@ class QuestEditorToolbarController(
|
|||||||
|
|
||||||
val quest = questEditorStore.currentQuest.value ?: return
|
val quest = questEditorStore.currentQuest.value ?: return
|
||||||
val headerFilename = filename.value.trim()
|
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) {
|
||||||
val buffer = writeQuestToQst(
|
is FileHolder.Qst -> {
|
||||||
convertQuestFromModel(quest),
|
if (holder.file is FileHandle.System) {
|
||||||
headerFilename,
|
val buffer = writeQuestToQst(
|
||||||
version.value,
|
convertQuestFromModel(quest),
|
||||||
online = true,
|
headerFilename,
|
||||||
)
|
version.value,
|
||||||
|
online = true,
|
||||||
|
)
|
||||||
|
|
||||||
qstFile.writeBuffer(buffer)
|
holder.file.writeBuffer(buffer)
|
||||||
|
|
||||||
|
questEditorStore.questSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FileHolder.BinDat -> {
|
||||||
|
if (holder.binFile is FileHandle.System &&
|
||||||
|
holder.datFile is FileHandle.System
|
||||||
|
) {
|
||||||
|
val (bin, dat) = writeQuestToBinDat(
|
||||||
|
convertQuestFromModel(quest),
|
||||||
|
version.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
holder.binFile.writeBuffer(bin)
|
||||||
|
holder.datFile.writeBuffer(dat)
|
||||||
|
|
||||||
|
questEditorStore.questSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val binFile = files.find { it.extension().equals("bin", ignoreCase = true) }
|
// When there's no existing file that can be saved, default to "Save as...".
|
||||||
val datFile = files.find { it.extension().equals("dat", ignoreCase = true) }
|
_saveAsDialogVisible.value = true
|
||||||
|
|
||||||
if (binFile != null && datFile != null) {
|
|
||||||
val (bin, dat) = writeQuestToBinDat(
|
|
||||||
convertQuestFromModel(quest),
|
|
||||||
version.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
binFile.writeBuffer(bin)
|
|
||||||
datFile.writeBuffer(dat)
|
|
||||||
}
|
|
||||||
|
|
||||||
questEditorStore.questSaved()
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
setResult(
|
setResult(
|
||||||
PwResult.build<Nothing>(logger)
|
PwResult.build<Nothing>(logger)
|
||||||
@ -266,7 +269,7 @@ class QuestEditorToolbarController(
|
|||||||
_version.value = version
|
_version.value = version
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAsDialogSave() {
|
suspend fun saveAsDialogSave() {
|
||||||
if (!saveAsEnabled.value) return
|
if (!saveAsEnabled.value) return
|
||||||
|
|
||||||
val quest = questEditorStore.currentQuest.value ?: return
|
val quest = questEditorStore.currentQuest.value ?: return
|
||||||
@ -286,25 +289,22 @@ class QuestEditorToolbarController(
|
|||||||
online = true,
|
online = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
val a = document.createElement("a") as HTMLAnchorElement
|
if (UserAgentFeatures.fileSystemApi) {
|
||||||
val url = URL.createObjectURL(
|
val fileHandle = showSaveFilePicker(listOf(
|
||||||
Blob(
|
FileType("Quest file", mapOf("application/pw-quest" to setOf(".qst")))
|
||||||
arrayOf(buffer.arrayBuffer),
|
))
|
||||||
obj { type = "application/octet-stream" },
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
if (fileHandle != null) {
|
||||||
a.href = url
|
fileHandle.writableStream().use { it.write(buffer.arrayBuffer).await() }
|
||||||
a.download = filename
|
|
||||||
document.body?.appendChild(a)
|
setFileHolder(FileHolder.Qst(fileHandle))
|
||||||
a.click()
|
questEditorStore.questSaved()
|
||||||
} finally {
|
}
|
||||||
URL.revokeObjectURL(url)
|
} else {
|
||||||
document.body?.removeChild(a)
|
val fileHandle = downloadFile(buffer.arrayBuffer, filename)
|
||||||
|
setFileHolder(FileHolder.Qst(fileHandle))
|
||||||
|
questEditorStore.questSaved()
|
||||||
}
|
}
|
||||||
|
|
||||||
questEditorStore.questSaved()
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
setResult(
|
setResult(
|
||||||
PwResult.build<Nothing>(logger)
|
PwResult.build<Nothing>(logger)
|
||||||
@ -341,8 +341,36 @@ class QuestEditorToolbarController(
|
|||||||
questEditorStore.setShowCollisionGeometry(show)
|
questEditorStore.setShowCollisionGeometry(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setCurrentQuest(quest: Quest) {
|
private fun setFileHolder(fileHolder: FileHolder?) {
|
||||||
questEditorStore.setCurrentQuest(convertQuestToModel(quest, areaStore::getVariant))
|
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<*>) {
|
private fun setResult(result: PwResult<*>) {
|
||||||
@ -352,4 +380,9 @@ class QuestEditorToolbarController(
|
|||||||
_resultDialogVisible.value = true
|
_resultDialogVisible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FileHolder {
|
||||||
|
class Qst(val file: FileHandle) : FileHolder()
|
||||||
|
class BinDat(val binFile: FileHandle, val datFile: FileHandle) : FileHolder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,10 @@ class QuestEditorStore(
|
|||||||
val firstUndo: Val<Action?> = undoManager.firstUndo
|
val firstUndo: Val<Action?> = undoManager.firstUndo
|
||||||
val canRedo: Val<Boolean> = questEditingEnabled and undoManager.canRedo
|
val canRedo: Val<Boolean> = questEditingEnabled and undoManager.canRedo
|
||||||
val firstRedo: Val<Action?> = undoManager.firstRedo
|
val firstRedo: Val<Action?> = undoManager.firstRedo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if there have been changes since the last save.
|
||||||
|
*/
|
||||||
val canSaveChanges: Val<Boolean> = !undoManager.allAtSavePoint
|
val canSaveChanges: Val<Boolean> = !undoManager.allAtSavePoint
|
||||||
|
|
||||||
val showCollisionGeometry: Val<Boolean> = _showCollisionGeometry
|
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() {
|
override fun dispose() {
|
||||||
@ -143,11 +147,8 @@ class QuestEditorStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultQuest(episode: Episode) {
|
suspend fun getDefaultQuest(episode: Episode): QuestModel =
|
||||||
setCurrentQuest(
|
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
|
||||||
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setSectionOnQuestEntities(
|
private fun setSectionOnQuestEntities(
|
||||||
entities: List<QuestEntityModel<*, *>>,
|
entities: List<QuestEntityModel<*, *>>,
|
||||||
|
@ -87,13 +87,15 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
|
|||||||
div {
|
div {
|
||||||
className = "pw-quest-editor-toolbar-save-as"
|
className = "pw-quest-editor-toolbar-save-as"
|
||||||
|
|
||||||
val filenameInput = TextInput(
|
if (ctrl.showSaveAsDialogNameField) {
|
||||||
label = "File name:",
|
val filenameInput = TextInput(
|
||||||
value = ctrl.filename,
|
label = "File name:",
|
||||||
onChange = ctrl::setFilename,
|
value = ctrl.filename,
|
||||||
)
|
onChange = ctrl::setFilename,
|
||||||
addWidget(filenameInput.label!!)
|
)
|
||||||
addWidget(filenameInput)
|
addWidget(filenameInput.label!!)
|
||||||
|
addWidget(filenameInput)
|
||||||
|
}
|
||||||
|
|
||||||
val versionSelect = Select(
|
val versionSelect = Select(
|
||||||
label = "Version:",
|
label = "Version:",
|
||||||
@ -116,7 +118,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
|
|||||||
footer = {
|
footer = {
|
||||||
addWidget(Button(
|
addWidget(Button(
|
||||||
text = "Save",
|
text = "Save",
|
||||||
onClick = { ctrl.saveAsDialogSave() },
|
onClick = { scope.launch { ctrl.saveAsDialogSave() } },
|
||||||
))
|
))
|
||||||
addWidget(Button(
|
addWidget(Button(
|
||||||
text = "Cancel",
|
text = "Cancel",
|
||||||
@ -128,7 +130,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
|
|||||||
|
|
||||||
saveAsDialog.dialogElement.addEventListener("keydown", { e ->
|
saveAsDialog.dialogElement.addEventListener("keydown", { e ->
|
||||||
if ((e as KeyboardEvent).key == "Enter") {
|
if ((e as KeyboardEvent).key == "Enter") {
|
||||||
ctrl.saveAsDialogSave()
|
scope.launch { ctrl.saveAsDialogSave() }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import kotlinx.browser.window
|
|||||||
import org.w3c.files.File
|
import org.w3c.files.File
|
||||||
import world.phantasmal.core.filenameExtension
|
import world.phantasmal.core.filenameExtension
|
||||||
|
|
||||||
object BrowserFeatures {
|
object UserAgentFeatures {
|
||||||
val fileSystemApi: Boolean = window.asDynamic().showOpenFilePicker != null
|
val fileSystemApi: Boolean = window.asDynamic().showOpenFilePicker != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,15 +6,18 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.await
|
import kotlinx.coroutines.await
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.khronos.webgl.ArrayBuffer
|
import org.khronos.webgl.ArrayBuffer
|
||||||
|
import org.w3c.dom.HTMLAnchorElement
|
||||||
import org.w3c.dom.HTMLInputElement
|
import org.w3c.dom.HTMLInputElement
|
||||||
import org.w3c.dom.asList
|
import org.w3c.dom.asList
|
||||||
import org.w3c.dom.events.Event
|
import org.w3c.dom.events.Event
|
||||||
|
import org.w3c.dom.url.URL
|
||||||
|
import org.w3c.files.Blob
|
||||||
import org.w3c.files.File
|
import org.w3c.files.File
|
||||||
import world.phantasmal.core.disposable.Disposable
|
import world.phantasmal.core.disposable.Disposable
|
||||||
import world.phantasmal.core.externals.browser.*
|
import world.phantasmal.core.externals.browser.*
|
||||||
import world.phantasmal.core.filenameBase
|
import world.phantasmal.core.filenameBase
|
||||||
import world.phantasmal.core.filenameExtension
|
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.dom.disposableListener
|
||||||
import world.phantasmal.webui.obj
|
import world.phantasmal.webui.obj
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@ -40,7 +43,7 @@ sealed class FileHandle {
|
|||||||
/**
|
/**
|
||||||
* File system access API file handle.
|
* 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
|
override val name: String = handle.name
|
||||||
|
|
||||||
suspend fun writableStream(): FileSystemWritableFileStream =
|
suspend fun writableStream(): FileSystemWritableFileStream =
|
||||||
@ -66,13 +69,16 @@ class FileType(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): List<FileHandle>? =
|
suspend fun showOpenFilePicker(
|
||||||
if (BrowserFeatures.fileSystemApi) {
|
types: List<FileType>,
|
||||||
|
multiple: Boolean = false,
|
||||||
|
): List<FileHandle>? =
|
||||||
|
if (UserAgentFeatures.fileSystemApi) {
|
||||||
try {
|
try {
|
||||||
val fileHandles = window.showOpenFilePicker(obj {
|
val fileHandles = window.showOpenFilePicker(obj {
|
||||||
this.multiple = multiple
|
this.multiple = multiple
|
||||||
this.types = types.map {
|
this.types = types.map {
|
||||||
obj<ShowOpenFilePickerOptionsType> {
|
obj<ShowFilePickerOptionsType> {
|
||||||
description = it.description
|
description = it.description
|
||||||
accept = obj {
|
accept = obj {
|
||||||
for ((mimeType, extensions) in it.accept) {
|
for ((mimeType, extensions) in it.accept) {
|
||||||
@ -83,7 +89,7 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
|
|||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
}).await()
|
}).await()
|
||||||
|
|
||||||
fileHandles.map(FileHandle::Fsaa)
|
fileHandles.map(FileHandle::System)
|
||||||
} 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") {
|
||||||
@ -120,3 +126,54 @@ suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): Li
|
|||||||
el.click()
|
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))
|
||||||
|
}
|
||||||
|
@ -8,7 +8,7 @@ import world.phantasmal.observable.value.trueVal
|
|||||||
import world.phantasmal.webui.dom.Icon
|
import world.phantasmal.webui.dom.Icon
|
||||||
import world.phantasmal.webui.files.FileHandle
|
import world.phantasmal.webui.files.FileHandle
|
||||||
import world.phantasmal.webui.files.FileType
|
import world.phantasmal.webui.files.FileType
|
||||||
import world.phantasmal.webui.files.showFilePicker
|
import world.phantasmal.webui.files.showOpenFilePicker
|
||||||
|
|
||||||
class FileButton(
|
class FileButton(
|
||||||
visible: Val<Boolean> = trueVal(),
|
visible: Val<Boolean> = trueVal(),
|
||||||
@ -29,7 +29,7 @@ class FileButton(
|
|||||||
if (filesSelected != null) {
|
if (filesSelected != null) {
|
||||||
element.onclick = {
|
element.onclick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
filesSelected.invoke(showFilePicker(types, multiple))
|
filesSelected.invoke(showOpenFilePicker(types, multiple))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user