From 691b10e2171c3b74c67e06fc07680d956bc8e79c Mon Sep 17 00:00:00 2001 From: jershell Date: Thu, 8 Feb 2024 17:38:01 +0300 Subject: [PATCH 1/5] add basic `ime` support for web browsers on mobile devices --- .../compose/foundation/text/TouchMode.js.kt | 2 +- .../mpp/demo/src/jsMain/resources/index.html | 3 +- .../ui/platform/getVisualViewport.js.kt | 21 ++ .../ui/native/ComposeLayer.jsNative.kt | 4 + .../androidx/compose/ui/native/Actuals.js.kt | 2 +- .../ui/platform/ImeMobileKeyboardListener.kt | 75 ++++++ .../ui/platform/ImeTextInputService.js.kt | 254 ++++++++++++++++++ .../ui/platform/JSTextInputService.js.kt | 38 ++- .../compose/ui/platform/getVisualViewport.kt | 20 ++ .../ui/platform/getVisualViewport.wasmJs.kt | 22 ++ 10 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt create mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt create mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt create mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt create mode 100644 compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt diff --git a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt index f7bc041d323e4..4861c5bcb65b6 100644 --- a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt +++ b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt @@ -16,4 +16,4 @@ package androidx.compose.foundation.text -internal actual val isInTouchMode = false +internal actual val isInTouchMode = true diff --git a/compose/mpp/demo/src/jsMain/resources/index.html b/compose/mpp/demo/src/jsMain/resources/index.html index eda2871422713..9e7f6b9b4a7cf 100644 --- a/compose/mpp/demo/src/jsMain/resources/index.html +++ b/compose/mpp/demo/src/jsMain/resources/index.html @@ -2,14 +2,13 @@ - + compose multiplatform web demo -

compose multiplatform web demo

