Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
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 @@ -30,8 +30,6 @@ internal interface ComposeCommandCommunicator {
fun sendEditCommand(command: EditCommand) = sendEditCommand(listOf(command))

fun sendKeyboardEvent(keyboardEvent: KeyEvent): Boolean

fun currentTextLayoutResult(): TextLayoutResult?
}

private fun setBackingInputBox(container: HTMLElement, left: Float, top: Float, width: Float, height: Float) { js("""
Expand Down Expand Up @@ -71,6 +69,7 @@ internal class BackingDomInput(
window.requestAnimationFrame {
backingElement.focus()
}

}

fun blur() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,34 @@ 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.JsNumber
import kotlin.js.definedExternally
import kotlin.js.get
import kotlin.js.js
import kotlin.js.toInt
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.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 isInCompositionMode = false

// To avoid the re-triggering of the selection change
private var pauseSelectionChangeListener = false
Expand All @@ -63,24 +70,31 @@ 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
setSelectionRange(htmlInput, textFieldValue.selection.min, textFieldValue.selection.max)

// Resetting `pauseSelectionChangeListener` synchronously right after is not enough
// TODO: this is the cheapest way to make sure that DOM <=> Compose sync won't self-trigger but we need to consider better possible options
window.requestAnimationFrame {

@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.

Looks like this paragraph in the documentation explains this - https://www.w3.org/TR/selection-api/#scheduling-selectionchange-event

To schedule a selectionchange event on a node target, run these steps:
If target's has scheduled selectionchange event is true, abort these steps.
Set target's has scheduled selectionchange event to true.
Queue a task on the user interaction task source to fire a selectionchange event on target.

So the selectionchange event listeners do not run synchronously.

Could you please leave the link to the w3c documentation?

pauseSelectionChangeListener = false
}
}
Comment on lines +83 to 92
}

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,25 +115,30 @@ 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.firstRange = inputExt.getTargetRanges()[0]

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 = getSelectionRange(htmlInput)
val start = currentSelection?.get(0)?.toInt() ?: 0
val end = currentSelection?.get(1)?.toInt() ?: 0

val selection = lastMeaningfulUpdate.selection

if (start != selection.min || end != selection.max) {
Expand Down Expand Up @@ -157,21 +176,41 @@ private external interface DocumentOrShadowRootLike : JsAny {
internal external class InputEventExt : UIEvent {
val data: String?
val inputType: String
var textRangeStart: Int
var textRangeEnd: Int

var firstRange: StaticRange?

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>
}

internal inline fun UIEvent.asInputEventExt(): InputEventExt = unsafeCast<InputEventExt>()
/**
* 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 val InputEventExt.textRangeSize: Int
get() = this.asInputEventExt().let { it.textRangeEnd - it.textRangeStart }

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

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 +220,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 +254,88 @@ 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 {
// https://developer.mozilla.org/en-US/docs/Web/API/Selection/setBaseAndExtent
fun setBaseAndExtent(anchorNode: Node, anchorOffset: Int, focusNode: Node, focusOffset: Int)
}

@OptIn(ExperimentalWasmJsInterop::class)
private fun getSelectionRange(element: HTMLElement): JsArray<JsNumber>? = js(
"""{
var selection = window.getSelection();
if (selection == null) return null;
var root = element.getRootNode();
if (root == null) return null;

if (typeof selection.getComposedRanges === 'function') {
try {
// The modern standard approach
var composedRanges = selection.getComposedRanges({ shadowRoots: [root] });
if (composedRanges.length > 0) {
var firstRange = composedRanges[0];
return [firstRange.startOffset, firstRange.endOffset];
}
return null;
} catch (e) {
// Fallback for early Safari 17 point-releases
var composedRanges = selection.getComposedRanges(root);
if (composedRanges.length > 0) {
var firstRange = composedRanges[0];
return [firstRange.startOffset, firstRange.endOffset];
}
return null;
}
}

if (typeof root.getSelection === 'function') {
var rootSelection = root.getSelection();
if (rootSelection == null) return [0, 0];
if (rootSelection.rangeCount > 0) {
var rootRange = rootSelection.getRangeAt(0);
return [rootRange.startOffset, rootRange.endOffset];
}
return null;
}

if (selection.rangeCount > 0) {
var selectionRange = selection.getRangeAt(0);
return [selectionRange.startOffset, selectionRange.endOffset];
}
return null;
}"""
)

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

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

internal fun isTypedEvent(evt: KeyboardEvent): Boolean =

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

internal fun isModifyingEvent(evt: KeyboardEvent): Boolean {

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.

wdyt about making this entire function implemented in js?

Looks like isTypedEvent is used only here, so isTypedEvent can be changed to isModifyingEvent and include the check for Backspace?

return when (evt.key) {
"Backspace" -> true
else -> isTypedEvent(evt)
}
}
Loading
Loading