From 54576d8decb6d49ee8f7e3b2d91e37288a413106 Mon Sep 17 00:00:00 2001 From: nullsanya <000sanya.000sanya@gmail.com> Date: Thu, 3 Apr 2025 21:22:55 +1000 Subject: [PATCH] implement support of wasm/js for browser --- common/build.gradle | 7 + .../micro_utils/common/ByteArrayDataView.kt | 15 +++ .../micro_utils/common/CopyToClipboard.kt | 13 ++ .../common/CopyToClipboardImage.kt | 31 +++++ .../common/HTMLElementDomChanged.kt | 63 +++++++++ .../micro_utils/common/HtmlElementOnScreen.kt | 43 ++++++ .../common/IntersectionObserver.kt | 127 ++++++++++++++++++ .../dev/inmo/micro_utils/common/IsOverflow.kt | 12 ++ .../dev/inmo/micro_utils/common/JSMPPFile.kt | 58 ++++++++ .../inmo/micro_utils/common/OnClickOutside.kt | 41 ++++++ .../dev/inmo/micro_utils/common/OpenLink.kt | 8 ++ .../inmo/micro_utils/common/ResizeObserver.kt | 56 ++++++++ .../dev/inmo/micro_utils/common/SelectFile.kt | 30 +++++ .../micro_utils/common/TriggerDownload.kt | 14 ++ .../dev/inmo/micro_utils/common/Visibility.kt | 48 +++++++ .../dev/inmo/micro_utils/common/toFixed.kt | 11 ++ gradle/libs.versions.toml | 3 + ...sAndroidLinuxMingwLinuxArm64Project.gradle | 9 ++ ...sAndroidLinuxMingwLinuxArm64Project.gradle | 9 ++ .../mppJvmJsLinuxMingwProject.gradle | 9 ++ ...pProjectWithSerializationAndCompose.gradle | 9 ++ .../ktor/client/ActualTemporalUpload.kt | 63 +++++++++ .../ktor/client/ActualUniUpload.kt | 97 +++++++++++++ ktor/common/build.gradle | 6 + .../ktor/common/ActualMPPFileInput.kt | 7 + repos/common/tests/build.gradle | 4 + startup/plugin/build.gradle | 5 + .../kotlin/StartPluginSerializer.wasm.kt | 13 ++ 28 files changed, 811 insertions(+) create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ByteArrayDataView.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboard.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboardImage.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HtmlElementOnScreen.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IntersectionObserver.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IsOverflow.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/JSMPPFile.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OnClickOutside.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OpenLink.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ResizeObserver.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/SelectFile.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/TriggerDownload.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/Visibility.kt create mode 100644 common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/toFixed.kt create mode 100644 ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt create mode 100644 ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualUniUpload.kt create mode 100644 ktor/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/common/ActualMPPFileInput.kt create mode 100644 startup/plugin/src/wasmJsMain/kotlin/StartPluginSerializer.wasm.kt diff --git a/common/build.gradle b/common/build.gradle index eea0236fc9a..942a4c89148 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -29,5 +29,12 @@ kotlin { api libs.okio } } + + wasmJsMain { + dependencies { + api libs.kotlinx.browser + implementation libs.kt.coroutines + } + } } } diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ByteArrayDataView.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ByteArrayDataView.kt new file mode 100644 index 00000000000..6a151cc8947 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ByteArrayDataView.kt @@ -0,0 +1,15 @@ +package dev.inmo.micro_utils.common + +import org.khronos.webgl.* + +fun DataView.toByteArray() = ByteArray(this.byteLength) { + getInt8(it) +} + +fun ArrayBuffer.toByteArray() = Int8Array(this).toByteArray() + +fun ByteArray.toDataView() = DataView(ArrayBuffer(size)).also { + forEachIndexed { i, byte -> it.setInt8(i, byte) } +} + +fun ByteArray.toArrayBuffer() = toDataView().buffer diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboard.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboard.kt new file mode 100644 index 00000000000..3f1d30ed0f6 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboard.kt @@ -0,0 +1,13 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.window + +fun copyToClipboard(text: String): Boolean { + return runCatching { + window.navigator.clipboard.writeText( + text + ) + }.onFailure { + it.printStackTrace() + }.isSuccess +} diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboardImage.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboardImage.kt new file mode 100644 index 00000000000..f5b6f2a4287 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/CopyToClipboardImage.kt @@ -0,0 +1,31 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.window +import kotlinx.coroutines.await +import org.w3c.fetch.Response +import org.w3c.files.Blob +import org.w3c.files.BlobPropertyBag + +external class ClipboardItem(data: JsAny?) : JsAny + +fun createBlobData(blob: Blob): JsAny = js("""({[blob.type]: blob})""") + +inline fun Blob.convertToClipboardItem(): ClipboardItem { + return ClipboardItem(createBlobData(this)) +} + +suspend fun copyImageURLToClipboard(imageUrl: String): Boolean { + return runCatching { + val response = window.fetch(imageUrl).await() + val blob = response.blob().await() + val data = arrayOf( + Blob( + arrayOf(blob).toJsArray().unsafeCast(), + BlobPropertyBag("image/png") + ).convertToClipboardItem() + ).toJsArray() + window.navigator.clipboard.write(data.unsafeCast()) + }.onFailure { + it.printStackTrace() + }.isSuccess +} diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt new file mode 100644 index 00000000000..08ed4dcc4cc --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt @@ -0,0 +1,63 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import org.w3c.dom.* + +private fun createMutationObserverInit(childList: Boolean, subtree: Boolean): JsAny = js("({childList, subtree})") + +fun Node.onRemoved(block: () -> Unit): MutationObserver { + lateinit var observer: MutationObserver + + observer = MutationObserver { _, _ -> + fun checkIfRemoved(node: Node): Boolean { + return node.parentNode != document && (node.parentNode ?.let { checkIfRemoved(it) } ?: true) + } + + if (checkIfRemoved(this)) { + observer.disconnect() + block() + } + } + + observer.observe(document, createMutationObserverInit(childList = true, subtree = true).unsafeCast()) + return observer +} + +fun Element.onVisibilityChanged(block: IntersectionObserverEntry.(Float, IntersectionObserver) -> Unit): IntersectionObserver { + var previousIntersectionRatio = -1f + val observer = IntersectionObserver { entries, observer -> + entries.toArray().forEach { + if (previousIntersectionRatio.toDouble() != it.intersectionRatio.toDouble()) { + previousIntersectionRatio = it.intersectionRatio.toDouble().toFloat() + it.block(previousIntersectionRatio, observer) + } + } + } + + observer.observe(this) + return observer +} + +fun Element.onVisible(block: Element.(IntersectionObserver) -> Unit) { + var previous = -1f + onVisibilityChanged { intersectionRatio, observer -> + if (previous != intersectionRatio) { + if (intersectionRatio > 0 && previous == 0f) { + block(observer) + } + previous = intersectionRatio + } + } +} + +fun Element.onInvisible(block: Element.(IntersectionObserver) -> Unit): IntersectionObserver { + var previous = -1f + return onVisibilityChanged { intersectionRatio, observer -> + if (previous != intersectionRatio) { + if (intersectionRatio == 0f && previous != 0f) { + block(observer) + } + previous = intersectionRatio + } + } +} diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HtmlElementOnScreen.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HtmlElementOnScreen.kt new file mode 100644 index 00000000000..93a27d3812f --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/HtmlElementOnScreen.kt @@ -0,0 +1,43 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.window +import org.w3c.dom.DOMRect +import org.w3c.dom.Element + +val DOMRect.isOnScreenByLeftEdge: Boolean + get() = left >= 0 && left <= window.innerWidth +inline val Element.isOnScreenByLeftEdge + get() = getBoundingClientRect().isOnScreenByLeftEdge + +val DOMRect.isOnScreenByRightEdge: Boolean + get() = right >= 0 && right <= window.innerWidth +inline val Element.isOnScreenByRightEdge + get() = getBoundingClientRect().isOnScreenByRightEdge + +internal val DOMRect.isOnScreenHorizontally: Boolean + get() = isOnScreenByLeftEdge || isOnScreenByRightEdge + + +val DOMRect.isOnScreenByTopEdge: Boolean + get() = top >= 0 && top <= window.innerHeight +inline val Element.isOnScreenByTopEdge + get() = getBoundingClientRect().isOnScreenByTopEdge + +val DOMRect.isOnScreenByBottomEdge: Boolean + get() = bottom >= 0 && bottom <= window.innerHeight +inline val Element.isOnScreenByBottomEdge + get() = getBoundingClientRect().isOnScreenByBottomEdge + +internal val DOMRect.isOnScreenVertically: Boolean + get() = isOnScreenByLeftEdge || isOnScreenByRightEdge + + +val DOMRect.isOnScreenFully: Boolean + get() = isOnScreenByLeftEdge && isOnScreenByTopEdge && isOnScreenByRightEdge && isOnScreenByBottomEdge +val Element.isOnScreenFully: Boolean + get() = getBoundingClientRect().isOnScreenFully + +val DOMRect.isOnScreen: Boolean + get() = isOnScreenFully || (isOnScreenHorizontally && isOnScreenVertically) +inline val Element.isOnScreen: Boolean + get() = getBoundingClientRect().isOnScreen diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IntersectionObserver.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IntersectionObserver.kt new file mode 100644 index 00000000000..c4f4d9cbb4e --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IntersectionObserver.kt @@ -0,0 +1,127 @@ +package dev.inmo.micro_utils.common + +import org.w3c.dom.DOMRectReadOnly +import org.w3c.dom.Element + +external interface IntersectionObserverOptions: JsAny { + /** + * An Element or Document object which is an ancestor of the intended target, whose bounding rectangle will be + * considered the viewport. Any part of the target not visible in the visible area of the root is not considered + * visible. + */ + var root: Element? + + /** + * A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, + * effectively shrinking or growing the root for calculation purposes. The syntax is approximately the same as that + * for the CSS margin property; see The root element and root margin in Intersection Observer API for more + * information on how the margin works and the syntax. The default is "0px 0px 0px 0px". + */ + var rootMargin: String? + + /** + * Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to + * total bounding box area for the observed target. A value of 0.0 means that even a single visible pixel counts as + * the target being visible. 1.0 means that the entire target element is visible. See Thresholds in Intersection + * Observer API for a more in-depth description of how thresholds are used. The default is a threshold of 0.0. + */ + var threshold: JsArray? +} + +private fun createEmptyJsObject(): JsAny = js("{}") + +fun IntersectionObserverOptions( + block: IntersectionObserverOptions.() -> Unit = {} +): IntersectionObserverOptions = createEmptyJsObject().unsafeCast().apply(block) + +external interface IntersectionObserverEntry: JsAny { + /** + * Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in + * the documentation for Element.getBoundingClientRect(). + */ + val boundingClientRect: DOMRectReadOnly + + /** + * Returns the ratio of the intersectionRect to the boundingClientRect. + */ + val intersectionRatio: JsNumber + + /** + * Returns a DOMRectReadOnly representing the target's visible area. + */ + val intersectionRect: DOMRectReadOnly + + /** + * A Boolean value which is true if the target element intersects with the intersection observer's root. If this is + * true, then, the IntersectionObserverEntry describes a transition into a state of intersection; if it's false, + * then you know the transition is from intersecting to not-intersecting. + */ + val isIntersecting: Boolean + + /** + * Returns a DOMRectReadOnly for the intersection observer's root. + */ + val rootBounds: DOMRectReadOnly + + /** + * The Element whose intersection with the root changed. + */ + val target: Element + + /** + * A DOMHighResTimeStamp indicating the time at which the intersection was recorded, relative to the + * IntersectionObserver's time origin. + */ + val time: Double +} + +typealias IntersectionObserverCallback = (entries: JsArray, observer: IntersectionObserver) -> Unit + +/** + * This is just an implementation from [this commentary](https://youtrack.jetbrains.com/issue/KT-43157#focus=Comments-27-4498582.0-0) + * of Kotlin JS issue related to the absence of [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) + */ +external class IntersectionObserver(callback: IntersectionObserverCallback): JsAny { + constructor(callback: IntersectionObserverCallback, options: IntersectionObserverOptions) + + /** + * The Element or Document whose bounds are used as the bounding box when testing for intersection. If no root value + * was passed to the constructor or its value is null, the top-level document's viewport is used. + */ + val root: Element + + /** + * An offset rectangle applied to the root's bounding box when calculating intersections, effectively shrinking or + * growing the root for calculation purposes. The value returned by this property may not be the same as the one + * specified when calling the constructor as it may be changed to match internal requirements. Each offset can be + * expressed in pixels (px) or as a percentage (%). The default is "0px 0px 0px 0px". + */ + val rootMargin: String + + /** + * A list of thresholds, sorted in increasing numeric order, where each threshold is a ratio of intersection area to + * bounding box area of an observed target. Notifications for a target are generated when any of the thresholds are + * crossed for that target. If no value was passed to the constructor, 0 is used. + */ + val thresholds: JsArray + + /** + * Stops the IntersectionObserver object from observing any target. + */ + fun disconnect() + + /** + * Tells the IntersectionObserver a target element to observe. + */ + fun observe(targetElement: Element) + + /** + * Returns an array of IntersectionObserverEntry objects for all observed targets. + */ + fun takeRecords(): JsArray + + /** + * Tells the IntersectionObserver to stop observing a particular target element. + */ + fun unobserve(targetElement: Element) +} diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IsOverflow.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IsOverflow.kt new file mode 100644 index 00000000000..28412eaa0b0 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/IsOverflow.kt @@ -0,0 +1,12 @@ +package dev.inmo.micro_utils.common + +import org.w3c.dom.Element + +inline val Element.isOverflowWidth + get() = scrollWidth > clientWidth + +inline val Element.isOverflowHeight + get() = scrollHeight > clientHeight + +inline val Element.isOverflow + get() = isOverflowHeight || isOverflowWidth diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/JSMPPFile.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/JSMPPFile.kt new file mode 100644 index 00000000000..cd5ad68ae73 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/JSMPPFile.kt @@ -0,0 +1,58 @@ +package dev.inmo.micro_utils.common + +import kotlinx.coroutines.await +import org.khronos.webgl.ArrayBuffer +import org.w3c.dom.ErrorEvent +import org.w3c.files.* +import kotlin.js.Promise + +/** + * @suppress + */ +actual typealias MPPFile = File + +private fun createJsError(message: String): JsAny = js("Error(message)") + +fun MPPFile.readBytesPromise() = Promise { success, failure -> + val reader = FileReader() + reader.onload = { + success((reader.result as ArrayBuffer)) + } + reader.onerror = { + val message = (it as ErrorEvent).message + failure(createJsError(message)) + } + reader.readAsArrayBuffer(this) +} + +fun MPPFile.readBytes(): ByteArray { + val reader = FileReaderSync() + return reader.readAsArrayBuffer(this).toByteArray() +} + +private suspend fun MPPFile.dirtyReadBytes(): ByteArray = readBytesPromise().await().toByteArray() + +/** + * @suppress + */ +actual val MPPFile.filename: FileName + get() = FileName(name) +/** + * @suppress + */ +actual val MPPFile.filesize: Long + get() = jsNumberToBigInt(size).toLong() +/** + * @suppress + */ +@Warning("That is not optimized version of bytes allocator. Use asyncBytesAllocator everywhere you can") +actual val MPPFile.bytesAllocatorSync: ByteArrayAllocator + get() = ::readBytes +/** + * @suppress + */ +@Warning("That is not optimized version of bytes allocator. Use asyncBytesAllocator everywhere you can") +actual val MPPFile.bytesAllocator: SuspendByteArrayAllocator + get() = ::dirtyReadBytes + +private fun jsNumberToBigInt(number: JsNumber): JsBigInt = js("BigInt(number)") diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OnClickOutside.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OnClickOutside.kt new file mode 100644 index 00000000000..481f879ff27 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OnClickOutside.kt @@ -0,0 +1,41 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import org.w3c.dom.* +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener + +private fun createEventListener(listener: (Event) -> Unit): JsAny = js("({handleEvent: listener})") + +fun Element.onActionOutside(type: String, options: AddEventListenerOptions? = null, callback: (Event) -> Unit): EventListener { + lateinit var observer: MutationObserver + val listener = createEventListener { it: Event -> + val elementsToCheck = mutableListOf(this@onActionOutside) + while (it.target != this@onActionOutside && elementsToCheck.isNotEmpty()) { + val childrenGettingElement = elementsToCheck.removeFirst() + for (i in 0 until childrenGettingElement.childElementCount) { + elementsToCheck.add(childrenGettingElement.children[i] ?: continue) + } + } + if (elementsToCheck.isEmpty()) { + callback(it) + } + }.unsafeCast() + + if (options == null) { + document.addEventListener(type, listener) + } else { + document.addEventListener(type, listener, options) + } + observer = onRemoved { + if (options == null) { + document.removeEventListener(type, listener) + } else { + document.removeEventListener(type, listener, options) + } + observer.disconnect() + } + return listener +} + +fun Element.onClickOutside(options: AddEventListenerOptions? = null, callback: (Event) -> Unit) = onActionOutside("click", options, callback) diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OpenLink.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OpenLink.kt new file mode 100644 index 00000000000..24315e95559 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/OpenLink.kt @@ -0,0 +1,8 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.window + +fun openLink(link: String, target: String = "_blank", features: String = "") { + window.open(link, target, features) ?.focus() +} + diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ResizeObserver.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ResizeObserver.kt new file mode 100644 index 00000000000..ee9d4ab7411 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/ResizeObserver.kt @@ -0,0 +1,56 @@ +package dev.inmo.micro_utils.common + +import org.w3c.dom.* + +external class ResizeObserver( + callback: (JsArray, ResizeObserver) -> Unit +): JsAny { + fun observe(target: Element, options: JsAny = definedExternally) + + fun unobserve(target: Element) + + fun disconnect() +} + +private fun createObserveOptions(jsBox: JsString?): JsAny = js("({box: jsBox})") + +external interface ResizeObserverSize: JsAny { + val blockSize: Float + val inlineSize: Float +} + +external interface ResizeObserverEntry: JsAny { + val borderBoxSize: JsArray + val contentBoxSize: JsArray + val devicePixelContentBoxSize: JsArray + val contentRect: DOMRectReadOnly + val target: Element +} + +fun ResizeObserver.observe(target: Element, options: ResizeObserverObserveOptions) = observe( + target, + createObserveOptions(options.box?.name?.toJsString()) +) + +class ResizeObserverObserveOptions( + val box: Box? = null +) { + sealed interface Box { + val name: String + + object Content : Box { + override val name: String + get() = "content-box" + } + + object Border : Box { + override val name: String + get() = "border-box" + } + + object DevicePixelContent : Box { + override val name: String + get() = "device-pixel-content-box" + } + } +} diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/SelectFile.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/SelectFile.kt new file mode 100644 index 00000000000..c2af68ad32d --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/SelectFile.kt @@ -0,0 +1,30 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import kotlinx.dom.createElement +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLInputElement +import org.w3c.files.get + +fun selectFile( + inputSetup: (HTMLInputElement) -> Unit = {}, + onFailure: (Throwable) -> Unit = {}, + onFile: (MPPFile) -> Unit +) { + (document.createElement("input") { + (this as HTMLInputElement).apply { + type = "file" + onchange = { + runCatching { + files ?.get(0) ?: error("File must not be null") + }.onSuccess { + onFile(it) + }.onFailure { + onFailure(it) + } + } + inputSetup(this) + } + } as HTMLElement).click() +} + diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/TriggerDownload.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/TriggerDownload.kt new file mode 100644 index 00000000000..369050cf196 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/TriggerDownload.kt @@ -0,0 +1,14 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import org.w3c.dom.HTMLAnchorElement + +fun triggerDownloadFile(filename: String, fileLink: String) { + val hiddenElement = document.createElement("a") as HTMLAnchorElement + + hiddenElement.href = fileLink + hiddenElement.target = "_blank" + hiddenElement.download = filename + hiddenElement.click() +} + diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/Visibility.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/Visibility.kt new file mode 100644 index 00000000000..991b9843901 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/Visibility.kt @@ -0,0 +1,48 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.window +import org.w3c.dom.Element +import org.w3c.dom.css.CSSStyleDeclaration + +sealed class Visibility +data object Visible : Visibility() +data object Invisible : Visibility() +data object Gone : Visibility() + +var CSSStyleDeclaration.visibilityState: Visibility + get() = when { + display == "none" -> Gone + visibility == "hidden" -> Invisible + else -> Visible + } + set(value) { + when (value) { + Visible -> { + if (display == "none") { + display = "initial" + } + visibility = "visible" + } + Invisible -> { + if (display == "none") { + display = "initial" + } + visibility = "hidden" + } + Gone -> { + display = "none" + } + } + } +inline var Element.visibilityState: Visibility + get() = window.getComputedStyle(this).visibilityState + set(value) { + window.getComputedStyle(this).visibilityState = value + } + +inline val Element.isVisible: Boolean + get() = visibilityState == Visible +inline val Element.isInvisible: Boolean + get() = visibilityState == Invisible +inline val Element.isGone: Boolean + get() = visibilityState == Gone diff --git a/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/toFixed.kt b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/toFixed.kt new file mode 100644 index 00000000000..3de164e37d2 --- /dev/null +++ b/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/common/toFixed.kt @@ -0,0 +1,11 @@ +package dev.inmo.micro_utils.common + +actual fun Float.fixed(signs: Int): Float { + return jsToFixed(toDouble().toJsNumber(), signs.coerceIn(FixedSignsRange)).toString().toFloat() +} + +actual fun Double.fixed(signs: Int): Double { + return jsToFixed(toJsNumber(), signs.coerceIn(FixedSignsRange)).toString().toDouble() +} + +private fun jsToFixed(number: JsNumber, signs: Int): JsString = js("number.toFixed(signs)") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f32dc7d040..b41c06d18c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,8 @@ kt = "2.1.20" kt-serialization = "1.8.0" kt-coroutines = "1.10.1" +kotlinx-browser = "0.3" + kslog = "1.4.1" jb-compose = "1.7.3" @@ -55,6 +57,7 @@ kt-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-and kt-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kt-coroutines" } kt-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "kt-coroutines" } +kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser-wasm-js", version.ref = "kotlinx-browser" } ktor-io = { module = "io.ktor:ktor-io", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } diff --git a/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle b/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle index 5e2606e4b50..3d804a813fb 100644 --- a/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle +++ b/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle @@ -27,6 +27,10 @@ kotlin { } } } + wasmJs { + browser() + nodejs() + } androidTarget { publishAllLibraryVariants() compilations.all { @@ -91,6 +95,11 @@ kotlin { implementation kotlin('test-js') } } + wasmJsTest { + dependencies { + implementation kotlin('test-wasm-js') + } + } nativeMain.dependsOn commonMain linuxX64Main.dependsOn nativeMain mingwX64Main.dependsOn nativeMain diff --git a/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle b/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle index 574b69a0107..e626931c55b 100644 --- a/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle +++ b/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle @@ -27,6 +27,10 @@ kotlin { } } } + wasmJs { + browser() + nodejs() + } androidTarget { publishAllLibraryVariants() compilations.all { @@ -71,6 +75,11 @@ kotlin { implementation kotlin('test-js') } } + wasmJsTest { + dependencies { + implementation kotlin('test-wasm-js') + } + } nativeMain.dependsOn commonMain linuxX64Main.dependsOn nativeMain mingwX64Main.dependsOn nativeMain diff --git a/gradle/templates/mppJvmJsLinuxMingwProject.gradle b/gradle/templates/mppJvmJsLinuxMingwProject.gradle index b62c12dc548..1e0ac854f87 100644 --- a/gradle/templates/mppJvmJsLinuxMingwProject.gradle +++ b/gradle/templates/mppJvmJsLinuxMingwProject.gradle @@ -27,6 +27,10 @@ kotlin { } } } + wasmJs { + browser() + nodejs() + } linuxX64() mingwX64() @@ -55,6 +59,11 @@ kotlin { implementation kotlin('test-js') } } + wasmJsTest { + dependencies { + implementation kotlin('test-wasm-js') + } + } nativeMain.dependsOn commonMain linuxX64Main.dependsOn nativeMain diff --git a/gradle/templates/mppProjectWithSerializationAndCompose.gradle b/gradle/templates/mppProjectWithSerializationAndCompose.gradle index 0a5c853bf56..4ee03b48c2a 100644 --- a/gradle/templates/mppProjectWithSerializationAndCompose.gradle +++ b/gradle/templates/mppProjectWithSerializationAndCompose.gradle @@ -27,6 +27,10 @@ kotlin { } } } + wasmJs { + browser() + nodejs() + } androidTarget { publishAllLibraryVariants() compilations.all { @@ -72,6 +76,11 @@ kotlin { implementation kotlin('test-js') } } + wasmJsTest { + dependencies { + implementation kotlin('test-wasm-js') + } + } androidUnitTest { dependencies { implementation kotlin('test-junit') diff --git a/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt b/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt new file mode 100644 index 00000000000..be3d2d7c538 --- /dev/null +++ b/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt @@ -0,0 +1,63 @@ +package dev.inmo.micro_utils.ktor.client + +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.coroutines.LinkedSupervisorJob +import dev.inmo.micro_utils.coroutines.launchLoggingDropExceptions +import dev.inmo.micro_utils.ktor.common.TemporalFileId +import io.ktor.client.HttpClient +import io.ktor.client.content.* +import kotlinx.coroutines.* +import org.w3c.xhr.* +import org.w3c.xhr.XMLHttpRequest.Companion.DONE + +suspend fun tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: ProgressListener +): TemporalFileId { + val formData = FormData() + val answer = CompletableDeferred(currentCoroutineContext().job) + val subscope = CoroutineScope(currentCoroutineContext().LinkedSupervisorJob()) + + formData.append( + "data", + file + ) + + val request = XMLHttpRequest() + request.responseType = XMLHttpRequestResponseType.TEXT + request.upload.onprogress = { + subscope.launchLoggingDropExceptions { onUpload.onProgress(it.loaded.toString().toLong(), it.total.toString().toLong()) } + } + request.onload = { + if (request.status == 200.toShort()) { + answer.complete(TemporalFileId(request.responseText)) + } else { + answer.completeExceptionally(Exception("Something went wrong: $it")) + } + } + request.onerror = { + answer.completeExceptionally(Exception("Something went wrong: $it")) + } + request.open("POST", fullTempUploadDraftPath, true) + request.send(formData) + + answer.invokeOnCompletion { + runCatching { + if (request.readyState != DONE) { + request.abort() + } + } + } + + return answer.await().also { + subscope.cancel() + } +} + + +actual suspend fun HttpClient.tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: ProgressListener +): TemporalFileId = dev.inmo.micro_utils.ktor.client.tempUpload(fullTempUploadDraftPath, file, onUpload) diff --git a/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualUniUpload.kt b/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualUniUpload.kt new file mode 100644 index 00000000000..60ac8ca1b9e --- /dev/null +++ b/ktor/client/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualUniUpload.kt @@ -0,0 +1,97 @@ +package dev.inmo.micro_utils.ktor.client + +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.coroutines.LinkedSupervisorJob +import dev.inmo.micro_utils.coroutines.launchLoggingDropExceptions +import io.ktor.client.HttpClient +import io.ktor.client.content.* +import io.ktor.http.Headers +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.io.readByteArray +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.StringFormat +import kotlinx.serialization.encodeToString +import org.khronos.webgl.toInt8Array +import org.w3c.files.Blob +import org.w3c.xhr.FormData +import org.w3c.xhr.TEXT +import org.w3c.xhr.XMLHttpRequest +import org.w3c.xhr.XMLHttpRequestResponseType + +/** + * Will execute submitting of multipart data request + * + * @param data [Map] where keys will be used as names for multipart parts and values as values. If you will pass + * [dev.inmo.micro_utils.common.MPPFile] (File from JS or JVM platform). Also you may pass [UniUploadFileInfo] as value + * in case you wish to pass other source of multipart binary data than regular file + * @suppress + */ +actual suspend fun HttpClient.uniUpload( + url: String, + data: Map, + resultDeserializer: DeserializationStrategy, + headers: Headers, + stringFormat: StringFormat, + onUpload: ProgressListener +): T? { + val formData = FormData() + val answer = CompletableDeferred(currentCoroutineContext().job) + val subscope = CoroutineScope(currentCoroutineContext().LinkedSupervisorJob()) + + data.forEach { (k, v) -> + when (v) { + is MPPFile -> formData.append( + k, + v + ) + is UniUploadFileInfo -> formData.append( + k, + Blob(arrayOf(v.inputAllocator().readByteArray().toInt8Array()).toJsArray().unsafeCast()), + v.fileName.name + ) + else -> formData.append( + k, + stringFormat.encodeToString(v) + ) + } + } + + val request = XMLHttpRequest() + headers.forEach { s, strings -> + request.setRequestHeader(s, strings.joinToString()) + } + request.responseType = XMLHttpRequestResponseType.TEXT + request.upload.onprogress = { + subscope.launchLoggingDropExceptions { onUpload.onProgress(it.loaded.toString().toLong(), it.total.toString().toLong()) } + } + request.onload = { + if (request.status == 200.toShort()) { + answer.complete( + stringFormat.decodeFromString(resultDeserializer, request.responseText) + ) + } else { + answer.completeExceptionally(Exception("Something went wrong: $it")) + } + } + request.onerror = { + answer.completeExceptionally(Exception("Something went wrong: $it")) + } + request.open("POST", url, true) + request.send(formData) + + answer.invokeOnCompletion { + runCatching { + if (request.readyState != XMLHttpRequest.DONE) { + request.abort() + } + } + } + + return answer.await().also { + subscope.cancel() + } +} diff --git a/ktor/common/build.gradle b/ktor/common/build.gradle index 2e044e41e55..be8b564ad28 100644 --- a/ktor/common/build.gradle +++ b/ktor/common/build.gradle @@ -16,5 +16,11 @@ kotlin { api libs.ktor.io } } + + wasmJsMain { + dependencies { + + } + } } } diff --git a/ktor/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/common/ActualMPPFileInput.kt b/ktor/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/common/ActualMPPFileInput.kt new file mode 100644 index 00000000000..8144362a2d9 --- /dev/null +++ b/ktor/common/src/wasmJsMain/kotlin/dev/inmo/micro_utils/ktor/common/ActualMPPFileInput.kt @@ -0,0 +1,7 @@ +package dev.inmo.micro_utils.ktor.common + +import dev.inmo.micro_utils.common.* +import io.ktor.utils.io.core.ByteReadPacket +import io.ktor.utils.io.core.Input + +actual fun MPPFile.input(): Input = ByteReadPacket(readBytes()) diff --git a/repos/common/tests/build.gradle b/repos/common/tests/build.gradle index 22158b48a9f..173e538b797 100644 --- a/repos/common/tests/build.gradle +++ b/repos/common/tests/build.gradle @@ -19,6 +19,10 @@ kotlin { browser() nodejs() } + wasmJs { + browser() + nodejs() + } androidTarget { compilations.all { kotlinOptions { diff --git a/startup/plugin/build.gradle b/startup/plugin/build.gradle index aadfac944d7..239fb6c0dea 100644 --- a/startup/plugin/build.gradle +++ b/startup/plugin/build.gradle @@ -21,6 +21,11 @@ kotlin { api libs.uuid } } + wasmJsMain { + dependencies { + api libs.uuid + } + } linuxX64Main { dependencies { api libs.uuid diff --git a/startup/plugin/src/wasmJsMain/kotlin/StartPluginSerializer.wasm.kt b/startup/plugin/src/wasmJsMain/kotlin/StartPluginSerializer.wasm.kt new file mode 100644 index 00000000000..db3d48e3e5e --- /dev/null +++ b/startup/plugin/src/wasmJsMain/kotlin/StartPluginSerializer.wasm.kt @@ -0,0 +1,13 @@ +package dev.inmo.micro_utils.startup.plugin + +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal actual fun alternativeDeserialize(decoder: Decoder): StartPlugin? { + return null +} + +internal actual fun alternativeSerialize( + encoder: Encoder, + value: StartPlugin +): Boolean = false \ No newline at end of file