Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -24,27 +24,39 @@ 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.length
import kotlin.js.toInt
import kotlin.js.toJsArray
import kotlin.js.toJsNumber
Comment on lines +33 to +36
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 on lines +42 to +43
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)
private var isInCompositionMode = false

// To avoid the re-triggering of the selection change
private var pauseSelectionChangeListener = false
Expand All @@ -63,24 +75,26 @@ 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
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)
setSelectionRange(htmlInput,textFieldValue.selection.min, textFieldValue.selection.max)
pauseSelectionChangeListener = false
}
Comment on lines +88 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,32 @@ 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)
}
})

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
latestSelection = TextSelection(start, end)

val selection = lastMeaningfulUpdate.selection

if (start != selection.min || end != selection.max) {
Expand Down Expand Up @@ -161,17 +182,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 +226,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 +260,84 @@ 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 =
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))
}
Comment on lines +208 to 210
Comment on lines +208 to 210

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;
z-index: -1;
}
""".trimIndent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,16 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
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
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 @@ -63,25 +60,25 @@ class ExternalSelectionChangeListenerTest : OnCanvasTests {

assertEquals(TextRange(text.length), textFieldValue.value.selection)

htmlInput.setSelectionRange(1, 7)
setSelectionRange(htmlInput, 1, 7)
document.dispatchEvent(Event("selectionchange"))
awaitAnimationFrame()
awaitIdle()

assertEquals(TextRange(1, 7), textFieldValue.value.selection)

htmlInput.setSelectionRange(8, 8)
setSelectionRange(htmlInput, 8, 8)
document.dispatchEvent(Event("selectionchange"))
awaitAnimationFrame()
awaitIdle()

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