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 new file mode 100644 index 00000000..c4861cad --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/core/files/FilesExtensions.kt @@ -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) 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 f7e53f0b..814ccb50 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 @@ -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 = _resultDialogVisible val result: Val?> = _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) { + suspend fun openFiles(files: List?) { 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) } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt index d5c5d319..3b9f77b2 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorToolbarWidget.kt @@ -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) } }, ), diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt index 9bf822d8..5f6b8d4f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt @@ -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) }, diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt index e88d6ba5..822badba 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt @@ -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) { + suspend fun openFiles(files: List?) { + files ?: return + val result = PwResult.build(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) diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbarWidget.kt similarity index 84% rename from web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt rename to web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbarWidget.kt index 04dcf717..08d133ef 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbarWidget.kt @@ -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) } }, ), diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt index 4a73e726..4fb24595 100644 --- a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt +++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt @@ -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 diff --git a/webui/src/main/kotlin/world/phantasmal/webui/Files.kt b/webui/src/main/kotlin/world/phantasmal/webui/Files.kt deleted file mode 100644 index 1c63aa39..00000000 --- a/webui/src/main/kotlin/world/phantasmal/webui/Files.kt +++ /dev/null @@ -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 = - 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()) {} - } else { - cont.cancel(Exception(reader.error.message.unsafeCast())) - } - } - reader.readAsArrayBuffer(file) -} diff --git a/webui/src/main/kotlin/world/phantasmal/webui/Js.kt b/webui/src/main/kotlin/world/phantasmal/webui/Js.kt index 61f0a740..9e4edb7f 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/Js.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/Js.kt @@ -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 obj(block: T.() -> Unit): T = js("{}").unsafeCast().apply(block) diff --git a/webui/src/main/kotlin/world/phantasmal/webui/externals/browser/Browser.kt b/webui/src/main/kotlin/world/phantasmal/webui/externals/browser/Browser.kt new file mode 100644 index 00000000..47b67e2c --- /dev/null +++ b/webui/src/main/kotlin/world/phantasmal/webui/externals/browser/Browser.kt @@ -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 = + asDynamic().arrayBuffer().unsafeCast>() + +open external class FileSystemHandle { + val kind: String /* "file" | "directory" */ + val name: String +} + +external class FileSystemFileHandle : FileSystemHandle { + fun getFile(): Promise +} + +external interface ShowOpenFilePickerOptionsType { + var description: String + var accept: dynamic +} + +external interface ShowOpenFilePickerOptions { + var multiple: Boolean + var excludeAcceptAllOption: Boolean + var types: Array +} + +fun Window.showOpenFilePicker( + options: ShowOpenFilePickerOptions, +): Promise> = + asDynamic().showOpenFilePicker(options).unsafeCast>>() diff --git a/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt b/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt new file mode 100644 index 00000000..3e95986a --- /dev/null +++ b/webui/src/main/kotlin/world/phantasmal/webui/files/Files.kt @@ -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 = + handle?.getFile() ?: Promise.resolve(file!!) +} + +class FileType( + val description: String, + /** + * Map of MIME types to file extensions + */ + val accept: Map>, +) + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun showFilePicker(types: List, multiple: Boolean = false): List? = + suspendCancellableCoroutine { cont -> + if (BrowserFeatures.fileSystemApi) { + window.showOpenFilePicker(obj { + this.multiple = multiple + this.types = types.map { + obj { + 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("focus", { + focusListener.dispose() + + window.setTimeout({ + if (cont.isActive) { + cont.resume(null) {} + } + }, 500) + }) + + el.click() + } + } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt index affda425..37c408ec 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Dialog.kt @@ -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 = trueVal(), enabled: Val = trueVal(), diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt index d84ebb58..0b62e491 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt @@ -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 = trueVal(), @@ -18,9 +19,9 @@ class FileButton( textVal: Val? = null, iconLeft: Icon? = null, iconRight: Icon? = null, - private val accept: String = "", + private val types: List = emptyList(), private val multiple: Boolean = false, - private val filesSelected: ((List) -> Unit)? = null, + private val filesSelected: ((List?) -> 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)) } } }