diff --git a/.fleet/run.json b/.fleet/run.json
index 823e91e1..e4c93de0 100644
--- a/.fleet/run.json
+++ b/.fleet/run.json
@@ -24,6 +24,14 @@
":demo-app:run"
]
},
+ {
+ "name": "Js-App",
+ "type": "gradle",
+ "workingDir": "$PROJECT_DIR$",
+ "tasks": [
+ ":demo-app:jsRun"
+ ]
+ },
{
"name": "Reformat",
"type": "gradle",
diff --git a/README.md b/README.md
index 92613e7a..dc19e3eb 100644
--- a/README.md
+++ b/README.md
@@ -33,9 +33,11 @@ Android and iOS support is implemented with
[MapLibre Native](https://github.com/maplibre/maplibre-native). A broad set of
features are supported.
-Desktop support is implemented with
-[MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) and
-[KCEF](https://github.com/DatL4g/KCEF). It's currently **very** limited and
-experimental.
+Web support is implemented with
+[MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js).
+
+Desktop support is also implemented with MapLibre GL JS and
+[KCEF](https://github.com/DatL4g/KCEF) for now, though we'd like to switch to
+MapLibre Native.
Web is not yet supported.
diff --git a/build.gradle.kts b/build.gradle.kts
index 48c437a0..6efeef65 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -48,6 +48,8 @@ dependencies {
dokka(project(":lib:maplibre-compose:"))
dokka(project(":lib:maplibre-compose-expressions:"))
dokka(project(":lib:maplibre-compose-material3:"))
+ dokka(project(":lib:kotlin-maplibre-js"))
+ dokka(project(":lib:compose-html-interop:"))
}
spotless {
diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts
index 1fdde783..ccfb1610 100644
--- a/demo-app/build.gradle.kts
+++ b/demo-app/build.gradle.kts
@@ -47,6 +47,10 @@ kotlin {
iosSimulatorArm64()
iosX64()
jvm("desktop")
+ js(IR) {
+ browser { commonWebpackConfig { outputFileName = "app.js" } }
+ binaries.executable()
+ }
cocoapods {
summary = "MapLibre Compose demo app"
@@ -86,6 +90,7 @@ kotlin {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
+ implementation(libs.kotlinx.coroutines.android)
implementation(libs.ktor.client.okhttp)
}
@@ -94,6 +99,12 @@ kotlin {
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
+ implementation(libs.ktor.client.okhttp)
+ }
+
+ jsMain.dependencies {
+ implementation(compose.html.core)
+ implementation(libs.ktor.client.js)
}
commonTest.dependencies {
diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt
deleted file mode 100644
index e1a6f007..00000000
--- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.sargunv.maplibrecompose.demoapp
-
-expect object Platform {
- val supportsBlending: Boolean
- val supportsFps: Boolean
- val supportsCamera: Boolean
- val supportsLayers: Boolean
-}
diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt
index d8996b55..c5e90f99 100644
--- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt
+++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt
@@ -76,3 +76,10 @@ internal class FrameRateState(private val spinner: String = "◐◓◑◒") {
}
@Composable expect fun getDefaultColorScheme(isDark: Boolean = false): ColorScheme
+
+expect object Platform {
+ val supportsBlending: Boolean
+ val supportsFps: Boolean
+ val supportsCamera: Boolean
+ val supportsLayers: Boolean
+}
diff --git a/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/Main.kt b/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/Main.kt
index ba1be0bd..e69e177b 100644
--- a/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/Main.kt
+++ b/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/Main.kt
@@ -30,3 +30,10 @@ fun main() {
actual fun getDefaultColorScheme(isDark: Boolean): ColorScheme {
return if (isDark) darkColorScheme() else lightColorScheme()
}
+
+actual object Platform {
+ actual val supportsBlending = false
+ actual val supportsFps = false
+ actual val supportsCamera = false
+ actual val supportsLayers = false
+}
diff --git a/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt b/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt
deleted file mode 100644
index 1f56fad6..00000000
--- a/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.sargunv.maplibrecompose.demoapp
-
-actual object Platform {
- actual val supportsBlending = false
- actual val supportsFps = false
- actual val supportsCamera = false
- actual val supportsLayers = false
-}
diff --git a/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/MainViewController.kt b/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/MainViewController.kt
index 81c17bf2..1b26eba1 100644
--- a/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/MainViewController.kt
+++ b/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/MainViewController.kt
@@ -13,3 +13,10 @@ fun MainViewController() = ComposeUIViewController { DemoApp() }
actual fun getDefaultColorScheme(isDark: Boolean): ColorScheme {
return if (isDark) darkColorScheme() else lightColorScheme()
}
+
+actual object Platform {
+ actual val supportsBlending = true
+ actual val supportsFps = true
+ actual val supportsCamera = true
+ actual val supportsLayers = true
+}
diff --git a/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt b/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt
deleted file mode 100644
index ff33057d..00000000
--- a/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.sargunv.maplibrecompose.demoapp
-
-actual object Platform {
- actual val supportsBlending = true
- actual val supportsFps = true
- actual val supportsCamera = true
- actual val supportsLayers = true
-}
diff --git a/demo-app/src/jsMain/kotlin/dev/sargunv/maplibrecompose/demoapp/main.kt b/demo-app/src/jsMain/kotlin/dev/sargunv/maplibrecompose/demoapp/main.kt
new file mode 100644
index 00000000..8038121e
--- /dev/null
+++ b/demo-app/src/jsMain/kotlin/dev/sargunv/maplibrecompose/demoapp/main.kt
@@ -0,0 +1,27 @@
+package dev.sargunv.maplibrecompose.demoapp
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.window.ComposeViewport
+import kotlinx.browser.document
+import org.jetbrains.skiko.wasm.onWasmReady
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() {
+ onWasmReady { ComposeViewport(document.body!!) { DemoApp() } }
+}
+
+@Composable
+actual fun getDefaultColorScheme(isDark: Boolean): ColorScheme {
+ return if (isDark) darkColorScheme() else lightColorScheme()
+}
+
+actual object Platform {
+ actual val supportsBlending = false
+ actual val supportsFps = false
+ actual val supportsCamera = false
+ actual val supportsLayers = false
+}
diff --git a/demo-app/src/jsMain/resources/index.html b/demo-app/src/jsMain/resources/index.html
new file mode 100644
index 00000000..fcd23a78
--- /dev/null
+++ b/demo-app/src/jsMain/resources/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ MapLibre Compose demo app
+
+
+
+
+
+
+
diff --git a/demo-app/src/jsMain/resources/styles.css b/demo-app/src/jsMain/resources/styles.css
new file mode 100644
index 00000000..8e94d43f
--- /dev/null
+++ b/demo-app/src/jsMain/resources/styles.css
@@ -0,0 +1,7 @@
+html, body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
diff --git a/docs/docs/index.md b/docs/docs/index.md
index 9a537c24..279d9b4e 100644
--- a/docs/docs/index.md
+++ b/docs/docs/index.md
@@ -21,29 +21,31 @@ to express an interactive map API in Compose.
Android and iOS support is implemented with [MapLibre Native][maplibre-native].
-Desktop support is implemented with [MapLibre GL JS][maplibre-js] and
-[KCEF][kcef].
-
-| Feature | Android | iOS | Desktop | Web |
-| ------------------------------------------------- | ------------------ | ------------------ | ------------------ | --- |
-| Render a map | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
-| Overlay Compose UI over the map | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Load Compose resource URIs | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
-| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
-| Configure gestures (pan, zoom, rotate, pitch) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
-| Respond to a map click or long click | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Query visible map features | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Get, set, and animate the camera position | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Convert between screen and geographic coordinates | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Get the currently visible region and bounding box | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Insert, remove, and replace layers | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Configure layers with expressions | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: |
-| Add annotations | :x: | :x: | :x: | :x: |
-| Snapshot the map as an image | :x: | :x: | :x: | :x: |
-| Configure the offline cache | :x: | :x: | :x: | :x: |
-| Configure layer transitions | :x: | :x: | :x: | :x: |
+Web support is implemented with [MapLibre GL JS][maplibre-js].
+
+Desktop support is also implemented with MapLibre GL JS in [KCEF][kcef] for now,
+though we'd like to switch to MapLibre Native.
+
+| Feature | Android | iOS | Desktop | Web |
+| ------------------------------------------------- | ------------------ | ------------------ | ------------------ | ------------------ |
+| Render a map | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
+| Overlay Compose UI over the map | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Load Compose resource URIs | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
+| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
+| Configure gestures (pan, zoom, rotate, pitch) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
+| Respond to a map click or long click | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Query visible map features | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Get, set, and animate the camera position | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Convert between screen and geographic coordinates | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Get the currently visible region and bounding box | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Insert, remove, and replace layers | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Configure layers with expressions | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: |
+| Add annotations | :x: | :x: | :x: | :x: |
+| Snapshot the map as an image | :x: | :x: | :x: | :x: |
+| Configure the offline cache | :x: | :x: | :x: | :x: |
+| Configure layer transitions | :x: | :x: | :x: | :x: |
[compose]: https://www.jetbrains.com/compose-multiplatform/
[maplibre]: https://maplibre.org/
diff --git a/gradle.properties b/gradle.properties
index c20a93e7..33845fea 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,6 +4,8 @@ androidTargetSdk=35
iosDeploymentTarget=12.0
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx2048M
+kotlin.incremental.wasm=true
+org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true
# Blocker: https://github.com/diffplug/spotless/issues/2347
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 48e95c28..4cac7983 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,8 +2,8 @@
androidx-activity = "1.9.3"
androidx-composeUi = "1.7.6"
androidx-navigation = "2.8.0-alpha11"
-webview = "1.9.40"
kermit = "2.0.5"
+kotlinx-browser = "0.3"
kotlinx-coroutines = "1.9.0"
ktor = "3.0.3"
maplibre-android-sdk = "11.7.1"
@@ -13,6 +13,7 @@ maplibre-js = "4.7.1"
spatialk = "0.3.0"
webpack-html = "5.6.3"
webpack-htmlInlineScript = "3.2.1"
+webview = "1.9.40"
gradle-android = "8.7.3"
gradle-compose = "1.7.3"
@@ -29,11 +30,14 @@ tool-prettier = "3.4.2"
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-composeUi-testManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-composeUi" }
androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" }
webview = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "webview" }
kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index 28fe7069..caf6859b 100644
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -112,7 +112,7 @@
B92378962B6B1156000C7307 /* Frameworks */,
7555FF79242A565900829871 /* Resources */,
FB29095E4E4AE33FC6A4F030 /* [CP] Embed Pods Frameworks */,
- E8A0EF8E3B4DDB502C870BB0 /* [CP] Copy Pods Resources */,
+ 1DBF57571B44936782348BCD /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -170,43 +170,43 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
- 81EA16B41DBA59C4915ABE3F /* [CP] Check Pods Manifest.lock */ = {
+ 1DBF57571B44936782348BCD /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
+ name = "[CP] Copy Pods Resources";
outputFileListPaths = (
- );
- outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- E8A0EF8E3B4DDB502C870BB0 /* [CP] Copy Pods Resources */ = {
+ 81EA16B41DBA59C4915ABE3F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- name = "[CP] Copy Pods Resources";
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
FB29095E4E4AE33FC6A4F030 /* [CP] Embed Pods Frameworks */ = {
diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock
index b8387491..76a2f89f 100644
--- a/kotlin-js-store/yarn.lock
+++ b/kotlin-js-store/yarn.lock
@@ -52,6 +52,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
+"@js-joda/core@3.2.0":
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
+ integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
+
"@leichtgewicht/ip-codec@^2.0.1":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1"
@@ -3288,7 +3293,7 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
-ws@^8.13.0:
+ws@8.18.0, ws@^8.13.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
diff --git a/lib/compose-html-interop/MODULE.md b/lib/compose-html-interop/MODULE.md
new file mode 100644
index 00000000..a667366d
--- /dev/null
+++ b/lib/compose-html-interop/MODULE.md
@@ -0,0 +1,3 @@
+# Module compose-html-interop
+
+Include an HTML element in a Compose Web UI.
diff --git a/lib/compose-html-interop/build.gradle.kts b/lib/compose-html-interop/build.gradle.kts
new file mode 100644
index 00000000..514d8635
--- /dev/null
+++ b/lib/compose-html-interop/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ id("library-conventions")
+ id(libs.plugins.kotlin.multiplatform.get().pluginId)
+ id(libs.plugins.kotlin.composeCompiler.get().pluginId)
+ id(libs.plugins.compose.get().pluginId)
+ id(libs.plugins.mavenPublish.get().pluginId)
+}
+
+mavenPublishing {
+ pom {
+ name = "Compose HTML Interop"
+ description = "Include an HTML element in a Compose Web UI."
+ url = "https://github.com/sargunv/maplibre-compose"
+ }
+}
+
+kotlin {
+ js(IR) { browser() }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(kotlin("stdlib-js"))
+ implementation(compose.foundation)
+ }
+
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(kotlin("test-common"))
+ implementation(kotlin("test-annotations-common"))
+ }
+ }
+}
diff --git a/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlElement.kt b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlElement.kt
new file mode 100644
index 00000000..084758e2
--- /dev/null
+++ b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlElement.kt
@@ -0,0 +1,34 @@
+package dev.sargunv.composehtmlinterop
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import kotlinx.browser.document
+import org.w3c.dom.HTMLElement
+
+@Composable
+public fun HtmlElement(
+ factory: () -> T,
+ update: (T) -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+
+ val container =
+ rememberDomNode(parent = document.body!!) {
+ document.createElement("div").unsafeCast().apply {
+ style.position = "absolute"
+ style.margin = "0px"
+ }
+ }
+
+ val child = rememberDomNode(parent = container, factory = factory)
+
+ SnapshotEffect(child) { update(it) }
+
+ Box(modifier.onGloballyPositioned { container.matchLayout(it, density) })
+
+ HtmlFocusAdapter(container)
+}
diff --git a/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlFocusAdapter.kt b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlFocusAdapter.kt
new file mode 100644
index 00000000..0ae1f99a
--- /dev/null
+++ b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/HtmlFocusAdapter.kt
@@ -0,0 +1,63 @@
+package dev.sargunv.composehtmlinterop
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalFocusManager
+import org.w3c.dom.HTMLElement
+
+@Composable
+internal fun HtmlFocusAdapter(container: HTMLElement) {
+ val focusManager = LocalFocusManager.current
+ var ownFocusRequest by remember { mutableStateOf(false) }
+
+ val head = remember { FocusRequester() }
+ val tail = remember { FocusRequester() }
+
+ val currentContainer by rememberUpdatedState(container)
+
+ Box(
+ modifier =
+ Modifier.focusRequester(head).onFocusChanged {
+ if (it.isFocused && !ownFocusRequest) {
+ val htmlHead = currentContainer.firstElementChild
+ if (htmlHead != null) {
+ focusManager.clearFocus(force = true)
+ htmlHead.unsafeCast().focus()
+ } else {
+ ownFocusRequest = true
+ tail.requestFocus()
+ ownFocusRequest = false
+ focusManager.moveFocus(FocusDirection.Next)
+ }
+ }
+ }
+ )
+
+ Box(
+ modifier =
+ Modifier.focusRequester(tail).onFocusChanged {
+ if (it.isFocused && !ownFocusRequest) {
+ val htmlTail = currentContainer.lastElementChild
+ if (htmlTail != null) {
+ focusManager.clearFocus(force = true)
+ htmlTail.unsafeCast().focus()
+ } else {
+ ownFocusRequest = true
+ head.requestFocus()
+ ownFocusRequest = false
+ focusManager.moveFocus(FocusDirection.Previous)
+ }
+ }
+ }
+ )
+}
diff --git a/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/SnapshotEffect.kt b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/SnapshotEffect.kt
new file mode 100644
index 00000000..54319789
--- /dev/null
+++ b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/SnapshotEffect.kt
@@ -0,0 +1,23 @@
+package dev.sargunv.composehtmlinterop
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshots.SnapshotStateObserver
+
+@Composable
+internal fun SnapshotEffect(target: T, effect: (T) -> Unit) {
+ val observer = remember { SnapshotStateObserver { it() } }
+ val currentTarget by rememberUpdatedState(target)
+ val currentEffect by rememberUpdatedState(effect)
+ DisposableEffect(observer) {
+ observer.start()
+ observer.observeReads(Unit, { currentEffect(currentTarget) }) { currentEffect(currentTarget) }
+ onDispose {
+ observer.stop()
+ observer.clear()
+ }
+ }
+}
diff --git a/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/util.kt b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/util.kt
new file mode 100644
index 00000000..5c5bf96c
--- /dev/null
+++ b/lib/compose-html-interop/src/commonMain/kotlin/dev/sargunv/composehtmlinterop/util.kt
@@ -0,0 +1,35 @@
+package dev.sargunv.composehtmlinterop
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import org.w3c.dom.HTMLElement
+import org.w3c.dom.Node
+
+internal fun Dp.toCssValue(): String = "${value}px"
+
+internal fun HTMLElement.matchLayout(layoutCoordinates: LayoutCoordinates, density: Density) {
+ with(density) {
+ style.apply {
+ val rect = layoutCoordinates.boundsInWindow()
+ width = rect.width.toDp().toCssValue()
+ height = rect.height.toDp().toCssValue()
+ left = rect.left.toDp().toCssValue()
+ top = rect.top.toDp().toCssValue()
+ }
+ }
+}
+
+@Composable
+internal fun rememberDomNode(parent: Node, factory: () -> T): T {
+ return remember(key1 = parent, calculation = factory).also { child ->
+ DisposableEffect(parent, child) {
+ parent.insertBefore(child, parent.firstChild)
+ onDispose { parent.removeChild(child) }
+ }
+ }
+}
diff --git a/lib/kotlin-maplibre-js/MODULE.md b/lib/kotlin-maplibre-js/MODULE.md
index cf75d481..3e9d3c20 100644
--- a/lib/kotlin-maplibre-js/MODULE.md
+++ b/lib/kotlin-maplibre-js/MODULE.md
@@ -1,3 +1,3 @@
# Module maplibre-gl-js-kotlin
-Kotlin wrapper for [MapLibre GL JS](https://www.npmjs.com/package/maplibre-gl).
+Kotlin bindings for [MapLibre GL JS](https://www.npmjs.com/package/maplibre-gl).
diff --git a/lib/kotlin-maplibre-js/build.gradle.kts b/lib/kotlin-maplibre-js/build.gradle.kts
index 6ba5ba63..31e2dde1 100644
--- a/lib/kotlin-maplibre-js/build.gradle.kts
+++ b/lib/kotlin-maplibre-js/build.gradle.kts
@@ -13,7 +13,7 @@ mavenPublishing {
}
kotlin {
- js(IR) { browser {} }
+ js(IR) { browser() }
sourceSets {
commonMain.dependencies {
diff --git a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt
index 2927955c..dd8f7766 100644
--- a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt
+++ b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt
@@ -2,11 +2,11 @@
package dev.sargunv.maplibrejs
+import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLElement
/** [Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/) */
-@JsName("Map")
-public external class Maplibre public constructor(options: MapOptions) {
+public external class Map public constructor(options: MapOptions) {
public var repaint: Boolean
public var showCollisionBoxes: Boolean
public var showOverdrawInspector: Boolean
@@ -57,6 +57,14 @@ public external class Maplibre public constructor(options: MapOptions) {
public fun addControl(control: IControl, position: String)
public fun removeControl(control: IControl)
+
+ public fun triggerRepaint()
+
+ public fun getCanvasContainer(): HTMLElement
+
+ public fun getCanvas(): HTMLCanvasElement
+
+ public fun resize()
}
/**
@@ -157,9 +165,9 @@ public external class TwoFingersTouchZoomRotateHandler {
/** [LogoControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/LogoControl/) */
public external class LogoControl
public constructor(options: LogoControlOptions = definedExternally) : IControl {
- override fun onAdd(map: Maplibre): HTMLElement
+ override fun onAdd(map: Map): HTMLElement
- override fun onRemove(map: Maplibre)
+ override fun onRemove(map: Map)
}
/**
@@ -172,9 +180,9 @@ public external interface LogoControlOptions {
/** [ScaleControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/ScaleControl/) */
public external class ScaleControl
public constructor(options: ScaleControlOptions = definedExternally) : IControl {
- override fun onAdd(map: Maplibre): HTMLElement
+ override fun onAdd(map: Map): HTMLElement
- override fun onRemove(map: Maplibre)
+ override fun onRemove(map: Map)
}
/**
@@ -190,9 +198,9 @@ public external interface ScaleControlOptions {
*/
public external class AttributionControl
public constructor(options: AttributionControlOptions = definedExternally) : IControl {
- override fun onAdd(map: Maplibre): HTMLElement
+ override fun onAdd(map: Map): HTMLElement
- override fun onRemove(map: Maplibre)
+ override fun onRemove(map: Map)
}
/**
@@ -206,9 +214,9 @@ public external interface AttributionControlOptions {
/** [NavigationControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/NavigationControl/) */
public external class NavigationControl
public constructor(options: NavigationControlOptions = definedExternally) : IControl {
- override fun onAdd(map: Maplibre): HTMLElement
+ override fun onAdd(map: Map): HTMLElement
- override fun onRemove(map: Maplibre)
+ override fun onRemove(map: Map)
}
/**
@@ -222,9 +230,9 @@ public external interface NavigationControlOptions {
/** [IControl](https://maplibre.org/maplibre-gl-js/docs/API/interfaces/IControl/) */
public external interface IControl {
- public fun onAdd(map: Maplibre): HTMLElement
+ public fun onAdd(map: Map): HTMLElement
- public fun onRemove(map: Maplibre)
+ public fun onRemove(map: Map)
}
/** [LngLat](https://maplibre.org/maplibre-gl-js/docs/API/classes/LngLat/) */
diff --git a/lib/maplibre-compose-expressions/build.gradle.kts b/lib/maplibre-compose-expressions/build.gradle.kts
index 2110ff69..f1029b41 100644
--- a/lib/maplibre-compose-expressions/build.gradle.kts
+++ b/lib/maplibre-compose-expressions/build.gradle.kts
@@ -1,7 +1,7 @@
-@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalComposeLibrary::class)
+@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalWasmDsl::class)
-import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
@@ -35,6 +35,8 @@ kotlin {
iosSimulatorArm64()
iosX64()
jvm("desktop")
+ js(IR) { browser() }
+ wasmJs { browser() }
sourceSets {
commonMain.dependencies { implementation(compose.foundation) }
diff --git a/lib/maplibre-compose-material3/build.gradle.kts b/lib/maplibre-compose-material3/build.gradle.kts
index 92a10663..76a3750f 100644
--- a/lib/maplibre-compose-material3/build.gradle.kts
+++ b/lib/maplibre-compose-material3/build.gradle.kts
@@ -36,6 +36,7 @@ kotlin {
iosSimulatorArm64()
iosX64()
jvm("desktop")
+ js(IR) { browser() }
cocoapods {
noPodspec()
diff --git a/lib/maplibre-compose-webview/build.gradle.kts b/lib/maplibre-compose-webview/build.gradle.kts
index 1989e309..c8e59fd3 100644
--- a/lib/maplibre-compose-webview/build.gradle.kts
+++ b/lib/maplibre-compose-webview/build.gradle.kts
@@ -6,7 +6,6 @@ plugins {
kotlin {
js(IR) {
browser { webpackTask {} }
-
useEsModules()
binaries.executable()
generateTypeScriptDefinitions()
diff --git a/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewMapBridge.kt b/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewMapBridge.kt
index ca556311..7c6ab1e9 100644
--- a/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewMapBridge.kt
+++ b/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewMapBridge.kt
@@ -5,8 +5,8 @@ package dev.sargunv.maplibrecompose.webview
import dev.sargunv.maplibrejs.AttributionControl
import dev.sargunv.maplibrejs.LogoControl
+import dev.sargunv.maplibrejs.Map
import dev.sargunv.maplibrejs.MapOptions
-import dev.sargunv.maplibrejs.Maplibre
import dev.sargunv.maplibrejs.NavigationControl
import dev.sargunv.maplibrejs.NavigationControlOptions
import dev.sargunv.maplibrejs.ScaleControl
@@ -16,7 +16,7 @@ import org.w3c.dom.HTMLDivElement
@JsExport
object WebviewMapBridge {
private var container: HTMLDivElement? = null
- private lateinit var map: Maplibre
+ private lateinit var map: Map
private lateinit var navigationControl: NavigationControl
private lateinit var logoControl: LogoControl
private lateinit var scaleControl: ScaleControl
@@ -28,7 +28,7 @@ object WebviewMapBridge {
it.setAttribute("style", "width: 100%; height: 100vh;")
document.body!!.appendChild(it)
} as HTMLDivElement
- map = Maplibre(MapOptions(container = container!!, disableAttributionControl = true))
+ map = Map(MapOptions(container = container!!, disableAttributionControl = true))
navigationControl = NavigationControl(NavigationControlOptions(visualizePitch = true))
logoControl = LogoControl()
scaleControl = ScaleControl()
diff --git a/lib/maplibre-compose/build.gradle.kts b/lib/maplibre-compose/build.gradle.kts
index c29f8ac5..baa31416 100644
--- a/lib/maplibre-compose/build.gradle.kts
+++ b/lib/maplibre-compose/build.gradle.kts
@@ -55,6 +55,7 @@ kotlin {
iosSimulatorArm64()
iosX64()
jvm("desktop")
+ js(IR) { browser() }
cocoapods {
noPodspec()
@@ -88,6 +89,11 @@ kotlin {
implementation(libs.webview)
}
+ jsMain.dependencies {
+ implementation(project(":lib:kotlin-maplibre-js"))
+ implementation(project(":lib:compose-html-interop"))
+ }
+
commonTest.dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-common"))
@@ -110,9 +116,6 @@ compose.resources {
customDirectory(
sourceSetName = "desktopMain",
- directoryProvider =
- // layout.dir(copyDesktopResources.map {
- // it.destinationDir.relativeTo(layout.projectDirectory.asFile) }),
- layout.dir(copyDesktopResources.map { it.destinationDir }),
+ directoryProvider = layout.dir(copyDesktopResources.map { it.destinationDir }),
)
}
diff --git a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/compose/WebMapView.kt b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/compose/WebMapView.kt
new file mode 100644
index 00000000..7cd631f9
--- /dev/null
+++ b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/compose/WebMapView.kt
@@ -0,0 +1,66 @@
+package dev.sargunv.maplibrecompose.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import co.touchlab.kermit.Logger
+import dev.sargunv.composehtmlinterop.HtmlElement
+import dev.sargunv.maplibrecompose.core.MaplibreMap
+import dev.sargunv.maplibrejs.Map
+import dev.sargunv.maplibrejs.MapOptions
+import kotlinx.browser.document
+import org.w3c.dom.HTMLElement
+
+@Composable
+internal actual fun ComposableMapView(
+ modifier: Modifier,
+ styleUri: String,
+ update: (map: MaplibreMap) -> Unit,
+ onReset: () -> Unit,
+ logger: Logger?,
+ callbacks: MaplibreMap.Callbacks,
+) =
+ WebMapView(
+ modifier = modifier,
+ styleUri = styleUri,
+ update = update,
+ onReset = onReset,
+ logger = logger,
+ callbacks = callbacks,
+ )
+
+@Composable
+internal fun WebMapView(
+ modifier: Modifier,
+ styleUri: String,
+ update: (map: MaplibreMap) -> Unit,
+ onReset: () -> Unit,
+ logger: Logger?,
+ callbacks: MaplibreMap.Callbacks,
+) {
+ var maybeMap by remember { mutableStateOf