Skip to content

Commit 67c178a

Browse files
authored
Support cursor control using space bar sliding gesture on Android Web (#2762)
Note: iOS Safari has limitations, and this solution doesn't work there Fixes [CMP-8362](https://youtrack.jetbrains.com/issue/CMP-8362) ## Testing This should be tested by QA ## Release Notes ### Fixes - Web - Support cursor control using space bar sliding gesture on Android Web
1 parent 219eec0 commit 67c178a

File tree

4 files changed

+150
-6
lines changed

4 files changed

+150
-6
lines changed

compose/mpp/demo/src/webMain/resources/input-events/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ <h2>Keyboard Event Order Tracker</h2>
6363
scheduleRAF();
6464
});
6565
}
66+
67+
document.addEventListener('selectionchange', () => log(`selectionchange --- ${input.selectionStart}-${input.selectionEnd}`));
6668
</script>
6769
</body>
6870
</html>

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/BackingDomInput.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ internal class BackingDomInput(
9292
}
9393

9494
fun dispose() {
95+
inputStrategy.dispose()
9596
backingElement.remove()
9697
}
97-
}
98+
}

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DomInputStrategy.kt

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,19 @@ import androidx.compose.ui.input.key.Key
2020
import androidx.compose.ui.text.input.ImeAction
2121
import androidx.compose.ui.text.input.ImeOptions
2222
import androidx.compose.ui.text.input.KeyboardType
23+
import androidx.compose.ui.text.input.SetSelectionCommand
2324
import androidx.compose.ui.text.input.TextFieldValue
25+
import kotlin.js.ExperimentalWasmJsInterop
2426
import kotlin.js.JsAny
2527
import kotlin.js.JsName
2628
import kotlin.js.definedExternally
2729
import kotlin.js.js
30+
import kotlin.js.unsafeCast
2831
import kotlinx.browser.document
2932
import kotlinx.browser.window
3033
import org.w3c.dom.HTMLElement
3134
import org.w3c.dom.events.CompositionEvent
35+
import org.w3c.dom.events.Event
3236
import org.w3c.dom.events.InputEvent
3337
import org.w3c.dom.events.KeyboardEvent
3438

