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