Skip to content

Commit

Permalink
Introduce minimal virtual keyboard support (#1259)
Browse files Browse the repository at this point in the history
Co-authored-by: jershell <jershell@mail.ru>
  • Loading branch information
Schahen and jershell committed Apr 11, 2024
1 parent f92948f commit 3e434d2
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.input.CommitTextCommand
import androidx.compose.ui.text.input.DeleteAllCommand
import androidx.compose.ui.text.input.EditCommand
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.TextFieldValue
import kotlinx.browser.document
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.dom.events.KeyboardEvent

/**
* The purpose of this entity is to isolate synchronization between a TextFieldValue
* and the DOM HTMLTextAreaElement we are actually listening events on in order to show
* the virtual keyboard.
*/
internal class BackingTextArea(
private val imeOptions: ImeOptions,
private val onEditCommand: (List<EditCommand>) -> Unit,
private val onImeActionPerformed: (ImeAction) -> Unit
) {
private val textArea: HTMLTextAreaElement = createHtmlInput()

private fun createHtmlInput(): HTMLTextAreaElement {
val htmlInput = document.createElement("textarea") as HTMLTextAreaElement

htmlInput.setAttribute("autocorrect", "off")
htmlInput.setAttribute("autocomplete", "off")
htmlInput.setAttribute("autocapitalize", "off")
htmlInput.setAttribute("spellcheck", "false")

val inputMode = when (imeOptions.keyboardType) {
KeyboardType.Text -> "text"
KeyboardType.Ascii -> "text"
KeyboardType.Number -> "number"
KeyboardType.Phone -> "tel"
KeyboardType.Uri -> "url"
KeyboardType.Email -> "email"
KeyboardType.Password -> "password"
KeyboardType.NumberPassword -> "number"
KeyboardType.Decimal -> "decimal"
else -> "text"
}

val enterKeyHint = when (imeOptions.imeAction) {
ImeAction.Default -> "enter"
ImeAction.None -> "enter"
ImeAction.Done -> "done"
ImeAction.Go -> "go"
ImeAction.Next -> "next"
ImeAction.Previous -> "previous"
ImeAction.Search -> "search"
ImeAction.Send -> "send"
else -> "enter"
}

htmlInput.setAttribute("inputmode", inputMode)
htmlInput.setAttribute("enterkeyhint", enterKeyHint)

htmlInput.style.apply {
setProperty("position", "absolute")
setProperty("user-select", "none")
setProperty("forced-color-adjust", "none")
setProperty("white-space", "pre-wrap")
setProperty("align-content", "center")
setProperty("top", "0")
setProperty("left", "0")
setProperty("padding", "0")
setProperty("opacity", "0")
setProperty("color", "transparent")
setProperty("background", "transparent")
setProperty("caret-color", "transparent")
setProperty("outline", "none")
setProperty("border", "none")
setProperty("resize", "none")
setProperty("text-shadow", "none")
}

htmlInput.addEventListener("input", {
val text = htmlInput.value
val cursorPosition = htmlInput.selectionEnd
sendImeValueToCompose(onEditCommand, text, cursorPosition)
})

htmlInput.addEventListener("contextmenu", { evt ->
evt.preventDefault()
evt.stopPropagation()
})

// this done by analogy with KeyCommand.NEW_LINE processing in TextFieldKeyInput
if (imeOptions.singleLine) {
htmlInput.addEventListener("keydown", { evt ->
evt.preventDefault()
evt as KeyboardEvent
if (evt.key == "Enter" && evt.type == "keydown") {
onImeActionPerformed(imeOptions.imeAction)
}
})
}

return htmlInput
}

fun register() {
document.body?.appendChild(textArea)
}

private fun sendImeValueToCompose(
onEditCommand: (List<EditCommand>) -> Unit,
text: String,
newCursorPosition: Int? = null
) {
val value = if (text == "\n") {
""
} else {
text
}

if (newCursorPosition != null) {
onEditCommand(
listOf(
DeleteAllCommand(),
CommitTextCommand(value, newCursorPosition),
)
)
} else {
onEditCommand(
listOf(
CommitTextCommand(value, 1)
)
)
}
}

fun focus() {
textArea.focus()
}

fun blur() {
textArea.blur()
}

fun updateHtmlInputPosition(offset: Offset) {
textArea.style.left = "${offset.x}px"
textArea.style.top = "${offset.y}px"

focus()
}

fun updateState(textFieldValue: TextFieldValue) {
textArea.value = textFieldValue.text
textArea.setSelectionRange(textFieldValue.selection.start, textFieldValue.selection.end)
}

fun dispose() {
textArea.remove()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,49 +16,52 @@

package androidx.compose.ui.platform

import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.input.EditCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.text.input.TextFieldValue

internal class JSTextInputService : PlatformTextInputService {
internal class WebImeInputService(parentInputService: InputAwareInputService) : PlatformTextInputService, InputAwareInputService by parentInputService {

data class CurrentInput(
var value: TextFieldValue,
val onEditCommand: ((List<EditCommand>) -> Unit),
)

private var currentInput: CurrentInput? = null
private var backingTextArea: BackingTextArea? = null
set(value) {
field?.dispose()
field = value
}

override fun startInput(
value: TextFieldValue,
imeOptions: ImeOptions,
onEditCommand: (List<EditCommand>) -> Unit,
onImeActionPerformed: (ImeAction) -> Unit
) {
currentInput = CurrentInput(
value,
onEditCommand
)
backingTextArea = BackingTextArea(imeOptions, onEditCommand, onImeActionPerformed)
backingTextArea?.register()

showSoftwareKeyboard()
}

override fun stopInput() {
currentInput = null
backingTextArea?.dispose()
}

override fun showSoftwareKeyboard() {
println("TODO showSoftwareKeyboard in JS")
backingTextArea?.focus()
}

override fun hideSoftwareKeyboard() {
println("TODO showSoftwareKeyboard in JS")
backingTextArea?.blur()
}

override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) {
currentInput?.let { input ->
input.value = newValue
}
backingTextArea?.updateState(newValue)
}
}

override fun notifyFocusedRect(rect: Rect) {
super.notifyFocusedRect(rect)
backingTextArea?.updateHtmlInputPosition(getOffset(rect))
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

import androidx.compose.ui.text.input.EditCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.text.input.TextFieldValue

internal class WebKeyboardInputService : PlatformTextInputService {
override fun startInput(
value: TextFieldValue,
imeOptions: ImeOptions,
onEditCommand: (List<EditCommand>) -> Unit,
onImeActionPerformed: (ImeAction) -> Unit
) = Unit

override fun stopInput() = Unit
override fun showSoftwareKeyboard() = Unit
override fun hideSoftwareKeyboard() = Unit
override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.text.input.EditCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.text.input.TextFieldValue

internal interface InputAwareInputService {
fun resolveInputMode(): InputMode
fun getOffset(rect: Rect): Offset
}

internal abstract class WebTextInputService : PlatformTextInputService, InputAwareInputService {
private val webImeInputService = WebImeInputService(this)
private val webKeyboardInputService = WebKeyboardInputService()

private fun delegatedService(): PlatformTextInputService {
return when (resolveInputMode()) {
InputMode.Touch -> webImeInputService
InputMode.Keyboard -> webKeyboardInputService
else -> webKeyboardInputService
}
}

override fun startInput(
value: TextFieldValue,
imeOptions: ImeOptions,
onEditCommand: (List<EditCommand>) -> Unit,
onImeActionPerformed: (ImeAction) -> Unit
) {
delegatedService().startInput(value, imeOptions, onEditCommand, onImeActionPerformed)
}

override fun stopInput() {
delegatedService().stopInput()
}

override fun showSoftwareKeyboard() {
delegatedService().showSoftwareKeyboard()
}

override fun hideSoftwareKeyboard() {
delegatedService().hideSoftwareKeyboard()
}

override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) {
delegatedService().updateState(oldValue, newValue)
}

override fun notifyFocusedRect(rect: Rect) {
delegatedService().notifyFocusedRect(rect)
}
}
Loading

0 comments on commit 3e434d2

Please sign in to comment.