diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt new file mode 100644 index 0000000000000..997e215f4d60e --- /dev/null +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt @@ -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) -> 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) -> 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() + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt similarity index 62% rename from compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt rename to compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt index 861228fc90e6b..72eca7b75ca62 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt @@ -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. @@ -16,20 +16,20 @@ 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) -> Unit), - ) - - private var currentInput: CurrentInput? = null + private var backingTextArea: BackingTextArea? = null + set(value) { + field?.dispose() + field = value + } override fun startInput( value: TextFieldValue, @@ -37,28 +37,31 @@ internal class JSTextInputService : PlatformTextInputService { onEditCommand: (List) -> 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)) + } + +} \ No newline at end of file diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebKeyboardInputService.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebKeyboardInputService.kt new file mode 100644 index 0000000000000..1b99719669887 --- /dev/null +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebKeyboardInputService.kt @@ -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) -> 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 +} \ No newline at end of file diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebTextInputService.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebTextInputService.kt new file mode 100644 index 0000000000000..398e355c748ae --- /dev/null +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebTextInputService.kt @@ -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) -> 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) + } +} diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index b219e42ab9044..fcc8d81515333 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.events.EventTargetListener import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.key.toComposeEvent import androidx.compose.ui.input.pointer.BrowserCursor import androidx.compose.ui.input.pointer.PointerEventType @@ -34,7 +36,8 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.composeButton import androidx.compose.ui.input.pointer.composeButtons import androidx.compose.ui.native.ComposeLayer -import androidx.compose.ui.platform.JSTextInputService +import androidx.compose.ui.platform.DefaultInputModeManager +import androidx.compose.ui.platform.WebTextInputService import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.ViewConfiguration @@ -58,6 +61,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.jetbrains.skiko.SkiaLayer import org.w3c.dom.AddEventListenerOptions +import org.w3c.dom.DOMRect +import org.w3c.dom.DOMRectReadOnly import org.w3c.dom.Element import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLStyleElement @@ -155,23 +160,34 @@ private class ComposeWindow( private val canvasEvents = EventTargetListener(canvas) - private val jsTextInputService = JSTextInputService() - private val platformContext: PlatformContext = - object : PlatformContext by PlatformContext.Empty { - override val windowInfo get() = _windowInfo - override val textInputService = jsTextInputService - override val viewConfiguration = - object : ViewConfiguration by PlatformContext.Empty.viewConfiguration { - override val touchSlop: Float get() = with(density) { 18.dp.toPx() } - } + private val platformContext: PlatformContext = object : PlatformContext { + override val windowInfo get() = _windowInfo - override fun setPointerIcon(pointerIcon: PointerIcon) { - if (pointerIcon is BrowserCursor) { - canvas.style.cursor = pointerIcon.id - } + override val inputModeManager: InputModeManager = DefaultInputModeManager() + + override val textInputService = object : WebTextInputService() { + override fun resolveInputMode() = inputModeManager.inputMode + override fun getOffset(rect: Rect): Offset { + val viewportRect = canvas.getBoundingClientRect() + val offsetX = viewportRect.left.toFloat().coerceAtLeast(0f) + (rect.left / density.density) + val offsetY = viewportRect.top.toFloat().coerceAtLeast(0f) + (rect.top / density.density) + return Offset(offsetX, offsetY) } } + override val viewConfiguration = + object : ViewConfiguration by PlatformContext.Empty.viewConfiguration { + override val touchSlop: Float get() = with(density) { 18.dp.toPx() } + } + + override fun setPointerIcon(pointerIcon: PointerIcon) { + if (pointerIcon is BrowserCursor) { + canvas.style.cursor = pointerIcon.id + } + } + + } + private val layer = ComposeLayer( layer = SkiaLayer(), platformContext = platformContext, @@ -197,6 +213,7 @@ private class ComposeWindow( canvas.getBoundingClientRect().apply { offset = Offset(x = left.toFloat(), y = top.toFloat()) } + layer.onTouchEvent(event, offset) }