The file system access API is now used for opening files when supported by the browser.

This commit is contained in:
Daan Vanden Bosch 2021-04-14 20:35:59 +02:00
parent d6751b0151
commit 329e067a17
13 changed files with 211 additions and 72 deletions

View File

@ -0,0 +1,9 @@
package world.phantasmal.web.core.files
import world.phantasmal.lib.Endianness
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)

View File

@ -5,17 +5,16 @@ import mu.KotlinLogging
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.url.URL
import org.w3c.files.Blob
import org.w3c.files.File
import world.phantasmal.core.*
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.Episode
import world.phantasmal.lib.cursor.cursor
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.web.core.PwToolType
import world.phantasmal.web.core.files.cursor
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.stores.AreaStore
@ -23,9 +22,10 @@ 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.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.readFile
import world.phantasmal.webui.selectFiles
private val logger = KotlinLogging.logger {}
@ -47,7 +47,12 @@ class QuestEditorToolbarController(
val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result
val openFileAccept = ".bin, .dat, .qst"
val supportedFileTypes = listOf(
FileType(
description = "Quests",
accept = mapOf("application/pw-quest" to setOf(".qst", ".bin", ".dat")),
),
)
// Save as
@ -101,7 +106,7 @@ class QuestEditorToolbarController(
init {
addDisposables(
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-O") {
openFiles(selectFiles(accept = openFileAccept, multiple = true))
openFiles(showFilePicker(supportedFileTypes, multiple = true))
},
uiStore.onGlobalKeyDown(PwToolType.QuestEditor, "Ctrl-Shift-S") {
@ -128,26 +133,26 @@ class QuestEditorToolbarController(
questEditorStore.setDefaultQuest(episode)
}
suspend fun openFiles(files: List<File>) {
suspend fun openFiles(files: List<FileHandle>?) {
try {
if (files.isEmpty()) return
if (files.isNullOrEmpty()) return
val qst = files.find { it.name.endsWith(".qst", ignoreCase = true) }
val qstFile = files.find { it.extension().equals("qst", ignoreCase = true) }
if (qst != null) {
val parseResult = parseQstToQuest(readFile(qst).cursor(Endianness.Little))
if (qstFile != null) {
val parseResult = parseQstToQuest(qstFile.cursor(Endianness.Little))
setResult(parseResult)
if (parseResult is Success) {
setFilename(filenameBase(qst.name) ?: qst.name)
setFilename(filenameBase(qstFile.name) ?: qstFile.name)
setVersion(parseResult.value.version)
setCurrentQuest(parseResult.value.quest)
}
} else {
val bin = files.find { it.name.endsWith(".bin", ignoreCase = true) }
val dat = files.find { it.name.endsWith(".dat", ignoreCase = true) }
val binFile = files.find { it.extension().equals("bin", ignoreCase = true) }
val datFile = files.find { it.extension().equals("dat", ignoreCase = true) }
if (bin == null || dat == null) {
if (binFile == null || datFile == null) {
setResult(Failure(listOf(Problem(
Severity.Error,
"Please select a .qst file or one .bin and one .dat file."
@ -156,13 +161,13 @@ class QuestEditorToolbarController(
}
val parseResult = parseBinDatToQuest(
readFile(bin).cursor(Endianness.Little),
readFile(dat).cursor(Endianness.Little),
binFile.cursor(Endianness.Little),
datFile.cursor(Endianness.Little),
)
setResult(parseResult)
if (parseResult is Success) {
setFilename(filenameBase(bin.name) ?: filenameBase(dat.name) ?: bin.name)
setFilename(binFile.basename() ?: datFile.basename() ?: binFile.name)
setVersion(Version.BB)
setCurrentQuest(parseResult.value)
}

View File

@ -30,7 +30,7 @@ class QuestEditorToolbarWidget(private val ctrl: QuestEditorToolbarController) :
text = "Open file...",
tooltip = value("Open a quest file (Ctrl-O)"),
iconLeft = Icon.File,
accept = ctrl.openFileAccept,
types = ctrl.supportedFileTypes,
multiple = true,
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
),

View File

@ -16,7 +16,7 @@ import world.phantasmal.web.viewer.rendering.MeshRenderer
import world.phantasmal.web.viewer.rendering.TextureRenderer
import world.phantasmal.web.viewer.stores.ViewerStore
import world.phantasmal.web.viewer.widgets.CharacterClassOptionsWidget
import world.phantasmal.web.viewer.widgets.ViewerToolbar
import world.phantasmal.web.viewer.widgets.ViewerToolbarWidget
import world.phantasmal.web.viewer.widgets.ViewerWidget
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget
@ -54,7 +54,7 @@ class Viewer(
// Main Widget
return ViewerWidget(
viewerController,
{ ViewerToolbar(viewerToolbarController) },
{ ViewerToolbarWidget(viewerToolbarController) },
{ CharacterClassOptionsWidget(characterClassOptionsController) },
{ RendererWidget(meshRenderer) },
{ RendererWidget(textureRenderer) },

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.viewer.controllers
import mu.KotlinLogging
import org.w3c.files.File
import world.phantasmal.core.Failure
import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
@ -16,11 +15,11 @@ import world.phantasmal.lib.fileFormats.parseAreaCollisionGeometry
import world.phantasmal.lib.fileFormats.parseAreaRenderGeometry
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.files.cursor
import world.phantasmal.web.viewer.stores.NinjaGeometry
import world.phantasmal.web.viewer.stores.ViewerStore
import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.extension
import world.phantasmal.webui.readFile
import world.phantasmal.webui.files.FileHandle
private val logger = KotlinLogging.logger {}
@ -70,7 +69,9 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
store.setCurrentAnimation(null)
}
suspend fun openFiles(files: List<File>) {
suspend fun openFiles(files: List<FileHandle>?) {
files ?: return
val result = PwResult.build<Unit>(logger)
var success = false
@ -82,7 +83,7 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
for (file in files) {
val extension = file.extension()?.toLowerCase()
val cursor = readFile(file).cursor(Endianness.Little)
val cursor = file.cursor(Endianness.Little)
var fileResult: PwResult<*>
when (extension) {
@ -105,6 +106,7 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
}
"rel" -> {
// TODO: Detect .rel type instead of relying on filename.
if (file.name.endsWith("c.rel")) {
val collisionGeometry = parseAreaCollisionGeometry(cursor)
fileResult = Success(collisionGeometry)

View File

@ -5,9 +5,10 @@ import org.w3c.dom.Node
import world.phantasmal.web.viewer.controllers.ViewerToolbarController
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.files.FileType
import world.phantasmal.webui.widgets.*
class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() {
class ViewerToolbarWidget(private val ctrl: ViewerToolbarController) : Widget() {
override fun Node.createElement() =
div {
className = "pw-viewer-toolbar"
@ -17,7 +18,16 @@ class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() {
FileButton(
text = "Open file...",
iconLeft = Icon.File,
accept = ".afs, .nj, .njm, .rel, .xj, .xvm",
types = listOf(
FileType(
description = "Models, textures, animations",
accept = mapOf(
"application/pw-viewer-file" to setOf(
".afs", ".nj", ".njm", ".rel", ".xj", ".xvm"
),
),
),
),
multiple = true,
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
),

View File

@ -9,6 +9,7 @@ import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.test.WebTestSuite
import world.phantasmal.web.test.createQuestModel
import world.phantasmal.web.test.createQuestNpcModel
import world.phantasmal.webui.files.FileHandle
import kotlin.test.*
class QuestEditorToolbarControllerTests : WebTestSuite {
@ -35,7 +36,7 @@ class QuestEditorToolbarControllerTests : WebTestSuite {
assertNull(ctrl.result.value)
ctrl.openFiles(listOf(File(arrayOf(), "unknown.extension")))
ctrl.openFiles(listOf(FileHandle(File(arrayOf(), "unknown.extension"))))
val result = ctrl.result.value

View File

@ -1,38 +0,0 @@
package world.phantasmal.webui
import kotlinx.browser.document
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.asList
import org.w3c.files.File
import org.w3c.files.FileReader
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun selectFiles(accept: String = "", multiple: Boolean = false): List<File> =
suspendCancellableCoroutine { cont ->
val el = document.createElement("input") as HTMLInputElement
el.type = "file"
el.accept = accept
el.multiple = multiple
el.onchange = {
cont.resume(el.files?.asList() ?: emptyList()) {}
}
el.click()
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun readFile(file: File): ArrayBuffer = suspendCancellableCoroutine { cont ->
val reader = FileReader()
reader.onloadend = {
if (reader.result is ArrayBuffer) {
cont.resume(reader.result.unsafeCast<ArrayBuffer>()) {}
} else {
cont.cancel(Exception(reader.error.message.unsafeCast<String>()))
}
}
reader.readAsArrayBuffer(file)
}

View File

@ -1,8 +1,13 @@
package world.phantasmal.webui
import kotlinx.browser.window
import org.w3c.files.File
import world.phantasmal.core.filenameExtension
object BrowserFeatures {
val fileSystemApi: Boolean = window.asDynamic().showOpenFilePicker != null
}
inline fun <T> obj(block: T.() -> Unit): T =
js("{}").unsafeCast<T>().apply(block)

View File

@ -0,0 +1,35 @@
package world.phantasmal.webui.externals.browser
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.Window
import org.w3c.files.Blob
import org.w3c.files.File
import kotlin.js.Promise
fun Blob.arrayBuffer(): Promise<ArrayBuffer> =
asDynamic().arrayBuffer().unsafeCast<Promise<ArrayBuffer>>()
open external class FileSystemHandle {
val kind: String /* "file" | "directory" */
val name: String
}
external class FileSystemFileHandle : FileSystemHandle {
fun getFile(): Promise<File>
}
external interface ShowOpenFilePickerOptionsType {
var description: String
var accept: dynamic
}
external interface ShowOpenFilePickerOptions {
var multiple: Boolean
var excludeAcceptAllOption: Boolean
var types: Array<ShowOpenFilePickerOptionsType>
}
fun Window.showOpenFilePicker(
options: ShowOpenFilePickerOptions,
): Promise<Array<FileSystemFileHandle>> =
asDynamic().showOpenFilePicker(options).unsafeCast<Promise<Array<FileSystemFileHandle>>>()

View File

@ -0,0 +1,108 @@
package world.phantasmal.webui.files
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import org.khronos.webgl.ArrayBuffer
import org.w3c.dom.HTMLInputElement
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.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.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))
}
/**
* Returns the filename without extension if there is one.
*/
fun basename(): String? = filenameBase(name)
/**
* Returns the filename extension if there is one.
*/
fun extension(): String? = filenameExtension(name)
suspend fun arrayBuffer(): ArrayBuffer = suspendCancellableCoroutine { cont ->
getFile()
.then { it.arrayBuffer() }
.then({ cont.resume(it) {} }, cont::cancel)
}
private fun getFile(): Promise<File> =
handle?.getFile() ?: Promise.resolve(file!!)
}
class FileType(
val description: String,
/**
* Map of MIME types to file extensions
*/
val accept: Map<String, Set<String>>,
)
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun showFilePicker(types: List<FileType>, multiple: Boolean = false): List<FileHandle>? =
suspendCancellableCoroutine { cont ->
if (BrowserFeatures.fileSystemApi) {
window.showOpenFilePicker(obj {
this.multiple = multiple
this.types = types.map {
obj<ShowOpenFilePickerOptionsType> {
description = it.description
accept = obj {
for ((mimeType, extensions) in it.accept) {
this[mimeType] = extensions.toTypedArray()
}
}
}
}.toTypedArray()
}).then({ cont.resume(it.map(::FileHandle)) {} }, { cont.resume(null) {} })
} else {
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()) {}
}
@Suppress("JoinDeclarationAndAssignment")
lateinit var focusListener: Disposable
focusListener = window.disposableListener<Event>("focus", {
focusListener.dispose()
window.setTimeout({
if (cont.isActive) {
cont.resume(null) {}
}
}, 500)
})
el.click()
}
}

View File

@ -16,6 +16,7 @@ import world.phantasmal.webui.dom.dom
import world.phantasmal.webui.dom.h1
import world.phantasmal.webui.dom.section
// TODO: Use HTML dialog element.
open class Dialog(
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),

View File

@ -2,12 +2,13 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.launch
import org.w3c.dom.HTMLElement
import org.w3c.files.File
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.selectFiles
import world.phantasmal.webui.files.FileHandle
import world.phantasmal.webui.files.FileType
import world.phantasmal.webui.files.showFilePicker
class FileButton(
visible: Val<Boolean> = trueVal(),
@ -18,9 +19,9 @@ class FileButton(
textVal: Val<String>? = null,
iconLeft: Icon? = null,
iconRight: Icon? = null,
private val accept: String = "",
private val types: List<FileType> = emptyList(),
private val multiple: Boolean = false,
private val filesSelected: ((List<File>) -> Unit)? = null,
private val filesSelected: ((List<FileHandle>?) -> Unit)? = null,
) : Button(visible, enabled, tooltip, className, text, textVal, iconLeft, iconRight) {
override fun interceptElement(element: HTMLElement) {
element.classList.add("pw-file-button")
@ -28,7 +29,7 @@ class FileButton(
if (filesSelected != null) {
element.onclick = {
scope.launch {
filesSelected.invoke(selectFiles(accept, multiple))
filesSelected.invoke(showFilePicker(types, multiple))
}
}
}