@@ -21,12 +21,11 @@ import androidx.compose.ui.input.key.toComposeEvent
21
21
import androidx.compose.ui.text.input.BackspaceCommand
22
22
import androidx.compose.ui.text.input.CommitTextCommand
23
23
import androidx.compose.ui.text.input.DeleteSurroundingTextCommand
24
- import androidx.compose.ui.text.input.EditCommand
25
24
import androidx.compose.ui.text.input.SetComposingTextCommand
26
25
import androidx.compose.ui.text.input.TextFieldValue
27
26
import org.w3c.dom.events.CompositionEvent
28
- import org.w3c.dom.events.Event
29
27
import org.w3c.dom.events.KeyboardEvent
28
+ import org.w3c.dom.events.UIEvent
30
29
31
30
/* *
32
31
* Processes native input events and handles their translation to commands
@@ -41,7 +40,7 @@ internal abstract class NativeInputEventsProcessor(
41
40
private val composeSender : ComposeCommandCommunicator
42
41
) {
43
42
44
- private val collectedEvents = mutableListOf<Event >()
43
+ private val collectedEvents = mutableListOf<UIEvent >()
45
44
private var isCheckpointScheduled = false
46
45
private var lastCompositionEndTimestamp = 0.0 // Double because of k/wasm where Number.toLong() leads to a compilation error
47
46
@@ -74,113 +73,117 @@ internal abstract class NativeInputEventsProcessor(
74
73
|| it.type == " beforeinput" && (it as InputEvent ).isComposing
75
74
}
76
75
77
- var keydownEvent: KeyboardEvent ? = null
78
- var compositionEndEvt: CompositionEvent ? = null
76
+ var lastProcessedEventIsBackspace: Boolean = false
79
77
80
78
collectedEvents.forEach { evt ->
81
- val eventName = evt.type
79
+ val timestamp = evt.timeStamp.toDouble()
82
80
83
- when (eventName ) {
81
+ when (evt.type ) {
84
82
" keydown" -> {
85
- keydownEvent = evt as KeyboardEvent
86
- val isTypedEvent = isTypedEvent(keydownEvent)
87
- val isFromLastComposition =
88
- keydownEvent.timeStamp.toDouble() < lastCompositionEndTimestamp
89
- if (! isInIMEComposition && ! isTypedEvent && ! isFromLastComposition) {
90
- composeSender.sendKeyboardEvent(keydownEvent.toComposeEvent())
83
+ if (isInIMEComposition) return @forEach
84
+
85
+ evt as KeyboardEvent
86
+ if (isTypedEvent(evt)) return @forEach
87
+
88
+ val isFromLastComposition = timestamp < lastCompositionEndTimestamp
89
+
90
+ // see https://youtrack.jetbrains.com/issue/CMP-8745/Web-Mobile.-iOS.-Composite-input.-Characters-arent-deleted
91
+ // on mobile iOS we cannot rely on the timestamp of the "keydown" event, it's always zero
92
+ // it might seem strange that we are ignoring the isFromLastComposition safeguard
93
+ // which was historically introduced exactly to resolve issues in Safari -
94
+ // but isFromLastComposition is needed so that we won't type a number digit if it was pressed during composition mode
95
+ // this is something that is not supposed to happen on mobile devices
96
+ val shouldBeProcessed = timestamp == 0.0 || ! isFromLastComposition
97
+
98
+ if (shouldBeProcessed) {
99
+ lastProcessedEventIsBackspace = evt.key == " Backspace"
100
+ composeSender.sendKeyboardEvent(evt.toComposeEvent())
91
101
}
92
102
}
93
103
94
104
" compositionend" -> {
95
- compositionEndEvt = evt as CompositionEvent
96
- lastCompositionEndTimestamp = evt.timeStamp.toDouble()
97
- composeSender.sendEditCommand(CommitTextCommand (compositionEndEvt.data, 1 ))
105
+ lastCompositionEndTimestamp = timestamp
106
+ composeSender.sendEditCommand(CommitTextCommand ((evt as CompositionEvent ).data, 1 ))
98
107
}
99
108
100
109
" beforeinput" -> {
101
- evt as InputEvent
102
- val inputType = evt.inputType
103
- val data = evt.data
104
-
105
- val editCommands = mutableListOf<EditCommand >()
106
- when (inputType) {
107
- " insertFromComposition" , " deleteCompositionText" -> {
108
- // We see these events in Safari just before 'compositionEnd' event.
109
- // We do nothing here, because Safari also sends 'insertCompositionText' which we handle,
110
- // and the behavior is as expected atm. We also handle 'compositionEnd'.
111
- }
112
-
113
- " deleteContentBackward" -> {
114
- // If it's "Backspace", then it's handled earlier in "keydown" above, so skipping it here
115
- if (keydownEvent?.key != " Backspace" ) {
116
- if (! currentTextFieldValue.selection.collapsed) {
117
- // Likely it's on mobile, where the Backspace has Unidentified key value.
118
- // When Compose TextField shows text selection,
119
- // a good UX for deleteContentBackward would be to emulate Backspace
120
- editCommands.add(BackspaceCommand ())
121
- } else {
122
- // This happens when an autocorrection is applied on mobile:
123
- // The system first tells us to delete the old text,
124
- // and then it would send the "insertText" event.
125
- val deleteSize = evt.deleteContentBackwardSize
126
- if (deleteSize > 0 ) {
127
- editCommands.add(DeleteSurroundingTextCommand (deleteSize, 0 ))
128
- }
129
- }
130
- }
131
- }
132
-
133
- " insertReplacementText" -> if (data != null ) {
134
- val deleteSize = evt.deleteContentBackwardSize
135
- if (deleteSize > 0 ) {
136
- editCommands.add(DeleteSurroundingTextCommand (deleteSize, 0 ))
137
- }
138
- editCommands.add(CommitTextCommand (data, 1 ))
139
- }
140
-
141
- " insertText" -> if (data != null ) {
142
- val deleteSize = evt.deleteContentBackwardSize
143
- if (deleteSize > 0 && currentTextFieldValue.selection.collapsed) {
144
- editCommands.add(DeleteSurroundingTextCommand (deleteSize, 0 ))
145
- }
146
-
147
- editCommands.add(CommitTextCommand (data, 1 ))
148
- }
149
-
150
- " insertCompositionText" -> if (data != null ) {
151
- val deleteSize = evt.deleteContentBackwardSize
152
- if (deleteSize > 0 ) {
153
- editCommands.add(DeleteSurroundingTextCommand (deleteSize, 0 ))
154
- }
155
- editCommands.add(SetComposingTextCommand (data, 1 ))
156
- }
157
- }
158
- composeSender.sendEditCommand(editCommands)
110
+ (evt as InputEvent ).process(
111
+ lastProcessedEventIsBackspace = lastProcessedEventIsBackspace,
112
+ currentTextFieldValue = currentTextFieldValue
113
+ )
159
114
}
160
115
}
161
116
}
162
117
163
118
collectedEvents.clear()
164
119
}
165
120
166
- internal fun addInputEvent (event : InputEvent , deleteContentBackwardSize : Int = 0) {
167
- if (deleteContentBackwardSize > 0 ) {
168
- event.deleteContentBackwardSize = deleteContentBackwardSize
121
+ private fun InputEvent.process (lastProcessedEventIsBackspace : Boolean , currentTextFieldValue : TextFieldValue ) {
122
+ val editCommands = when (inputType) {
123
+ " deleteContentBackward" -> buildList {
124
+ // this means "deleteContentBackward" happened because of an earlier "keydown" event, so skipping it here
125
+ if (lastProcessedEventIsBackspace) return @buildList
126
+
127
+ if (! currentTextFieldValue.selection.collapsed) {
128
+ // Likely it's on mobile, where the Backspace has Unidentified key value.
129
+ // When Compose TextField shows text selection,
130
+ // a good UX for deleteContentBackward would be to emulate Backspace
131
+ add(BackspaceCommand ())
132
+ } else {
133
+ // This happens when an autocorrection is applied on mobile:
134
+ // The system first tells us to delete the old text,
135
+ // and then it would send the "insertText" event.
136
+ val deleteSize = deleteContentBackwardSize
137
+ if (deleteSize > 0 ) {
138
+ add(DeleteSurroundingTextCommand (deleteSize, 0 ))
139
+ }
140
+ }
141
+ }
142
+
143
+ " insertReplacementText" -> buildList {
144
+ if (data == null ) return @buildList
145
+ val deleteSize = deleteContentBackwardSize
146
+ if (deleteSize > 0 ) {
147
+ add(DeleteSurroundingTextCommand (deleteSize, 0 ))
148
+ }
149
+
150
+ add(CommitTextCommand (data, 1 ))
151
+ }
152
+
153
+ " insertText" -> buildList {
154
+ if (data == null ) return @buildList
155
+ val deleteSize = deleteContentBackwardSize
156
+ if (deleteSize > 0 && currentTextFieldValue.selection.collapsed) {
157
+ add(DeleteSurroundingTextCommand (deleteSize, 0 ))
158
+ }
159
+
160
+ add(CommitTextCommand (data, 1 ))
161
+ }
162
+
163
+ " insertCompositionText" -> buildList {
164
+ if (data == null ) return @buildList
165
+ val deleteSize = deleteContentBackwardSize
166
+ if (deleteSize > 0 ) {
167
+ add(DeleteSurroundingTextCommand (deleteSize, 0 ))
168
+ }
169
+ add(SetComposingTextCommand (data, 1 ))
170
+ }
171
+
172
+ // "insertFromComposition", "deleteCompositionText" are triggered in Safari just before the 'compositionEnd' event.
173
+ // They're ignored because Safari also sends 'insertCompositionText' which we handle (alongside 'compositionEnd')
174
+ else -> emptyList()
169
175
}
170
- collectedEvents.add(event)
171
- internalScheduleCheckpoint()
172
- }
173
176
174
- internal fun addKeyEvent ( event : KeyboardEvent ) {
175
- collectedEvents.add(event )
176
- internalScheduleCheckpoint()
177
+ if (editCommands.isNotEmpty() ) {
178
+ composeSender.sendEditCommand(editCommands )
179
+ }
177
180
}
178
181
179
- internal fun addCompositionEvent (event : CompositionEvent ) {
182
+ internal fun registerEvent (event : UIEvent ) {
180
183
collectedEvents.add(event)
181
184
internalScheduleCheckpoint()
182
185
}
183
186
184
187
@TestOnly
185
- internal fun getCollectedEvents (): List < Event > = collectedEvents
188
+ internal fun getCollectedEvents () = collectedEvents
186
189
}
0 commit comments