Skip to content

Commit 2c54526

Browse files
authored
Fix web clipboard events in SelectionContainer (#2700) (#2701)
Cherry-pick from #2700 Fixes https://youtrack.jetbrains.com/issue/CMP-9508 ## Testing This should be tested by QA ## Release Notes ### Fixes - Web - Fix `Ctrl/Cmd + C` (copy) event handling for the selected text wrapped in SelectionContainer
2 parents 7956af5 + a754971 commit 2c54526

File tree

3 files changed

+99
-4
lines changed

3 files changed

+99
-4
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.internal
18+
19+
import kotlin.js.js
20+
import org.w3c.dom.HTMLElement
21+
22+
@Suppress("LocalVariableName")
23+
internal fun focusExt(element: HTMLElement, _preventScroll: Boolean): Unit =
24+
js("element.focus({ preventScroll: _preventScroll })")

compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,14 @@ import androidx.compose.ui.geometry.Rect
3030
import androidx.compose.ui.graphics.asComposeCanvas
3131
import androidx.compose.ui.input.InputMode
3232
import androidx.compose.ui.input.InputModeManager
33+
import androidx.compose.ui.input.key.Key
3334
import androidx.compose.ui.input.key.KeyEvent
35+
import androidx.compose.ui.input.key.KeyEventType
36+
import androidx.compose.ui.input.key.isCtrlPressed
37+
import androidx.compose.ui.input.key.isMetaPressed
38+
import androidx.compose.ui.input.key.key
3439
import androidx.compose.ui.input.key.toComposeEvent
40+
import androidx.compose.ui.input.key.type
3541
import androidx.compose.ui.input.pointer.BrowserCursor
3642
import androidx.compose.ui.input.pointer.PointerButtons
3743
import androidx.compose.ui.input.pointer.PointerEventType
@@ -41,6 +47,7 @@ import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
4147
import androidx.compose.ui.input.pointer.PointerType
4248
import androidx.compose.ui.input.pointer.composeButton
4349
import androidx.compose.ui.input.pointer.composeButtons
50+
import androidx.compose.ui.internal.focusExt
4451
import androidx.compose.ui.navigationevent.BackNavigationEventInput
4552
import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner
4653
import androidx.compose.ui.platform.DefaultInputModeManager
@@ -90,13 +97,16 @@ import kotlinx.coroutines.launch
9097
import org.jetbrains.skia.Canvas
9198
import org.jetbrains.skiko.SkiaLayer
9299
import org.jetbrains.skiko.SkikoRenderDelegate
100+
import org.jetbrains.skiko.hostOs
93101
import org.w3c.dom.AddEventListenerOptions
94102
import org.w3c.dom.Element
95103
import org.w3c.dom.HTMLCanvasElement
96104
import org.w3c.dom.HTMLDivElement
97105
import org.w3c.dom.HTMLElement
98106
import org.w3c.dom.HTMLStyleElement
99107
import org.w3c.dom.HTMLTitleElement
108+
import org.w3c.dom.HTMLTextAreaElement
109+
import org.w3c.dom.LOADING
100110
import org.w3c.dom.MediaQueryListEvent
101111
import org.w3c.dom.Node
102112
import org.w3c.dom.OPEN
@@ -186,6 +196,7 @@ internal class DefaultWindowState(private val viewportContainer: Element) : Comp
186196
internal class ComposeWindow(
187197
private val canvas: HTMLCanvasElement,
188198
private val rootNode: Node,
199+
private val layerRoot: HTMLElement,
189200
private val interopContainerElement: HTMLDivElement,
190201
private val a11yContainerElement: HTMLDivElement?,
191202
private val configuration: ComposeViewportConfiguration,
@@ -214,6 +225,8 @@ internal class ComposeWindow(
214225
// Used in WebTextInputService. Also see https://youtrack.jetbrains.com/issue/CMP-8611
215226
private var activeTouchOffset: Offset? = null
216227

228+
private val clipTarget = clipTargetElement(canvas)
229+
217230
private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() {
218231
override val windowInfo get() = _windowInfo
219232
override val architectureComponentsOwner get() = archComponentsOwner
@@ -331,7 +344,27 @@ internal class ComposeWindow(
331344
val keyEvent = keyboardEvent.toComposeEvent()
332345
val processed = scene.sendKeyEvent(keyEvent) ||
333346
navigationEventInput.onKeyEvent(keyEvent)
334-
if (processed) keyboardEvent.preventDefault()
347+
348+
if (processed) {
349+
keyboardEvent.preventDefault()
350+
} else if (keyEvent.type == KeyEventType.KeyDown){
351+
processClipKeyDown(keyEvent)
352+
}
353+
}
354+
355+
private val isMacOS = hostOs.isMacOS
356+
357+
private fun processClipKeyDown(keyEvent: KeyEvent) {
358+
val mod = if (isMacOS) keyEvent.isMetaPressed else keyEvent.isCtrlPressed
359+
if (!mod) return
360+
if (keyEvent.key == Key.C || keyEvent.key == Key.V || keyEvent.key == Key.X) {
361+
// A browser is about to dispatch a Clipboard Event.
362+
// Some browsers do not dispatch Clipboard events to <canvas> despite it having focus,
363+
// so let it dispatch the event to clipTarget (text area).
364+
// By focusing on it, we let a browser dispatch the event to it.
365+
layerRoot.appendChild(clipTarget)
366+
focusExt(clipTarget, true)
367+
}
335368
}
336369

337370
private fun initEvents(canvas: HTMLCanvasElement) {
@@ -421,13 +454,15 @@ internal class ComposeWindow(
421454

422455
val interopContainer = WebInteropContainer(InteropViewGroup(interopContainerElement))
423456

457+
val clipEventsTargetProvider: () -> HTMLElement = {
458+
(platformContext.textInputService as WebTextInputService).getBackingInput()
459+
?: clipTarget
460+
}
424461
scene.setContent {
425462
CompositionLocalProvider(
426463
LocalSystemTheme provides systemThemeObserver.currentSystemTheme.value,
427464
LocalInteropContainer provides interopContainer,
428-
LocalActiveClipEventsTarget provides {
429-
(platformContext.textInputService as WebTextInputService).getBackingInput() ?: canvas
430-
},
465+
LocalActiveClipEventsTarget provides clipEventsTargetProvider,
431466
content = {
432467
interopContainer.TrackInteropPlacementContainer {
433468
content()
@@ -681,6 +716,7 @@ fun CanvasBasedWindow(
681716
canvas = canvas,
682717
rootNode = canvas.getRootNode(),
683718
// a detached container
719+
layerRoot = document.createElement("div") as HTMLDivElement,
684720
interopContainerElement = document.createElement("div") as HTMLDivElement,
685721
a11yContainerElement = document.createElement("div") as HTMLDivElement,
686722
content = content,
@@ -781,10 +817,43 @@ fun ComposeViewport(
781817
ComposeWindow(
782818
canvas = canvas,
783819
rootNode = shadowRoot,
820+
layerRoot = layerRoot,
784821
interopContainerElement = interopContainerElement,
785822
a11yContainerElement = a11yContainerElement,
786823
content = content,
787824
configuration = configuration,
788825
state = DefaultWindowState(viewportContainer)
789826
)
827+
}
828+
829+
/**
830+
* The purpose of the clipTarget element is to briefly steal the focus to let the browser dispatch
831+
* ClipboardEvent to it. Then it returns the focus to the canvas.
832+
*/
833+
private fun clipTargetElement(canvas: HTMLCanvasElement): HTMLTextAreaElement {
834+
val clipTarget = (document.createElement("textarea") as HTMLTextAreaElement).apply {
835+
tabIndex = -1
836+
setAttribute("aria-hidden", "true")
837+
style.position = "fixed"
838+
style.left = "-1000px"
839+
style.top = "0"
840+
style.opacity = "0"
841+
style.width = "1px"
842+
style.height = "1px"
843+
}
844+
845+
val clipEventListener: (Event) -> Unit = { _ ->
846+
window.requestAnimationFrame {
847+
focusExt(canvas, true)
848+
clipTarget.remove()
849+
}
850+
}
851+
852+
// Here just return the focus to canvas.
853+
// For the actual event handling see rememberClipboardEventsHandler implementations.
854+
clipTarget.addEventListener("copy", clipEventListener)
855+
clipTarget.addEventListener("cut", clipEventListener)
856+
clipTarget.addEventListener("paste", clipEventListener)
857+
858+
return clipTarget
790859
}

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import kotlinx.browser.window
3030
import kotlinx.coroutines.channels.Channel
3131
import kotlinx.coroutines.test.runTest
3232
import org.w3c.dom.HTMLDivElement
33+
import org.w3c.dom.HTMLElement
3334

3435
class ComposeWindowLifecycleTest : OnCanvasTests {
3536
@Test
@@ -41,6 +42,7 @@ class ComposeWindowLifecycleTest : OnCanvasTests {
4142
val composeWindow = ComposeWindow(
4243
canvas = canvas,
4344
rootNode = getShadowRoot(),
45+
layerRoot = document.createElement("div") as HTMLElement,
4446
interopContainerElement = document.createElement("div") as HTMLDivElement,
4547
a11yContainerElement = null,
4648
content = {},

0 commit comments

Comments
 (0)