diff --git a/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt b/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt new file mode 100644 index 0000000000000..bd54e2593b16f --- /dev/null +++ b/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2023 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 + +internal actual fun getVisualViewport(): VisualViewport? { + return js("window.visualViewport") as? VisualViewport +} \ No newline at end of file diff --git a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt index 9d6953f8d2a54..31f34503e197a 100644 --- a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt +++ b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.toCompose @@ -73,6 +74,9 @@ internal class ComposeLayer( onPointerEventWithMultitouch(event) } else { // macos and desktop`s web don't work properly when using onPointerEventWithMultitouch + if (scene.platformContext.inputModeManager.inputMode != InputMode.Keyboard) { + scene.platformContext.inputModeManager.requestInputMode(InputMode.Keyboard) + } onPointerEventNoMultitouch(event) } } diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt index 787f30dd88465..e84c179316306 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt @@ -15,4 +15,4 @@ */ package androidx.compose.ui.native -internal actual val supportsMultitouch: Boolean get() = false +internal actual val supportsMultitouch: Boolean get() = true diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt new file mode 100644 index 0000000000000..2bbbf0892e6ef --- /dev/null +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 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 kotlinx.browser.window +import org.w3c.dom.events.* + +internal interface ImeMobileKeyboardListener { + fun onShow(callback: ()->Unit) + fun onHide(callback: ()->Unit) +} + +internal class ImeKeyboardListenerImpl: ImeMobileKeyboardListener { + private var callbackOnShow = {} + private var callbackOnHide = {} + + init { + val visualViewport = getVisualViewport() + + if (visualViewport != null) { + val viewportVsClientHeightRatio = 0.75 + + visualViewport.addEventListener("resize", { event -> + val target = event.target as? VisualViewport ?: return@addEventListener + if ( + (target.height * target.scale) / window.screen.height < + viewportVsClientHeightRatio + ) { + callbackOnShow() + } else { + callbackOnHide() + } + }, false) + } + } + + override fun onShow(callback: () -> Unit) { + callbackOnShow = callback + } + + override fun onHide(callback: () -> Unit) { + callbackOnHide = callback + } +} + +abstract external class VisualViewport : EventTarget { + val offsetLeft: Double + val offsetTop: Double + + val pageLeft: Double + val pageTop: Double + + val width: Double + val height: Double + + val scale: Double + + val onresize: (Event) -> Unit + val onscroll: (Event) -> Unit + val onscrollend: (Event) -> Unit +} \ No newline at end of file diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt new file mode 100644 index 0000000000000..7ae9c87e9410d --- /dev/null +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2023 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.Rect +import androidx.compose.ui.text.input.CommitTextCommand +import androidx.compose.ui.text.input.DeleteAllCommand +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.SetSelectionCommand +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density +import kotlinx.browser.document +import org.w3c.dom.HTMLTextAreaElement +import org.w3c.dom.asList +import org.w3c.dom.events.* +import org.w3c.dom.events.KeyboardEvent +import org.w3c.dom.events.MouseEvent + +internal open class ImeTextInputService( + private val canvasId: String, + private val density: Density +) { + private val inputId = "compose-software-input-$canvasId" + + private val imeKeyboardListener = ImeKeyboardListenerImpl() + + private var composeInput: JSTextInputService.CurrentInput? = null + + private var currentHtmlInput: HTMLTextAreaElement? = null + + private fun createHtmlInput(): HTMLTextAreaElement { + val htmlInput = document.createElement("textarea") as HTMLTextAreaElement + + // handle ActionKey(Enter) + val keyHandler: (Event) -> Unit = keyHandler@{ event -> + event as KeyboardEvent + if (event.key == "Enter" && event.type == "keydown") { + runImeActionIfRequired() + } + } + + htmlInput.apply { + setAttribute("autocorrect", "off") + setAttribute("autocomplete", "off") + setAttribute("autocapitalize", "off") + setAttribute("spellcheck", "false") + setAttribute("readonly", "true") + className = inputId + id = inputId + 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", "0px") + setProperty("left", "0px") + setProperty("padding", "0px") + 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") + } + // disable native context menu + val eventHandler: (MouseEvent) -> Any = eventHandler@{ event -> + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + return@eventHandler false + } + + oncontextmenu = eventHandler as ((MouseEvent) -> Unit) + addEventListener("keyup", keyHandler, false) + addEventListener("keydown", keyHandler, false) + addEventListener("input", eventHandler@{ event -> + val el = (event.target as HTMLTextAreaElement) + val text = el.value + val cursorPosition = el.selectionEnd + sendImeValueToCompose(text, cursorPosition) + }, false) + } + document.body?.appendChild(htmlInput) + + return htmlInput + } + + private fun createOrGetHtmlInput(): HTMLTextAreaElement { + // Use the same input to prevent flashing. + return (currentHtmlInput ?: createHtmlInput()) as HTMLTextAreaElement + } + + fun clear() { + // console.log("clear") + composeInput = null + currentHtmlInput = null + document.getElementsByClassName(inputId).asList().forEach { + it as HTMLTextAreaElement + document.body?.removeChild(it) + } + } + + fun showSoftwareKeyboard() { + // Safari accepts the focus event only inside a touch event handler. + // if event from js call, it's not will work + composeInput?.let { composeInput -> + val htmlInput = createOrGetHtmlInput() + + val inputMode = when (composeInput.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 (composeInput.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" + } + val start = composeInput.value.selection.start ?: htmlInput.value.length - 1 + val end = composeInput.value.selection.start ?: htmlInput.value.length - 1 + + htmlInput.setAttribute("inputmode", inputMode) + htmlInput.setAttribute("enterkeyhint", enterKeyHint) + htmlInput.value = composeInput.value.text + + htmlInput.setSelectionRange(start, end) + currentHtmlInput = htmlInput + imeKeyboardListener.onHide { + clear() + } + } + } + + fun hideSoftwareKeyboard() { + clear() + } + + fun updateState(newValue: TextFieldValue) { + currentHtmlInput?.let { it -> + it.value = newValue.text + it.setSelectionRange(newValue.selection.start, newValue.selection.end) + } + } + + fun updatePosition(rect: Rect) { + val scale = density.density + document.getElementById(canvasId)?.getBoundingClientRect()?.let { offset -> + val offsetX = offset.left.toFloat().coerceAtLeast(0f) + (rect.left / scale) + val offsetY = offset.top.toFloat().coerceAtLeast(0f) + (rect.top / scale) + + currentHtmlInput?.let { html -> + + html.style.apply { + setProperty("left", "${offsetX}px") + setProperty("top", "${offsetY}px") + } + + val hasFocus = html == document.activeElement + + if (!hasFocus) { + html.removeAttribute("readonly") + html.focus() + } + } + } + } + + private fun sendImeValueToCompose(text: String, newCursorPosition: Int? = null) { + composeInput?.let { input -> + val value = if (text == "\n") { + "" + } else { + text + } + + if (newCursorPosition != null) { + input.onEditCommand( + listOf( + DeleteAllCommand(), + CommitTextCommand(value, 1), + SetSelectionCommand(newCursorPosition, newCursorPosition) + ) + ) + } else { + input.onEditCommand( + listOf( + CommitTextCommand(value, 1) + ) + ) + } + } + } + + private fun imeActionRequired(): Boolean = + composeInput?.imeOptions?.run { + singleLine || ( + imeAction != ImeAction.None + && imeAction != ImeAction.Default + && imeAction != ImeAction.Search + ) + } ?: false + + private fun runImeActionIfRequired(): Boolean { + val currentImeOptions = composeInput?.imeOptions + val currentImeActionHandler = composeInput?.onImeActionPerformed + val imeAction = currentImeOptions?.imeAction ?: return false + val imeActionHandler = currentImeActionHandler ?: return false + if (!imeActionRequired()) { + return false + } + if (imeAction == ImeAction.Default) { + imeActionHandler(ImeAction.Done) + } else { + imeActionHandler(imeAction) + } + return true + } + + fun setInput(input: JSTextInputService.CurrentInput?) { + composeInput = input + } +} + diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt index d1ad2425ac6ec..a0d7ce67cb036 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.platform +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.InputMode import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.EditCommand import androidx.compose.ui.text.input.ImeAction @@ -25,11 +27,16 @@ import androidx.compose.ui.text.input.TextFieldValue import org.jetbrains.skiko.SkikoInput import org.jetbrains.skiko.SkikoInputEvent -internal class JSTextInputService : PlatformTextInputService { +internal class JSTextInputService( + private val jsInputModeManager: DefaultInputModeManager, + private val imeTextInputService: ImeTextInputService +) : PlatformTextInputService { data class CurrentInput( var value: TextFieldValue, + var imeOptions: ImeOptions, val onEditCommand: ((List) -> Unit), + var onImeActionPerformed: (ImeAction) -> Unit ) private var currentInput: CurrentInput? = null @@ -42,26 +49,47 @@ internal class JSTextInputService : PlatformTextInputService { ) { currentInput = CurrentInput( value, - onEditCommand + imeOptions, + onEditCommand, + onImeActionPerformed ) - showSoftwareKeyboard() + + if (jsInputModeManager.inputMode == InputMode.Touch) { + imeTextInputService.setInput(currentInput) + showSoftwareKeyboard() + } } override fun stopInput() { currentInput = null + imeTextInputService.clear() } override fun showSoftwareKeyboard() { - println("TODO showSoftwareKeyboard in JS") + if (jsInputModeManager.inputMode == InputMode.Touch) { + imeTextInputService.setInput(currentInput) + imeTextInputService.showSoftwareKeyboard() + } } override fun hideSoftwareKeyboard() { - println("TODO showSoftwareKeyboard in JS") + if (jsInputModeManager.inputMode == InputMode.Touch) { + imeTextInputService.hideSoftwareKeyboard() + } + } + + override fun notifyFocusedRect(rect: Rect) { + if (jsInputModeManager.inputMode == InputMode.Touch) { + imeTextInputService.updatePosition(rect) + } } override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { currentInput?.let { input -> input.value = newValue + if (jsInputModeManager.inputMode == InputMode.Touch) { + imeTextInputService.updateState(newValue) + } } } diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt new file mode 100644 index 0000000000000..b3d5da4de782c --- /dev/null +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2023 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 + + +internal expect fun getVisualViewport(): VisualViewport? \ No newline at end of file diff --git a/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt b/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt new file mode 100644 index 0000000000000..5da21958fcce5 --- /dev/null +++ b/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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 + +@JsFun(""" + () => window.visualViewport +""") +internal actual external fun getVisualViewport(): VisualViewport? \ No newline at end of file From 1783b29a318fd25b8b882742144dd66d8a81cd1f Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 10 Apr 2024 14:04:30 +0200 Subject: [PATCH 2/5] Revert "add basic `ime` support for web browsers on mobile devices" This reverts commit 691b10e2171c3b74c67e06fc07680d956bc8e79c. --- .../compose/foundation/text/TouchMode.js.kt | 2 +- .../mpp/demo/src/jsMain/resources/index.html | 3 +- .../ui/platform/getVisualViewport.js.kt | 21 -- .../ui/native/ComposeLayer.jsNative.kt | 4 - .../androidx/compose/ui/native/Actuals.js.kt | 2 +- .../ui/platform/ImeMobileKeyboardListener.kt | 75 ------ .../ui/platform/ImeTextInputService.js.kt | 254 ------------------ .../ui/platform/JSTextInputService.js.kt | 38 +-- .../compose/ui/platform/getVisualViewport.kt | 20 -- .../ui/platform/getVisualViewport.wasmJs.kt | 22 -- 10 files changed, 9 insertions(+), 432 deletions(-) delete mode 100644 compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt delete mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt delete mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt delete mode 100644 compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt delete mode 100644 compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt diff --git a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt index 4861c5bcb65b6..f7bc041d323e4 100644 --- a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt +++ b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/TouchMode.js.kt @@ -16,4 +16,4 @@ package androidx.compose.foundation.text -internal actual val isInTouchMode = true +internal actual val isInTouchMode = false diff --git a/compose/mpp/demo/src/jsMain/resources/index.html b/compose/mpp/demo/src/jsMain/resources/index.html index 9e7f6b9b4a7cf..eda2871422713 100644 --- a/compose/mpp/demo/src/jsMain/resources/index.html +++ b/compose/mpp/demo/src/jsMain/resources/index.html @@ -2,13 +2,14 @@ - + compose multiplatform web demo +

compose multiplatform web demo

diff --git a/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt b/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt deleted file mode 100644 index bd54e2593b16f..0000000000000 --- a/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.js.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2023 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 - -internal actual fun getVisualViewport(): VisualViewport? { - return js("window.visualViewport") as? VisualViewport -} \ No newline at end of file diff --git a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt index 31f34503e197a..9d6953f8d2a54 100644 --- a/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt +++ b/compose/ui/ui/src/jsNativeMain/kotlin/androidx/compose/ui/native/ComposeLayer.jsNative.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.toCompose @@ -74,9 +73,6 @@ internal class ComposeLayer( onPointerEventWithMultitouch(event) } else { // macos and desktop`s web don't work properly when using onPointerEventWithMultitouch - if (scene.platformContext.inputModeManager.inputMode != InputMode.Keyboard) { - scene.platformContext.inputModeManager.requestInputMode(InputMode.Keyboard) - } onPointerEventNoMultitouch(event) } } diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt index e84c179316306..787f30dd88465 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/native/Actuals.js.kt @@ -15,4 +15,4 @@ */ package androidx.compose.ui.native -internal actual val supportsMultitouch: Boolean get() = true +internal actual val supportsMultitouch: Boolean get() = false diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt deleted file mode 100644 index 2bbbf0892e6ef..0000000000000 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeMobileKeyboardListener.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2023 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 kotlinx.browser.window -import org.w3c.dom.events.* - -internal interface ImeMobileKeyboardListener { - fun onShow(callback: ()->Unit) - fun onHide(callback: ()->Unit) -} - -internal class ImeKeyboardListenerImpl: ImeMobileKeyboardListener { - private var callbackOnShow = {} - private var callbackOnHide = {} - - init { - val visualViewport = getVisualViewport() - - if (visualViewport != null) { - val viewportVsClientHeightRatio = 0.75 - - visualViewport.addEventListener("resize", { event -> - val target = event.target as? VisualViewport ?: return@addEventListener - if ( - (target.height * target.scale) / window.screen.height < - viewportVsClientHeightRatio - ) { - callbackOnShow() - } else { - callbackOnHide() - } - }, false) - } - } - - override fun onShow(callback: () -> Unit) { - callbackOnShow = callback - } - - override fun onHide(callback: () -> Unit) { - callbackOnHide = callback - } -} - -abstract external class VisualViewport : EventTarget { - val offsetLeft: Double - val offsetTop: Double - - val pageLeft: Double - val pageTop: Double - - val width: Double - val height: Double - - val scale: Double - - val onresize: (Event) -> Unit - val onscroll: (Event) -> Unit - val onscrollend: (Event) -> Unit -} \ No newline at end of file diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt deleted file mode 100644 index 7ae9c87e9410d..0000000000000 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/ImeTextInputService.js.kt +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright 2023 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.Rect -import androidx.compose.ui.text.input.CommitTextCommand -import androidx.compose.ui.text.input.DeleteAllCommand -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.SetSelectionCommand -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.Density -import kotlinx.browser.document -import org.w3c.dom.HTMLTextAreaElement -import org.w3c.dom.asList -import org.w3c.dom.events.* -import org.w3c.dom.events.KeyboardEvent -import org.w3c.dom.events.MouseEvent - -internal open class ImeTextInputService( - private val canvasId: String, - private val density: Density -) { - private val inputId = "compose-software-input-$canvasId" - - private val imeKeyboardListener = ImeKeyboardListenerImpl() - - private var composeInput: JSTextInputService.CurrentInput? = null - - private var currentHtmlInput: HTMLTextAreaElement? = null - - private fun createHtmlInput(): HTMLTextAreaElement { - val htmlInput = document.createElement("textarea") as HTMLTextAreaElement - - // handle ActionKey(Enter) - val keyHandler: (Event) -> Unit = keyHandler@{ event -> - event as KeyboardEvent - if (event.key == "Enter" && event.type == "keydown") { - runImeActionIfRequired() - } - } - - htmlInput.apply { - setAttribute("autocorrect", "off") - setAttribute("autocomplete", "off") - setAttribute("autocapitalize", "off") - setAttribute("spellcheck", "false") - setAttribute("readonly", "true") - className = inputId - id = inputId - 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", "0px") - setProperty("left", "0px") - setProperty("padding", "0px") - 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") - } - // disable native context menu - val eventHandler: (MouseEvent) -> Any = eventHandler@{ event -> - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - return@eventHandler false - } - - oncontextmenu = eventHandler as ((MouseEvent) -> Unit) - addEventListener("keyup", keyHandler, false) - addEventListener("keydown", keyHandler, false) - addEventListener("input", eventHandler@{ event -> - val el = (event.target as HTMLTextAreaElement) - val text = el.value - val cursorPosition = el.selectionEnd - sendImeValueToCompose(text, cursorPosition) - }, false) - } - document.body?.appendChild(htmlInput) - - return htmlInput - } - - private fun createOrGetHtmlInput(): HTMLTextAreaElement { - // Use the same input to prevent flashing. - return (currentHtmlInput ?: createHtmlInput()) as HTMLTextAreaElement - } - - fun clear() { - // console.log("clear") - composeInput = null - currentHtmlInput = null - document.getElementsByClassName(inputId).asList().forEach { - it as HTMLTextAreaElement - document.body?.removeChild(it) - } - } - - fun showSoftwareKeyboard() { - // Safari accepts the focus event only inside a touch event handler. - // if event from js call, it's not will work - composeInput?.let { composeInput -> - val htmlInput = createOrGetHtmlInput() - - val inputMode = when (composeInput.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 (composeInput.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" - } - val start = composeInput.value.selection.start ?: htmlInput.value.length - 1 - val end = composeInput.value.selection.start ?: htmlInput.value.length - 1 - - htmlInput.setAttribute("inputmode", inputMode) - htmlInput.setAttribute("enterkeyhint", enterKeyHint) - htmlInput.value = composeInput.value.text - - htmlInput.setSelectionRange(start, end) - currentHtmlInput = htmlInput - imeKeyboardListener.onHide { - clear() - } - } - } - - fun hideSoftwareKeyboard() { - clear() - } - - fun updateState(newValue: TextFieldValue) { - currentHtmlInput?.let { it -> - it.value = newValue.text - it.setSelectionRange(newValue.selection.start, newValue.selection.end) - } - } - - fun updatePosition(rect: Rect) { - val scale = density.density - document.getElementById(canvasId)?.getBoundingClientRect()?.let { offset -> - val offsetX = offset.left.toFloat().coerceAtLeast(0f) + (rect.left / scale) - val offsetY = offset.top.toFloat().coerceAtLeast(0f) + (rect.top / scale) - - currentHtmlInput?.let { html -> - - html.style.apply { - setProperty("left", "${offsetX}px") - setProperty("top", "${offsetY}px") - } - - val hasFocus = html == document.activeElement - - if (!hasFocus) { - html.removeAttribute("readonly") - html.focus() - } - } - } - } - - private fun sendImeValueToCompose(text: String, newCursorPosition: Int? = null) { - composeInput?.let { input -> - val value = if (text == "\n") { - "" - } else { - text - } - - if (newCursorPosition != null) { - input.onEditCommand( - listOf( - DeleteAllCommand(), - CommitTextCommand(value, 1), - SetSelectionCommand(newCursorPosition, newCursorPosition) - ) - ) - } else { - input.onEditCommand( - listOf( - CommitTextCommand(value, 1) - ) - ) - } - } - } - - private fun imeActionRequired(): Boolean = - composeInput?.imeOptions?.run { - singleLine || ( - imeAction != ImeAction.None - && imeAction != ImeAction.Default - && imeAction != ImeAction.Search - ) - } ?: false - - private fun runImeActionIfRequired(): Boolean { - val currentImeOptions = composeInput?.imeOptions - val currentImeActionHandler = composeInput?.onImeActionPerformed - val imeAction = currentImeOptions?.imeAction ?: return false - val imeActionHandler = currentImeActionHandler ?: return false - if (!imeActionRequired()) { - return false - } - if (imeAction == ImeAction.Default) { - imeActionHandler(ImeAction.Done) - } else { - imeActionHandler(imeAction) - } - return true - } - - fun setInput(input: JSTextInputService.CurrentInput?) { - composeInput = input - } -} - diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt index a0d7ce67cb036..d1ad2425ac6ec 100644 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt +++ b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt @@ -16,8 +16,6 @@ package androidx.compose.ui.platform -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.input.InputMode import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.EditCommand import androidx.compose.ui.text.input.ImeAction @@ -27,16 +25,11 @@ import androidx.compose.ui.text.input.TextFieldValue import org.jetbrains.skiko.SkikoInput import org.jetbrains.skiko.SkikoInputEvent -internal class JSTextInputService( - private val jsInputModeManager: DefaultInputModeManager, - private val imeTextInputService: ImeTextInputService -) : PlatformTextInputService { +internal class JSTextInputService : PlatformTextInputService { data class CurrentInput( var value: TextFieldValue, - var imeOptions: ImeOptions, val onEditCommand: ((List) -> Unit), - var onImeActionPerformed: (ImeAction) -> Unit ) private var currentInput: CurrentInput? = null @@ -49,47 +42,26 @@ internal class JSTextInputService( ) { currentInput = CurrentInput( value, - imeOptions, - onEditCommand, - onImeActionPerformed + onEditCommand ) - - if (jsInputModeManager.inputMode == InputMode.Touch) { - imeTextInputService.setInput(currentInput) - showSoftwareKeyboard() - } + showSoftwareKeyboard() } override fun stopInput() { currentInput = null - imeTextInputService.clear() } override fun showSoftwareKeyboard() { - if (jsInputModeManager.inputMode == InputMode.Touch) { - imeTextInputService.setInput(currentInput) - imeTextInputService.showSoftwareKeyboard() - } + println("TODO showSoftwareKeyboard in JS") } override fun hideSoftwareKeyboard() { - if (jsInputModeManager.inputMode == InputMode.Touch) { - imeTextInputService.hideSoftwareKeyboard() - } - } - - override fun notifyFocusedRect(rect: Rect) { - if (jsInputModeManager.inputMode == InputMode.Touch) { - imeTextInputService.updatePosition(rect) - } + println("TODO showSoftwareKeyboard in JS") } override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { currentInput?.let { input -> input.value = newValue - if (jsInputModeManager.inputMode == InputMode.Touch) { - imeTextInputService.updateState(newValue) - } } } diff --git a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt b/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt deleted file mode 100644 index b3d5da4de782c..0000000000000 --- a/compose/ui/ui/src/jsWasmMain/kotlin/androidx/compose/ui/platform/getVisualViewport.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2023 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 - - -internal expect fun getVisualViewport(): VisualViewport? \ No newline at end of file diff --git a/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt b/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt deleted file mode 100644 index 5da21958fcce5..0000000000000 --- a/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/platform/getVisualViewport.wasmJs.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 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 - -@JsFun(""" - () => window.visualViewport -""") -internal actual external fun getVisualViewport(): VisualViewport? \ No newline at end of file From 5b226d9b03e0e778b5bee390f98273d060c3ce89 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 10 Apr 2024 14:06:31 +0200 Subject: [PATCH 3/5] Introduce minimal virtual keyboard support --- .../ui/platform/SynchronizedTextArea.kt | 167 ++++++++++++++++++ .../ui/platform/WebImeInputService.kt} | 41 +++-- .../ui/platform/WebKeyboardInputService.kt | 37 ++++ .../ui/platform/WebTextInputService.kt | 73 ++++++++ .../compose/ui/window/ComposeWindow.web.kt | 45 +++-- 5 files changed, 330 insertions(+), 33 deletions(-) create mode 100644 compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt rename compose/ui/ui/src/{jsWasmMain/kotlin/androidx/compose/ui/platform/JSTextInputService.js.kt => webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt} (60%) create mode 100644 compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebKeyboardInputService.kt create mode 100644 compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebTextInputService.kt diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt new file mode 100644 index 0000000000000..1aa114d570034 --- /dev/null +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt @@ -0,0 +1,167 @@ +/* + * 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 + +internal class SynchronizedTextArea( + 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) + }) + + // 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 60% 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..3eb6b1f22516c 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 synchronizedTextArea: SynchronizedTextArea? = 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 - ) + synchronizedTextArea = SynchronizedTextArea(imeOptions, onEditCommand, onImeActionPerformed) + synchronizedTextArea?.register() + showSoftwareKeyboard() } override fun stopInput() { - currentInput = null + synchronizedTextArea?.dispose() } override fun showSoftwareKeyboard() { - println("TODO showSoftwareKeyboard in JS") + synchronizedTextArea?.focus() } override fun hideSoftwareKeyboard() { - println("TODO showSoftwareKeyboard in JS") + synchronizedTextArea?.blur() } override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { - currentInput?.let { input -> - input.value = newValue - } + synchronizedTextArea?.updateState(newValue) } -} + + override fun notifyFocusedRect(rect: Rect) { + super.notifyFocusedRect(rect) + synchronizedTextArea?.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..629c414563671 --- /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 + +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) } From 3cd0c38cf77abf38c63ea4544ce217e8b78a9b07 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Wed, 10 Apr 2024 17:06:06 +0200 Subject: [PATCH 4/5] Disable contextmenu in the textarea we're delegating to --- .../androidx/compose/ui/platform/SynchronizedTextArea.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt index 1aa114d570034..c73811b80b22f 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt @@ -96,6 +96,11 @@ internal class SynchronizedTextArea( 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 -> From bd81005cc1409fe412edc0cd7863f08d25ff832e Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Thu, 11 Apr 2024 11:27:38 +0200 Subject: [PATCH 5/5] Rename SynchronizedTextArea.kt and make WebKeyboardInputService internal --- ...ynchronizedTextArea.kt => BackingTextArea.kt} | 7 ++++++- .../compose/ui/platform/WebImeInputService.kt | 16 ++++++++-------- .../ui/platform/WebKeyboardInputService.kt | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) rename compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/{SynchronizedTextArea.kt => BackingTextArea.kt} (96%) diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt similarity index 96% rename from compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt rename to compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt index c73811b80b22f..997e215f4d60e 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/SynchronizedTextArea.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/BackingTextArea.kt @@ -28,7 +28,12 @@ import kotlinx.browser.document import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.events.KeyboardEvent -internal class SynchronizedTextArea( +/** +* 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 diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt index 3eb6b1f22516c..72eca7b75ca62 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/WebImeInputService.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.text.input.TextFieldValue internal class WebImeInputService(parentInputService: InputAwareInputService) : PlatformTextInputService, InputAwareInputService by parentInputService { - private var synchronizedTextArea: SynchronizedTextArea? = null + private var backingTextArea: BackingTextArea? = null set(value) { field?.dispose() field = value @@ -37,31 +37,31 @@ internal class WebImeInputService(parentInputService: InputAwareInputService) : onEditCommand: (List) -> Unit, onImeActionPerformed: (ImeAction) -> Unit ) { - synchronizedTextArea = SynchronizedTextArea(imeOptions, onEditCommand, onImeActionPerformed) - synchronizedTextArea?.register() + backingTextArea = BackingTextArea(imeOptions, onEditCommand, onImeActionPerformed) + backingTextArea?.register() showSoftwareKeyboard() } override fun stopInput() { - synchronizedTextArea?.dispose() + backingTextArea?.dispose() } override fun showSoftwareKeyboard() { - synchronizedTextArea?.focus() + backingTextArea?.focus() } override fun hideSoftwareKeyboard() { - synchronizedTextArea?.blur() + backingTextArea?.blur() } override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { - synchronizedTextArea?.updateState(newValue) + backingTextArea?.updateState(newValue) } override fun notifyFocusedRect(rect: Rect) { super.notifyFocusedRect(rect) - synchronizedTextArea?.updateHtmlInputPosition(getOffset(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 index 629c414563671..1b99719669887 100644 --- 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 @@ -22,7 +22,7 @@ import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.PlatformTextInputService import androidx.compose.ui.text.input.TextFieldValue -class WebKeyboardInputService : PlatformTextInputService { +internal class WebKeyboardInputService : PlatformTextInputService { override fun startInput( value: TextFieldValue, imeOptions: ImeOptions,