diff --git a/compose-imageviewer/README.md b/compose-imageviewer/README.md index 7acb0d8..2a9f6c6 100755 --- a/compose-imageviewer/README.md +++ b/compose-imageviewer/README.md @@ -21,7 +21,7 @@ Check out the repo, navigate to the project folder, and use the following comman ### Run Web version via Gradle -`./gradlew :shared:wasmRun` +`./gradlew :webApp:wasmRun` ### Run Desktop version via Gradle diff --git a/compose-imageviewer/settings.gradle.kts b/compose-imageviewer/settings.gradle.kts index 06f5ed5..4190cb0 100644 --- a/compose-imageviewer/settings.gradle.kts +++ b/compose-imageviewer/settings.gradle.kts @@ -27,3 +27,4 @@ rootProject.name = "imageviewer" include(":androidApp") include(":shared") include(":desktopApp") +include(":webApp") diff --git a/compose-imageviewer/shared/build.gradle.kts b/compose-imageviewer/shared/build.gradle.kts index d82d6cd..eab231b 100755 --- a/compose-imageviewer/shared/build.gradle.kts +++ b/compose-imageviewer/shared/build.gradle.kts @@ -21,25 +21,11 @@ kotlin { // iosSimulatorArm64() js(IR) { - moduleName = "imageviewer" browser() - binaries.executable() } wasm { - moduleName = "imageviewer" - browser { - commonWebpackConfig { - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).copy( - open = mapOf( - "app" to mapOf( - "name" to "google chrome", - ) - ), - ) - } - } - binaries.executable() + browser() } // cocoapods { @@ -111,10 +97,6 @@ kotlin { } } -compose.experimental { - web.application {} -} - android { compileSdk = 33 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") @@ -131,5 +113,5 @@ android { } } -// Use a proper version of webpack, TODO remove after updating to Kotlin 1.9. -rootProject.the().versions.webpack.version = "5.76.2" \ No newline at end of file +// Use a proper version of webpack, TODO remove after updating to Kotlin 1.9. +rootProject.the().versions.webpack.version = "5.76.2" diff --git a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt index aca0c3e..5345f43 100644 --- a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt +++ b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt @@ -34,7 +34,7 @@ enum class ExternalImageViewerEvent { @OptIn(ExperimentalAnimationApi::class) @Composable -internal fun ImageViewerCommon( +fun ImageViewerCommon( dependencies: Dependencies, externalEvents: Flow = emptyFlow() ) { diff --git a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt index b0056ac..ac70937 100755 --- a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt +++ b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/style/Palette.kt @@ -48,7 +48,7 @@ object ImageviewerColors { } @Composable -internal fun ImageViewerTheme(content: @Composable () -> Unit) { +fun ImageViewerTheme(content: @Composable () -> Unit) { isSystemInDarkTheme() // todo check and change colors MaterialTheme( colorScheme = MaterialTheme.colorScheme.copy( diff --git a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt index 6a0ca3e..0b7ab4c 100755 --- a/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt +++ b/compose-imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/Toast.kt @@ -22,7 +22,7 @@ sealed interface ToastState { } @Composable -internal fun Toast( +fun Toast( state: MutableState ) { val value = state.value diff --git a/compose-imageviewer/shared/src/wasmMain/kotlin/Main.wasm.kt b/compose-imageviewer/shared/src/wasmMain/kotlin/Main.wasm.kt deleted file mode 100644 index 123708c..0000000 --- a/compose-imageviewer/shared/src/wasmMain/kotlin/Main.wasm.kt +++ /dev/null @@ -1,188 +0,0 @@ -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Surface -import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.window.Window -import example.imageviewer.* -import example.imageviewer.ImageViewerCommon -import example.imageviewer.core.BitmapFilter -import example.imageviewer.core.FilterType -import example.imageviewer.model.* -import example.imageviewer.model.filtration.BlurFilter -import example.imageviewer.model.filtration.GrayScaleFilter -import example.imageviewer.model.filtration.PixelFilter -import example.imageviewer.style.ImageViewerTheme -import example.imageviewer.utils.ioDispatcher -import example.imageviewer.view.Toast -import example.imageviewer.view.ToastState -import kotlinx.coroutines.CoroutineScope -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.Resource -import org.khronos.webgl.ArrayBuffer -import org.khronos.webgl.Int8Array -import org.w3c.xhr.XMLHttpRequest -import org.w3c.xhr.XMLHttpRequestResponseType -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlin.wasm.unsafe.* -import kotlin.wasm.unsafe.withScopedMemoryAllocator -import kotlin.wasm.unsafe.UnsafeWasmMemoryApi -import androidx.compose.ui.window.CanvasBasedWindow - -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - CanvasBasedWindow("ImageViewer") { - ImageViewerWeb() - } -} - -@Composable -internal fun ImageViewerWeb() { - val toastState = remember { mutableStateOf(ToastState.Hidden) } - val ioScope: CoroutineScope = rememberCoroutineScope { ioDispatcher } - val dependencies = remember(ioScope) { getDependencies(ioScope, toastState) } - - ImageViewerTheme { - Surface( - modifier = Modifier.fillMaxSize() - ) { - ImageViewerCommon( - dependencies = dependencies - ) - Toast(toastState) - } - } -} - -@OptIn(ExperimentalResourceApi::class) -fun getDependencies(ioScope: CoroutineScope, toastState: MutableState) = object : Dependencies { - override val pictures: SnapshotStateList = mutableStateListOf(*resourcePictures) - override val ioScope: CoroutineScope = ioScope - override fun getFilter(type: FilterType): BitmapFilter = when (type) { - FilterType.GrayScale -> GrayScaleFilter() - FilterType.Pixel -> PixelFilter() - FilterType.Blur -> BlurFilter() - } - - override val localization: Localization = object : Localization { - override val appName = "ImageViewer" - override val loading = "Loading images..." - override val repoEmpty = "Repository is empty." - override val noInternet = "No internet access." - override val repoInvalid = "List of images in current repository is invalid or empty." - override val refreshUnavailable = "Cannot refresh images." - override val loadImageUnavailable = "Cannot load full size image." - override val lastImage = "This is last image." - override val firstImage = "This is first image." - override val picture = "Picture:" - override val size = "Size:" - override val pixels = "pixels." - override val back = "Back" - } - - override val httpClient: WrappedHttpClient = object : WrappedHttpClient { - - override suspend fun getAsBytes(urlString: String): ByteArray { - return urlResource(urlString).readBytes() - } - } - - private fun ContentRepository.cacheByUrlAdapter(): ContentRepository { - val original = this - return object : ContentRepository { - val cache = mutableMapOf() - override suspend fun loadContent(url: String): ImageBitmap { - return cache.getOrPut(url) { - original.loadContent(url) - } - } - } - } - - override val imageRepository: ContentRepository = - createNetworkRepository(httpClient) - .adapter { it.toImageBitmap() } - .cacheByUrlAdapter() - - override val notification: Notification = object : PopupNotification(localization) { - override fun showPopUpMessage(text: String) { - toastState.value = ToastState.Shown(text) - } - } -} - -@ExperimentalResourceApi -private fun urlResource(path: String): Resource = JSResourceImpl(path) - -@OptIn(ExperimentalResourceApi::class) -private abstract class AbstractResourceImpl(val path: String) : Resource { - override fun equals(other: Any?): Boolean { - if (this === other) return true - return if (other is AbstractResourceImpl) { - path == other.path - } else { - false - } - } - - override fun hashCode(): Int { - return path.hashCode() - } -} - -@ExperimentalResourceApi -private class JSResourceImpl(path: String) : AbstractResourceImpl(path) { - override suspend fun readBytes(): ByteArray { - return suspendCoroutine { continuation -> - val req = XMLHttpRequest() - req.open("GET", path, true) - req.responseType = "arraybuffer".asDynamic().unsafeCast() - - req.onload = { _ -> - val arrayBuffer = req.response - if (arrayBuffer is ArrayBuffer) { - val size = arrayBuffer.byteLength - continuation.resume(arrayBuffer.toByteArray()) - } else { - continuation.resumeWithException(MissingResourceException(path)) - } - null - } - req.send(null) - } - } -} - -private class MissingResourceException constructor(path: String) : - Exception("Missing resource with path: $path") - - -private fun ArrayBuffer.toByteArray(): ByteArray { - val source = Int8Array(this, 0, byteLength) - return jsInt8ArrayToKotlinByteArray(source) -} - -@JsFun( - """ (src, size, dstAddr) => { - const mem8 = new Int8Array(wasmExports.memory.buffer, dstAddr, size); - mem8.set(src); - } -""" -) -internal external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr: Int) - -internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray { - val size = x.length - - @OptIn(UnsafeWasmMemoryApi::class) - return withScopedMemoryAllocator { allocator -> - val memBuffer = allocator.allocate(size) - val dstAddress = memBuffer.address.toInt() - jsExportInt8ArrayToWasm(x, size, dstAddress) - ByteArray(size) { i -> (memBuffer + i).loadByte() } - } -} diff --git a/compose-imageviewer/webApp/build.gradle.kts b/compose-imageviewer/webApp/build.gradle.kts new file mode 100644 index 0000000..5a43082 --- /dev/null +++ b/compose-imageviewer/webApp/build.gradle.kts @@ -0,0 +1,69 @@ +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +val copyJsResources = tasks.create("copyJsResourcesWorkaround", Copy::class.java) { + from(project(":shared").file("src/commonMain/resources")) + into("build/processedResources/js/main") +} + +val copyWasmResources = tasks.create("copyWasmResourcesWorkaround", Copy::class.java) { + from(project(":shared").file("src/commonMain/resources")) + into("build/processedResources/wasm/main") +} + +afterEvaluate { + project.tasks.getByName("jsProcessResources").finalizedBy(copyJsResources) + project.tasks.getByName("wasmProcessResources").finalizedBy(copyWasmResources) +} + +kotlin { + js(IR) { + moduleName = "imageviewer" + browser() + binaries.executable() + } + + wasm { + moduleName = "imageviewer" + browser { + commonWebpackConfig { + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).copy( + open = mapOf( + "app" to mapOf( + "name" to "google chrome", + ) + ), + ) + } + } + binaries.executable() + } + + sourceSets { + val jsWasmMain by creating { + dependencies { + implementation(project(":shared")) + implementation(compose.runtime) + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.components.resources) + } + } + val jsMain by getting { + dependsOn(jsWasmMain) + } + val wasmMain by getting { + dependsOn(jsWasmMain) + } + } +} + +compose.experimental { + web.application {} +} diff --git a/compose-imageviewer/webApp/src/jsMain/kotlin/Actuals.kt b/compose-imageviewer/webApp/src/jsMain/kotlin/Actuals.kt new file mode 100644 index 0000000..1907493 --- /dev/null +++ b/compose-imageviewer/webApp/src/jsMain/kotlin/Actuals.kt @@ -0,0 +1,14 @@ +import example.imageviewer.model.WrappedHttpClient +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.request.* +import io.ktor.client.statement.* + +actual fun createWrappedHttpClient(): WrappedHttpClient { + return object : WrappedHttpClient { + private val ktorClient = HttpClient(JsClient()) + override suspend fun getAsBytes(urlString: String): ByteArray { + return ktorClient.get(urlString).readBytes() + } + } +} diff --git a/compose-imageviewer/webApp/src/jsMain/kotlin/Main.kt b/compose-imageviewer/webApp/src/jsMain/kotlin/Main.kt new file mode 100644 index 0000000..8c15620 --- /dev/null +++ b/compose-imageviewer/webApp/src/jsMain/kotlin/Main.kt @@ -0,0 +1,19 @@ +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import example.imageviewer.* +import example.imageviewer.model.* +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import org.jetbrains.skiko.wasm.onWasmReady + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + onWasmReady { + CanvasBasedWindow("ImageViewer") { + ImageViewerWeb() + } + } +} diff --git a/compose-imageviewer/shared/src/jsMain/resources/index.html b/compose-imageviewer/webApp/src/jsMain/resources/index.html similarity index 55% rename from compose-imageviewer/shared/src/jsMain/resources/index.html rename to compose-imageviewer/webApp/src/jsMain/resources/index.html index 6ab6ea9..283ee9b 100644 --- a/compose-imageviewer/shared/src/jsMain/resources/index.html +++ b/compose-imageviewer/webApp/src/jsMain/resources/index.html @@ -6,11 +6,7 @@ -
-
- -
-
+ - \ No newline at end of file + diff --git a/compose-imageviewer/shared/src/jsMain/kotlin/Main.js.kt b/compose-imageviewer/webApp/src/jsWasmMain/kotlin/Common.kt similarity index 79% rename from compose-imageviewer/shared/src/jsMain/kotlin/Main.js.kt rename to compose-imageviewer/webApp/src/jsWasmMain/kotlin/Common.kt index 6eb4eae..c8cf5d0 100644 --- a/compose-imageviewer/shared/src/jsMain/kotlin/Main.js.kt +++ b/compose-imageviewer/webApp/src/jsWasmMain/kotlin/Common.kt @@ -2,13 +2,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Surface import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.window.CanvasBasedWindow -import androidx.compose.ui.window.Window import example.imageviewer.* -import example.imageviewer.ImageViewerCommon import example.imageviewer.core.BitmapFilter import example.imageviewer.core.FilterType import example.imageviewer.model.* @@ -19,22 +15,10 @@ import example.imageviewer.style.ImageViewerTheme import example.imageviewer.utils.ioDispatcher import example.imageviewer.view.Toast import example.imageviewer.view.ToastState -import io.ktor.client.* -import io.ktor.client.engine.js.* -import io.ktor.client.request.* -import io.ktor.client.statement.* import kotlinx.coroutines.CoroutineScope import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.skiko.wasm.onWasmReady -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - onWasmReady { - CanvasBasedWindow("ImageViewer") { - ImageViewerWeb() - } - } -} +expect fun createWrappedHttpClient(): WrappedHttpClient @Composable internal fun ImageViewerWeb() { @@ -80,20 +64,29 @@ fun getDependencies(ioScope: CoroutineScope, toastState: MutableState.cacheByUrlAdapter(): ContentRepository { + val original = this + return object : ContentRepository { + val cache = mutableMapOf() + override suspend fun loadContent(url: String): ImageBitmap { + return cache.getOrPut(url) { + original.loadContent(url) + } + } } } override val imageRepository: ContentRepository = createNetworkRepository(httpClient) .adapter { it.toImageBitmap() } + .cacheByUrlAdapter() override val notification: Notification = object : PopupNotification(localization) { override fun showPopUpMessage(text: String) { toastState.value = ToastState.Shown(text) } } -} \ No newline at end of file +} diff --git a/compose-imageviewer/webApp/src/wasmMain/kotlin/Actuals.kt b/compose-imageviewer/webApp/src/wasmMain/kotlin/Actuals.kt new file mode 100644 index 0000000..3fb53f2 --- /dev/null +++ b/compose-imageviewer/webApp/src/wasmMain/kotlin/Actuals.kt @@ -0,0 +1,88 @@ +import androidx.compose.runtime.* +import example.imageviewer.* +import example.imageviewer.model.* +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.Resource +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Int8Array +import org.w3c.xhr.XMLHttpRequest +import org.w3c.xhr.XMLHttpRequestResponseType +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.wasm.unsafe.* + +actual fun createWrappedHttpClient(): WrappedHttpClient { + return object : WrappedHttpClient { + override suspend fun getAsBytes(urlString: String): ByteArray { + return JSResourceImpl(urlString).readBytes() + } + } +} + +@OptIn(ExperimentalResourceApi::class) +private abstract class AbstractResourceImpl(val path: String) : Resource { + override fun equals(other: Any?): Boolean { + if (this === other) return true + return if (other is AbstractResourceImpl) { + path == other.path + } else { + false + } + } + + override fun hashCode(): Int { + return path.hashCode() + } +} +private class JSResourceImpl(path: String) : AbstractResourceImpl(path) { + override suspend fun readBytes(): ByteArray { + return suspendCoroutine { continuation -> + val req = XMLHttpRequest() + req.open("GET", path, true) + req.responseType = "arraybuffer".asDynamic().unsafeCast() + + req.onload = { _ -> + val arrayBuffer = req.response + if (arrayBuffer is ArrayBuffer) { + val size = arrayBuffer.byteLength + continuation.resume(arrayBuffer.toByteArray()) + } else { + continuation.resumeWithException(MissingResourceException(path)) + } + null + } + req.send(null) + } + } +} + +private class MissingResourceException constructor(path: String) : + Exception("Missing resource with path: $path") + + +private fun ArrayBuffer.toByteArray(): ByteArray { + val source = Int8Array(this, 0, byteLength) + return jsInt8ArrayToKotlinByteArray(source) +} + +@JsFun( + """ (src, size, dstAddr) => { + const mem8 = new Int8Array(wasmExports.memory.buffer, dstAddr, size); + mem8.set(src); + } +""" +) +internal external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr: Int) + +internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray { + val size = x.length + + @OptIn(UnsafeWasmMemoryApi::class) + return withScopedMemoryAllocator { allocator -> + val memBuffer = allocator.allocate(size) + val dstAddress = memBuffer.address.toInt() + jsExportInt8ArrayToWasm(x, size, dstAddress) + ByteArray(size) { i -> (memBuffer + i).loadByte() } + } +} diff --git a/compose-imageviewer/webApp/src/wasmMain/kotlin/Main.wasm.kt b/compose-imageviewer/webApp/src/wasmMain/kotlin/Main.wasm.kt new file mode 100644 index 0000000..ef398a3 --- /dev/null +++ b/compose-imageviewer/webApp/src/wasmMain/kotlin/Main.wasm.kt @@ -0,0 +1,13 @@ +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import example.imageviewer.* +import example.imageviewer.model.* +import kotlin.wasm.unsafe.* + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + CanvasBasedWindow("ImageViewer") { + ImageViewerWeb() + } +} diff --git a/compose-imageviewer/shared/src/wasmMain/resources/index.html b/compose-imageviewer/webApp/src/wasmMain/resources/index.html similarity index 100% rename from compose-imageviewer/shared/src/wasmMain/resources/index.html rename to compose-imageviewer/webApp/src/wasmMain/resources/index.html diff --git a/compose-imageviewer/shared/src/wasmMain/resources/load.mjs b/compose-imageviewer/webApp/src/wasmMain/resources/load.mjs similarity index 100% rename from compose-imageviewer/shared/src/wasmMain/resources/load.mjs rename to compose-imageviewer/webApp/src/wasmMain/resources/load.mjs