@@ -40,6 +44,10 @@ internal class DomInputStrategy(
4044

4145
private var lastMeaningfulUpdate = TextFieldValue("")
4246

47+
// To avoid the re-triggering of the selection change
48+
private var pauseSelectionChangeListener = false
49+
private var selectionChangeListener: ((Event) -> Unit)? = null
50+
4351
init {
4452
initEvents()
4553
}
@@ -55,14 +63,18 @@ internal class DomInputStrategy(
5563
fun updateState(textFieldValue: TextFieldValue) {
5664
htmlInput as HTMLElementWithValue
5765

58-
if (lastMeaningfulUpdate.text != textFieldValue.text) {
66+
val needsTextUpdate = lastMeaningfulUpdate.text != textFieldValue.text
67+
val needsSelectionUpdate = lastMeaningfulUpdate.selection != textFieldValue.selection
68+
lastMeaningfulUpdate = textFieldValue
69+
70+
if (needsTextUpdate) {
5971
htmlInput.value = textFieldValue.text
6072
}
61-
if (lastMeaningfulUpdate.selection != textFieldValue.selection) {
73+
if (needsSelectionUpdate) {
74+
pauseSelectionChangeListener = true
6275
htmlInput.setSelectionRange(textFieldValue.selection.min, textFieldValue.selection.max)
76+
pauseSelectionChangeListener = false
6377
}
64-
65-
lastMeaningfulUpdate = textFieldValue
6678
}
6779

6880
private val tabKeyCode = Key.Tab.keyCode.toInt()
@@ -79,6 +91,9 @@ internal class DomInputStrategy(
7991
// Compose logic will handle the focus movement or insert Tabs if necessary
8092
evt.preventDefault()
8193
}
94+
95+
// Let Compose decide the selection right after a new key input
96+
pauseSelectionChangeListener = true
8297
})
8398

8499
htmlInput.addEventListener("keyup", { evt ->
@@ -104,7 +119,43 @@ internal class DomInputStrategy(
104119
htmlInput.addEventListener("compositionend", { evt ->
105120
nativeInputEventsProcessor.registerEvent(evt as CompositionEvent)
106121
})
122+
123+
selectionChangeListener = listener@{ _ ->
124+
if (pauseSelectionChangeListener || !isInputActive()) return@listener
125+
htmlInput as HTMLElementWithValue
126+
val start = htmlInput.selectionStart
127+
val end = htmlInput.selectionEnd
128+
val selection = lastMeaningfulUpdate.selection
129+
130+
if (start != selection.min || end != selection.max) {
131+
val normalizedStart = minOf(start, end)
132+
val normalizedEnd = maxOf(start, end)
133+
composeSender.sendEditCommand(SetSelectionCommand(normalizedStart, normalizedEnd))
134+
}
135+
}
136+
document.addEventListener("selectionchange", selectionChangeListener)
107137
}
138+
139+
fun dispose() {
140+
document.removeEventListener("selectionchange", selectionChangeListener)
141+
selectionChangeListener = null
142+
}
143+
144+
@OptIn(ExperimentalWasmJsInterop::class)
145+
private fun isInputActive(): Boolean {
146+
val root = htmlInput.unsafeCast<NodeWithRootNode>().getRootNode()
147+
val rootActive = root?.activeElement
148+
return rootActive == htmlInput
149+
}
150+
}
151+
152+
@OptIn(ExperimentalWasmJsInterop::class)
153+
private external interface NodeWithRootNode : JsAny {
154+
fun getRootNode(): DocumentOrShadowRootLike?
155+
}
156+
157+
private external interface DocumentOrShadowRootLike : JsAny {
158+
val activeElement: HTMLElement?
108159
}
109160

110161
@JsName("InputEvent")
@@ -208,4 +259,4 @@ private external interface HTMLElementWithValue {
208259
}
209260

210261
internal fun isTypedEvent(evt: KeyboardEvent): Boolean =
211-
js("!evt.metaKey && !evt.ctrlKey && evt.key.charAt(0) === evt.key")
262+
js("!evt.metaKey && !evt.ctrlKey && evt.key.charAt(0) === evt.key")
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.input
18+
19+
import androidx.compose.foundation.text.BasicTextField
20+
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.OnCanvasTests
23+
import androidx.compose.ui.WebApplicationScope
24+
import androidx.compose.ui.focus.FocusRequester
25+
import androidx.compose.ui.focus.focusRequester
26+
import androidx.compose.ui.text.TextRange
27+
import androidx.compose.ui.text.input.TextFieldValue
28+
import kotlin.test.Test
29+
import kotlin.test.assertEquals
30+
import kotlinx.browser.document
31+
import kotlinx.coroutines.Dispatchers
32+
import kotlinx.coroutines.delay
33+
import kotlinx.coroutines.withContext
34+
import kotlinx.coroutines.yield
35+
import org.w3c.dom.HTMLTextAreaElement
36+
import org.w3c.dom.events.Event
37+
38+
class ExternalSelectionChangeListenerTest : OnCanvasTests {
39+
40+
@Test
41+
fun selectionChangeInBackingInputUpdatesComposeSelection() = runApplicationTest {
42+
val text = "hello world"
43+
val textFieldValue = mutableStateOf(
44+
TextFieldValue(text, selection = TextRange(text.length))
45+
)
46+
val focusRequester = FocusRequester()
47+
48+
createComposeWindow {
49+
BasicTextField(
50+
value = textFieldValue.value,
51+
onValueChange = { value ->
52+
textFieldValue.value = value
53+
},
54+
modifier = Modifier.focusRequester(focusRequester)
55+
)
56+
}
57+
58+
focusRequester.requestFocus()
59+
val htmlInput = waitForHtmlInput()
60+
htmlInput.focus()
61+
awaitAnimationFrame()
62+
awaitIdle()
63+
64+
assertEquals(TextRange(text.length), textFieldValue.value.selection)
65+
66+
htmlInput.setSelectionRange(1, 7)
67+
document.dispatchEvent(Event("selectionchange"))
68+
awaitAnimationFrame()
69+
awaitIdle()
70+
71+
assertEquals(TextRange(1, 7), textFieldValue.value.selection)
72+
73+
htmlInput.setSelectionRange(8, 8)
74+
document.dispatchEvent(Event("selectionchange"))
75+
awaitAnimationFrame()
76+
awaitIdle()
77+
78+
assertEquals(TextRange(8, 8), textFieldValue.value.selection)
79+
}
80+
81+
private suspend fun WebApplicationScope.waitForHtmlInput(): HTMLTextAreaElement {
82+
while (true) {
83+
val element = getShadowRoot().querySelector("textarea")
84+
if (element is HTMLTextAreaElement) {
85+
return element
86+
}
87+
yield()
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)