Skip to content

Commit

Permalink
Listen to browser clipboard events and bind them with Compose TextFie…
Browse files Browse the repository at this point in the history
…ldSelectionManager and SelectionManager (#1206)

**Notes:** 
I had to almost copy/paste the code for k/js and k/wasm because of
k/wasm-js and k/js-js interop differences. The function is not too big,
so I consider it as a compromise.

**Test in mpp:demo:**
- Components -> Selection : select some text and press Cmd + C or Ctrl +
C, try to paste it then somewhere
- Components -> TextFields -> Almost FullScreen: Try all cobminations

---------

Co-authored-by: Shagen Ogandzhanian <shagen.ogandzhanian@jetbrains.com>
  • Loading branch information
eymar and Schahen committed Mar 21, 2024
1 parent 7248d48 commit be54485
Show file tree
Hide file tree
Showing 14 changed files with 373 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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.foundation.text

import androidx.compose.runtime.Composable

@Composable
internal expect inline fun rememberClipboardEventsHandler(
crossinline onPaste: (String) -> Unit = {},
crossinline onCopy: () -> String? = { null },
crossinline onCut: () -> String? = { null },
isEnabled: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ import androidx.compose.foundation.text.selection.SimpleLayout
import androidx.compose.foundation.text.selection.TextFieldSelectionHandle
import androidx.compose.foundation.text.selection.TextFieldSelectionManager
import androidx.compose.foundation.text.selection.isSelectionHandleInVisibleBound
import androidx.compose.foundation.text.selection.selectionGestureInput
import androidx.compose.foundation.text.selection.textFieldMagnifier
import androidx.compose.foundation.text.selection.updateSelectionTouchMode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
Expand Down Expand Up @@ -63,7 +61,6 @@ import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.IntrinsicMeasurable
Expand Down Expand Up @@ -304,6 +301,13 @@ internal fun CoreTextField(
val coroutineScope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }

rememberClipboardEventsHandler(
isEnabled = state.hasFocus,
onCopy = { manager.onCopyWithResult() },
onCut = { manager.onCutWithResult() },
onPaste = { manager.paste(AnnotatedString(it)) }
)

// Focus
val focusModifier = Modifier.textFieldFocusModifier(
enabled = enabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package androidx.compose.foundation.text.selection

import androidx.compose.foundation.text.ContextMenuArea
import androidx.compose.foundation.text.detectDownAndDragGesturesWithObserver
import androidx.compose.foundation.text.rememberClipboardEventsHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
Expand Down Expand Up @@ -95,6 +96,11 @@ internal fun SelectionContainer(
manager.onSelectionChange = onSelectionChange
manager.selection = selection

rememberClipboardEventsHandler(
onCopy = { manager.getSelectedText()?.text },
isEnabled = manager.isNonEmptySelection()
)

ContextMenuArea(manager) {
CompositionLocalProvider(LocalSelectionRegistrar provides registrarImpl) {
// Get the layout coordinates of the selection container. This is for hit test of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ internal class SelectionManager(private val selectionRegistrar: SelectionRegistr
.focusable()
.updateSelectionTouchMode { isInTouchMode = it }
.onKeyEvent {
if (isCopyKeyEvent(it)) {
if (!skipCopyKeyEvent && isCopyKeyEvent(it)) {
copy()
true
} else {
Expand Down Expand Up @@ -1028,3 +1028,7 @@ private suspend fun AwaitPointerEventScope.awaitPointerEventWhereAllChanges(
pass: PointerEventPass = PointerEventPass.Main,
predicate: (PointerInputChange) -> Boolean,
) = awaitPointerEvent(pass).takeIf { it.changes.fastAll(predicate) }


// We skip `isCopyKeyEvent(it)` on web, because should handle browser 'copy' event
internal expect val SelectionManager.skipCopyKeyEvent: Boolean
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.TextToolbar
Expand Down Expand Up @@ -600,6 +599,22 @@ internal class TextFieldSelectionManager(
setHandleState(HandleState.None)
}

internal fun onCopyWithResult(cancelSelection: Boolean = true): String? {
if (value.selection.collapsed) return null
val selectedText = value.getSelectedText().text

if (!cancelSelection) return selectedText

val newCursorOffset = value.selection.max
val newValue = createTextFieldValue(
annotatedString = value.annotatedString,
selection = TextRange(newCursorOffset, newCursorOffset)
)
onValueChange(newValue)
setHandleState(HandleState.None)
return selectedText
}

/**
* The method for pasting text.
*
Expand All @@ -626,6 +641,21 @@ internal class TextFieldSelectionManager(
undoManager?.forceNextSnapshot()
}

internal fun paste(text: AnnotatedString) {
val newText = value.getTextBeforeSelection(value.text.length) +
text +
value.getTextAfterSelection(value.text.length)
val newCursorOffset = value.selection.min + text.length

val newValue = createTextFieldValue(
annotatedString = newText,
selection = TextRange(newCursorOffset, newCursorOffset)
)
onValueChange(newValue)
setHandleState(HandleState.None)
undoManager?.forceNextSnapshot()
}

/**
* The method for cutting text.
*
Expand Down Expand Up @@ -654,6 +684,25 @@ internal class TextFieldSelectionManager(
undoManager?.forceNextSnapshot()
}

internal fun onCutWithResult(): String? {
if (value.selection.collapsed) return null
val selectedText = value.getSelectedText().text

val newText = value.getTextBeforeSelection(value.text.length) +
value.getTextAfterSelection(value.text.length)
val newCursorOffset = value.selection.min

val newValue = createTextFieldValue(
annotatedString = newText,
selection = TextRange(newCursorOffset, newCursorOffset)
)
onValueChange(newValue)
setHandleState(HandleState.None)
undoManager?.forceNextSnapshot()

return selectedText
}

/*@VisibleForTesting*/
internal fun selectAll() {
val newValue = createTextFieldValue(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.foundation.text

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.NonRestartableComposable
import kotlinx.browser.document
import org.w3c.dom.clipboard.ClipboardEvent
import org.w3c.dom.events.EventListener

@Composable
@NonRestartableComposable
internal actual inline fun rememberClipboardEventsHandler(
crossinline onPaste: (String) -> Unit,
crossinline onCopy: () -> String?,
crossinline onCut: () -> String?,
isEnabled: Boolean
) {
if (isEnabled) {
DisposableEffect(Unit) {

val copyListener = EventListener { event ->
val textToCopy = onCopy()
if (textToCopy != null && event is ClipboardEvent) {
event.clipboardData?.setData("text/plain", textToCopy)
event.preventDefault()
}
}

val pasteListener = EventListener { event ->
if (event is ClipboardEvent) {
val textToPaste = event.clipboardData?.getData("text/plain") ?: ""
onPaste(textToPaste)
event.preventDefault()
}
}

val cutListener = EventListener { event ->
val cutText = onCut()
if (cutText != null && event is ClipboardEvent) {
event.clipboardData?.setData("text/plain", cutText)
event.preventDefault()
}
}

document.addEventListener("copy", copyListener)
document.addEventListener("paste", pasteListener)
document.addEventListener("cut", cutListener)

onDispose {
document.removeEventListener("copy", copyListener)
document.removeEventListener("paste", pasteListener)
document.removeEventListener("cut", cutListener)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.foundation.text

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.hostOs

internal actual val platformDefaultKeyMapping: KeyMapping = createPlatformDefaultKeyMapping(hostOs)

internal fun createPlatformDefaultKeyMapping(platform: OS): KeyMapping {
val keyMapping = when (platform) {
OS.MacOS -> createMacosDefaultKeyMapping()
else -> defaultKeyMapping
}
return object : KeyMapping {
private val clipboardKeys = setOf(Key.C, Key.V, Key.X)

override fun map(event: KeyEvent): KeyCommand? {
val isCtrlOrCmd = if (hostOs.isMacOS) event.isMetaPressed else event.isCtrlPressed
if (isCtrlOrCmd && event.key in clipboardKeys) {
// we let a browser dispatch a clipboard event
return null
}
return keyMapping.map(event)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean {
*/
internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier =
TODO("implement js selectionMagnifier")

internal actual val SelectionManager.skipCopyKeyEvent: Boolean
get() = true
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Android Open Source Project
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,14 +16,14 @@

package androidx.compose.foundation.text

import org.jetbrains.skiko.OS
import org.jetbrains.skiko.hostOs
import androidx.compose.runtime.Composable

internal actual val platformDefaultKeyMapping: KeyMapping = createPlatformDefaultKeyMapping(hostOs)

internal fun createPlatformDefaultKeyMapping(platform: OS): KeyMapping {
return when (platform) {
OS.MacOS -> createMacosDefaultKeyMapping()
else -> defaultKeyMapping
}
@Composable
internal actual inline fun rememberClipboardEventsHandler(
crossinline onPaste: (String) -> Unit,
crossinline onCopy: () -> String?,
crossinline onCut: () -> String?,
isEnabled: Boolean
) {
// nothing to do
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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.foundation.text.selection

internal actual val SelectionManager.skipCopyKeyEvent: Boolean
get() = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.foundation.text

import androidx.compose.runtime.Composable

@Composable
internal actual inline fun rememberClipboardEventsHandler(
crossinline onPaste: (String) -> Unit,
crossinline onCopy: () -> String?,
crossinline onCut: () -> String?,
isEnabled: Boolean
) {
// nothing to do
}
Loading

0 comments on commit be54485

Please sign in to comment.