Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,43 @@
package androidx.compose.ui.platform

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.text.TextRange
Comment thread
Copilot marked this conversation as resolved.
Outdated
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.SetSelectionCommand
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.js.ExperimentalWasmJsInterop
import kotlin.js.JsAny
import kotlin.js.JsArray
import kotlin.js.JsName
import kotlin.js.definedExternally
import kotlin.js.get
import kotlin.js.js
import kotlin.js.length
import kotlin.js.unsafeCast
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLElement
import org.w3c.dom.EventInit
import org.w3c.dom.Node
import org.w3c.dom.Range
Comment thread
Copilot marked this conversation as resolved.
Outdated
import org.w3c.dom.events.CompositionEvent
import org.w3c.dom.events.Event
import org.w3c.dom.events.UIEvent
import org.w3c.dom.events.InputEvent
import org.w3c.dom.events.KeyboardEvent


internal class DomInputStrategy(
imeOptions: ImeOptions,
private val composeSender: ComposeCommandCommunicator,
) {
val htmlInput = imeOptions.createDomElement()

private var lastMeaningfulUpdate = TextFieldValue("")
private var latestSelection = TextSelection(0, 0)
var isInCompositionMode = false
Comment thread
Copilot marked this conversation as resolved.
Outdated

// To avoid the re-triggering of the selection change
private var pauseSelectionChangeListener = false
Expand All @@ -63,16 +72,17 @@ internal class DomInputStrategy(
}

fun updateState(textFieldValue: TextFieldValue) {
htmlInput as HTMLElementWithValue

val needsTextUpdate = lastMeaningfulUpdate.text != textFieldValue.text
val needsSelectionUpdate = lastMeaningfulUpdate.selection != textFieldValue.selection
val needsTextUpdate = (lastMeaningfulUpdate.text != textFieldValue.text) && !isInCompositionMode

@eymar Oleksandr Karpovich (eymar) Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since isInCompositionMode is a simple boolean, checking it first is a bit better so most of the times comparing the text values will be skipped.

UPD: " most of the times" is not true, but sometimes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, why is additional check for !isInCompositionMode necessary?

val needsSelectionUpdate = (lastMeaningfulUpdate.selection != textFieldValue.selection) && !isInCompositionMode
lastMeaningfulUpdate = textFieldValue

if (needsTextUpdate) {
htmlInput.value = textFieldValue.text
htmlInput.textContent = textFieldValue.text

htmlInput.focus()
}
if (needsSelectionUpdate) {

if (needsTextUpdate || needsSelectionUpdate) {
pauseSelectionChangeListener = true
htmlInput.setSelectionRange(textFieldValue.selection.min, textFieldValue.selection.max)
pauseSelectionChangeListener = false
Expand All @@ -81,6 +91,7 @@ internal class DomInputStrategy(

private val tabKeyCode = Key.Tab.keyCode.toInt()

@OptIn(ExperimentalWasmJsInterop::class)
private fun initEvents() {
// Whenever new type of event is processed, don't forget to sync the NativeInputEventsProcessor::runCheckpoint isIME check
htmlInput.addEventListener("keydown", { evt ->
Expand All @@ -101,26 +112,34 @@ internal class DomInputStrategy(

htmlInput.addEventListener("beforeinput", { evt ->
if (evt is InputEvent) {
htmlInput as HTMLElementWithValue

val inputExt = evt.asInputEventExt()
inputExt.textRangeStart = htmlInput.selectionStart
inputExt.textRangeEnd = htmlInput.selectionEnd

inputExt.textRangeStart = latestSelection.start
inputExt.textRangeEnd = latestSelection.end

nativeInputEventsProcessor.registerEvent(evt)
Comment on lines 118 to 122
}
})

htmlInput.addEventListener("compositionstart", {evt ->
isInCompositionMode = true
})

htmlInput.addEventListener("compositionend", { evt ->
isInCompositionMode = false
nativeInputEventsProcessor.registerEvent(evt as CompositionEvent)
})

selectionChangeListener = listener@{ _ ->
if (pauseSelectionChangeListener || !isInputActive()) return@listener
htmlInput as HTMLElementWithValue
val start = htmlInput.selectionStart
val end = htmlInput.selectionEnd

val currentSelection = htmlInput.getSelectionRange()
latestSelection = currentSelection

val selection = lastMeaningfulUpdate.selection
val start = currentSelection.start
val end = currentSelection.end

if (start != selection.min || end != selection.max) {
val normalizedStart = minOf(start, end)
Expand Down Expand Up @@ -161,17 +180,41 @@ internal external class InputEventExt : UIEvent {
var textRangeEnd: Int

constructor(type: String, eventInitDict: EventInit = definedExternally)

/**
* Returns an array of static ranges that will be affected by a change to the DOM
* if the input event is not canceled.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/InputEvent/getTargetRanges
*/
fun getTargetRanges(): JsArray<StaticRange>
}

/**
* Represents a [StaticRange] - a range of content in a document that is not updated
* when the underlying DOM tree is modified.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/StaticRange
*/
@OptIn(ExperimentalWasmJsInterop::class)
internal external interface StaticRange : JsAny {
val startContainer: JsAny
val startOffset: Int
val endContainer: JsAny
val endOffset: Int
val collapsed: Boolean
}


internal inline fun UIEvent.asInputEventExt(): InputEventExt = unsafeCast<InputEventExt>()

internal val InputEventExt.textRangeSize: Int
get() = this.asInputEventExt().let { it.textRangeEnd - it.textRangeStart }
internal val InputEventExt.textRangeCollapsed: Boolean
get() = this.asInputEventExt().let { it.textRangeEnd == it.textRangeStart }


private fun ImeOptions.createDomElement(): HTMLElement {
val htmlElement = document.createElement(
if (singleLine) "input" else "textarea"
if (singleLine) "span" else "div"
) as HTMLElement

// without autocorrect set "on" iOS virtual keyboard won't suggest
Expand All @@ -181,6 +224,8 @@ private fun ImeOptions.createDomElement(): HTMLElement {
htmlElement.setAttribute("autocapitalize", "off")
htmlElement.setAttribute("spellcheck", "false")

htmlElement.setAttribute("contenteditable", "true")

val inputMode = when (keyboardType) {
KeyboardType.Text -> "text"
KeyboardType.Ascii -> "text"
Expand Down Expand Up @@ -213,13 +258,80 @@ private fun ImeOptions.createDomElement(): HTMLElement {
return htmlElement
}

private external interface HTMLElementWithValue {
var value: String
val selectionStart: Int
val selectionEnd: Int
val selectionDirection: String?
fun setSelectionRange(start: Int, end: Int, direction: String = definedExternally)
@OptIn(ExperimentalWasmJsInterop::class)
private external interface HasDomSelection : JsAny {
fun getSelection(): Selection?
}

/**
* Represents a [Selection] - the range of text selected by the user or the current position of the caret.
*
* Minimal definition sufficient for [setSelectionRange] and [getSelectionOffsets].
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Selection
*/
@OptIn(ExperimentalWasmJsInterop::class)
private external interface Selection : JsAny {
val rangeCount: Int
fun getRangeAt(index: Int): Range
fun removeAllRanges()
fun addRange(range: Range)
val anchorOffset: Int
val focusOffset: Int
// https://developer.mozilla.org/en-US/docs/Web/API/Selection/setBaseAndExtent
fun setBaseAndExtent(anchorNode: Node, anchorOffset: Int, focusNode: Node, focusOffset: Int)

/**
* See https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
*/
fun getComposedRanges(options: GetComposedRangesOptions = definedExternally): JsArray<StaticRange>

fun getComposedRanges(fallbackRoot: DocumentOrShadowRootLike): JsArray<StaticRange>
}

private fun HTMLElement.getSelectionRange(): TextSelection {
val selection = window.unsafeCast<HasDomSelection>().getSelection() ?: return TextSelection(0, 0)
val root = this.unsafeCast<NodeWithRootNode>().getRootNode() ?: return TextSelection(0, 0)

val composedRanges = try {
// Try the modern standard approach
selection.getComposedRanges(GetComposedRangesOptions(root))
} catch (e: Throwable) {
// Fallback for older Safari 17 point-releases
selection.getComposedRanges(root)
}

if (composedRanges.length > 0) {
val firstRange = composedRanges[0]!!
return TextSelection(firstRange.startOffset, firstRange.endOffset)
}

return TextSelection(0, 0)
}

/**
* Options for [Selection.getComposedRanges].
* See https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges#parameters
*/
@OptIn(ExperimentalWasmJsInterop::class)
private external interface GetComposedRangesOptions : JsAny {
var shadowRoots: JsArray<DocumentOrShadowRootLike>
}
private fun GetComposedRangesOptions(root: DocumentOrShadowRootLike): GetComposedRangesOptions = js("({ shadowRoots: [root] })")

internal fun HTMLElement.setSelectionRange(startOffset: Int, endOffset: Int) {
val selection = window.unsafeCast<HasDomSelection>().getSelection()

val textNode = firstChild
if (textNode != null) {
selection?.setBaseAndExtent(textNode, startOffset, textNode, endOffset)
} else {
selection?.setBaseAndExtent(this, 0, this, 0)
}
}

internal fun isTypedEvent(evt: KeyboardEvent): Boolean =
js("!evt.metaKey && !evt.ctrlKey && evt.key.charAt(0) === evt.key")


private data class TextSelection(val start: Int, val end: Int)
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,14 @@ internal abstract class NativeInputEventsProcessor(
// This happens when an autocorrection is applied on mobile:
// The system first tells us to delete the old text,
// and then it would send the "insertText" event.
if (textRangeSize > 0) {
if (!textRangeCollapsed) {
// deleteContentBackward can happen under very non-trivial circumstances:
// - for instance, when an input suggestion on Android Chrome is accepted,
// the browser then deletes space after the word just to add space again;
// - or when a browser performs Fast Delete;
add(SetSelectionCommand(textRangeStart, textRangeEnd))
add(BackspaceCommand())
} else if (textRangeSize == 0 && lastProcessedKeydown?.isBackspace() != true) {
} else if (textRangeCollapsed && lastProcessedKeydown?.isBackspace() != true) {
// We skip this branch if the lastProcessedKeydown is Backspace, because Compose must have already processed this.
// Otherwise, under specific circumstance previous symbol can be deleted while inputting the new one
// see https://youtrack.jetbrains.com/issue/CMP-8773
Expand All @@ -205,26 +205,26 @@ internal abstract class NativeInputEventsProcessor(

"insertReplacementText" -> buildList {
if (data == null) return@buildList
if (textRangeSize > 0) {
add(SetSelectionCommand(textRangeStart, textRangeEnd))
if (!textRangeCollapsed) {
add(SetSelectionCommand(textRangeStart, textRangeEnd))
}

add(CommitTextCommand(data, 1))
}

"insertText" -> buildList {
if (data == null) return@buildList
if (textRangeSize > 0 && currentTextFieldValue.selection.collapsed) {
add(SetSelectionCommand(textRangeStart, textRangeEnd))
if (!textRangeCollapsed && currentTextFieldValue.selection.collapsed) {
add(resolveAsSelectionCommand())
}

add(CommitTextCommand(data, 1))
}

"insertCompositionText" -> buildList {
if (data == null) return@buildList
if (textRangeSize > 0) {
add(SetSelectionCommand(textRangeStart, textRangeEnd))
if (!textRangeCollapsed) {
add(resolveAsSelectionCommand())
}
add(SetComposingTextCommand(data, 1))
}
Expand All @@ -248,4 +248,9 @@ internal abstract class NativeInputEventsProcessor(
internal fun getCollectedEvents() = collectedEvents
}


private fun KeyboardEvent.isBackspace(): Boolean = key == "Backspace"

private fun InputEventExt.resolveAsSelectionCommand(): SetSelectionCommand {
return SetSelectionCommand(textRangeStart, textRangeEnd)
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ fun ComposeViewport(
width: calc(var(--compose-internal-web-backing-input-width) * 1px);
left: min(var(--compose-internal-web-backing-input-left) * 1px, 100vw - var(--compose-internal-web-backing-input-width) * 1px);
top: min(var(--compose-internal-web-backing-input-top) * 1px, 100vh - var(--compose-internal-web-backing-input-height) * 1px);


overflow: hidden;
align-content: center;
background: transparent;
border: none;
Expand All @@ -142,7 +143,7 @@ fun ComposeViewport(
resize: none;
text-shadow: none;
user-select: none;
white-space: pre;
white-space: break-spaces;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this change necessary?

z-index: -1;
}
""".trimIndent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,14 @@ import androidx.compose.ui.OnCanvasTests
import androidx.compose.ui.WebApplicationScope
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.setSelectionRange
Comment on lines 22 to +25
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.browser.document
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.events.Event

class ExternalSelectionChangeListenerTest : OnCanvasTests {
Expand Down Expand Up @@ -78,10 +76,10 @@ class ExternalSelectionChangeListenerTest : OnCanvasTests {
assertEquals(TextRange(8, 8), textFieldValue.value.selection)
}

private suspend fun WebApplicationScope.waitForHtmlInput(): HTMLTextAreaElement {
private suspend fun waitForHtmlInput(): HTMLDivElement {
while (true) {
val element = getShadowRoot().querySelector("textarea")
if (element is HTMLTextAreaElement) {
val element = getShadowRoot().querySelector("div.compose-backing-field")
if (element is HTMLDivElement) {
return element
}
yield()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.pointerevents.PointerEvent
import org.w3c.dom.pointerevents.PointerEventInit

Expand Down Expand Up @@ -70,10 +70,10 @@ class MouseTextInputTests: OnCanvasTests {
awaitIdle()
assertEquals(TextRange(0, 0), textRange.value)

val textArea = getShadowRoot().querySelector("textarea")
assertIs<HTMLTextAreaElement>(textArea)
val backintInput = getShadowRoot().querySelector("div.compose-backing-field")
assertIs<HTMLDivElement>(backintInput)

val textAreaRect = textArea.getBoundingClientRect()
val textAreaRect = backintInput.getBoundingClientRect()
// Do a manual hit-test
val elementsAtPos = getShadowRoot().elementFromPoint(
textAreaRect.left + textAreaRect.width / 2 ,
Expand Down
Loading
Loading