Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 1b9fb67

Browse files
antholeoleAnthony Oleinik
andauthored
Fix web editable text composing range (#33590)
Flutter web framework now gets valid composing region updates from engine Co-authored-by: Anthony Oleinik <oleina@google.com>
1 parent 1a1c309 commit 1b9fb67

File tree

6 files changed

+446
-32
lines changed

6 files changed

+446
-32
lines changed

ci/licenses_golden/licenses_flutter

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart
11331133
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_break_properties.dart
11341134
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_breaker.dart
11351135
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart
1136+
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart
11361137
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
11371138
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart
11381139
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart

lib/web_ui/lib/src/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export 'engine/text/unicode_range.dart';
154154
export 'engine/text/word_break_properties.dart';
155155
export 'engine/text/word_breaker.dart';
156156
export 'engine/text_editing/autofill_hint.dart';
157+
export 'engine/text_editing/composition_aware_mixin.dart';
157158
export 'engine/text_editing/input_type.dart';
158159
export 'engine/text_editing/text_capitalization.dart';
159160
export 'engine/text_editing/text_editing.dart';
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:html' as html;
6+
7+
import 'text_editing.dart';
8+
9+
/// Provides default functionality for listening to HTML composition events.
10+
///
11+
/// A class with this mixin generally calls [determineCompositionState] in order to update
12+
/// an [EditingState] with new composition values; namely, [EditingState.composingBaseOffset]
13+
/// and [EditingState.composingExtentOffset].
14+
///
15+
/// A class with this mixin should call [addCompositionEventHandlers] on initalization, and
16+
/// [removeCompositionEventHandlers] on deinitalization.
17+
///
18+
/// See also:
19+
///
20+
/// * [EditingState], the state of a text field that [CompositionAwareMixin] updates.
21+
/// * [DefaultTextEditingStrategy], the primary implementer of [CompositionAwareMixin].
22+
mixin CompositionAwareMixin {
23+
/// The name of the HTML composition event type that triggers on starting a composition.
24+
static const String _kCompositionStart = 'compositionstart';
25+
26+
/// The name of the browser composition event type that triggers on updating a composition.
27+
static const String _kCompositionUpdate = 'compositionupdate';
28+
29+
/// The name of the browser composition event type that triggers on ending a composition.
30+
static const String _kCompositionEnd = 'compositionend';
31+
32+
late final html.EventListener _compositionStartListener = _handleCompositionStart;
33+
late final html.EventListener _compositionUpdateListener = _handleCompositionUpdate;
34+
late final html.EventListener _compositionEndListener = _handleCompositionEnd;
35+
36+
/// The currently composing text in the `domElement`.
37+
///
38+
/// Will be null if composing just started, ended, or no composing is being done.
39+
/// This member is kept up to date provided compositionEventHandlers are in place,
40+
/// so it is safe to reference it to get the current composingText.
41+
String? composingText;
42+
43+
void addCompositionEventHandlers(html.HtmlElement domElement) {
44+
domElement.addEventListener(_kCompositionStart, _compositionStartListener);
45+
domElement.addEventListener(_kCompositionUpdate, _compositionUpdateListener);
46+
domElement.addEventListener(_kCompositionEnd, _compositionEndListener);
47+
}
48+
49+
void removeCompositionEventHandlers(html.HtmlElement domElement) {
50+
domElement.removeEventListener(_kCompositionStart, _compositionStartListener);
51+
domElement.removeEventListener(_kCompositionUpdate, _compositionUpdateListener);
52+
domElement.removeEventListener(_kCompositionEnd, _compositionEndListener);
53+
}
54+
55+
void _handleCompositionStart(html.Event event) {
56+
composingText = null;
57+
}
58+
59+
void _handleCompositionUpdate(html.Event event) {
60+
if (event is html.CompositionEvent) {
61+
composingText = event.data;
62+
}
63+
}
64+
65+
void _handleCompositionEnd(html.Event event) {
66+
composingText = null;
67+
}
68+
69+
EditingState determineCompositionState(EditingState editingState) {
70+
if (editingState.baseOffset == null || composingText == null || editingState.text == null) {
71+
return editingState;
72+
}
73+
74+
final int composingBase = editingState.baseOffset! - composingText!.length;
75+
76+
if (composingBase < 0) {
77+
return editingState;
78+
}
79+
80+
return editingState.copyWith(
81+
composingBaseOffset: composingBase,
82+
composingExtentOffset: composingBase + composingText!.length,
83+
);
84+
}
85+
}

lib/web_ui/lib/src/engine/text_editing/text_editing.dart

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import '../services.dart';
2020
import '../text/paragraph.dart';
2121
import '../util.dart';
2222
import 'autofill_hint.dart';
23+
import 'composition_aware_mixin.dart';
2324
import 'input_type.dart';
2425
import 'text_capitalization.dart';
2526

@@ -508,7 +509,6 @@ class TextEditingDeltaState {
508509
final bool isCurrentlyComposing = newTextEditingDeltaState.composingOffset != null && newTextEditingDeltaState.composingOffset != newTextEditingDeltaState.composingExtent;
509510
if (newTextEditingDeltaState.deltaText.isNotEmpty && previousSelectionWasCollapsed && isCurrentlyComposing) {
510511
newTextEditingDeltaState.deltaStart = newTextEditingDeltaState.composingOffset!;
511-
newTextEditingDeltaState.deltaEnd = newTextEditingDeltaState.composingExtent!;
512512
}
513513

514514
final bool isDeltaRangeEmpty = newTextEditingDeltaState.deltaStart == -1 && newTextEditingDeltaState.deltaStart == newTextEditingDeltaState.deltaEnd;
@@ -618,6 +618,8 @@ class TextEditingDeltaState {
618618
'deltaEnd': deltaEnd,
619619
'selectionBase': baseOffset,
620620
'selectionExtent': extentOffset,
621+
'composingBase': composingOffset,
622+
'composingExtent': composingExtent
621623
},
622624
],
623625
};
@@ -647,7 +649,13 @@ class TextEditingDeltaState {
647649

648650
/// The current text and selection state of a text field.
649651
class EditingState {
650-
EditingState({this.text, int? baseOffset, int? extentOffset}) :
652+
EditingState({
653+
this.text,
654+
int? baseOffset,
655+
int? extentOffset,
656+
this.composingBaseOffset,
657+
this.composingExtentOffset
658+
}) :
651659
// Don't allow negative numbers. Pick the smallest selection index for base.
652660
baseOffset = math.max(0, math.min(baseOffset ?? 0, extentOffset ?? 0)),
653661
// Don't allow negative numbers. Pick the greatest selection index for extent.
@@ -674,14 +682,20 @@ class EditingState {
674682
/// valid selection range for input DOM elements.
675683
factory EditingState.fromFrameworkMessage(
676684
Map<String, dynamic> flutterEditingState) {
685+
final String? text = flutterEditingState.tryString('text');
686+
677687
final int selectionBase = flutterEditingState.readInt('selectionBase');
678688
final int selectionExtent = flutterEditingState.readInt('selectionExtent');
679-
final String? text = flutterEditingState.tryString('text');
689+
690+
final int? composingBase = flutterEditingState.tryInt('composingBase');
691+
final int? composingExtent = flutterEditingState.tryInt('composingExtent');
680692

681693
return EditingState(
682694
text: text,
683695
baseOffset: selectionBase,
684696
extentOffset: selectionExtent,
697+
composingBaseOffset: composingBase,
698+
composingExtentOffset: composingExtent
685699
);
686700
}
687701

@@ -708,13 +722,31 @@ class EditingState {
708722
}
709723
}
710724

725+
EditingState copyWith({
726+
String? text,
727+
int? baseOffset,
728+
int? extentOffset,
729+
int? composingBaseOffset,
730+
int? composingExtentOffset,
731+
}) {
732+
return EditingState(
733+
text: text ?? this.text,
734+
baseOffset: baseOffset ?? this.baseOffset,
735+
extentOffset: extentOffset ?? this.extentOffset,
736+
composingBaseOffset: composingBaseOffset ?? this.composingBaseOffset,
737+
composingExtentOffset: composingExtentOffset ?? this.composingExtentOffset,
738+
);
739+
}
740+
711741
/// The counterpart of [EditingState.fromFrameworkMessage]. It generates a Map that
712742
/// can be sent to Flutter.
713743
// TODO(mdebbar): Should we get `selectionAffinity` and other properties from flutter's editing state?
714744
Map<String, dynamic> toFlutter() => <String, dynamic>{
715745
'text': text,
716746
'selectionBase': baseOffset,
717747
'selectionExtent': extentOffset,
748+
'composingBase': composingBaseOffset,
749+
'composingExtent': composingExtentOffset,
718750
};
719751

720752
/// The current text being edited.
@@ -726,11 +758,19 @@ class EditingState {
726758
/// The offset at which the text selection terminates.
727759
final int? extentOffset;
728760

761+
/// The offset at which [CompositionAwareMixin.composingText] begins, if any.
762+
final int? composingBaseOffset;
763+
764+
/// The offset at which [CompositionAwareMixin.composingText] terminates, if any.
765+
final int? composingExtentOffset;
766+
729767
/// Whether the current editing state is valid or not.
730768
bool get isValid => baseOffset! >= 0 && extentOffset! >= 0;
731769

732770
@override
733-
int get hashCode => Object.hash(text, baseOffset, extentOffset);
771+
int get hashCode => Object.hash(
772+
text, baseOffset, extentOffset, composingBaseOffset, composingExtentOffset
773+
);
734774

735775
@override
736776
bool operator ==(Object other) {
@@ -743,13 +783,15 @@ class EditingState {
743783
return other is EditingState &&
744784
other.text == text &&
745785
other.baseOffset == baseOffset &&
746-
other.extentOffset == extentOffset;
786+
other.extentOffset == extentOffset &&
787+
other.composingBaseOffset == composingBaseOffset &&
788+
other.composingExtentOffset == composingExtentOffset;
747789
}
748790

749791
@override
750792
String toString() {
751793
return assertionsEnabled
752-
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset)'
794+
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset, composingBase:$composingBaseOffset, composingExtent:$composingExtentOffset)'
753795
: super.toString();
754796
}
755797

@@ -1038,7 +1080,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
10381080
///
10391081
/// Unless a formfactor/browser requires specific implementation for a specific
10401082
/// strategy the methods in this class should be used.
1041-
abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
1083+
abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements TextEditingStrategy {
10421084
final HybridTextEditing owner;
10431085

10441086
DefaultTextEditingStrategy(this.owner);
@@ -1169,7 +1211,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
11691211

11701212
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
11711213

1172-
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
1214+
addCompositionEventHandlers(activeDomElement);
11731215

11741216
// Refocus on the activeDomElement after blur, so that user can keep editing the
11751217
// text field.
@@ -1210,6 +1252,8 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
12101252
subscriptions[i].cancel();
12111253
}
12121254
subscriptions.clear();
1255+
removeCompositionEventHandlers(activeDomElement);
1256+
12131257
// If focused element is a part of a form, it needs to stay on the DOM
12141258
// until the autofill context of the form is finalized.
12151259
// More details on `TextInput.finishAutofillContext` call.
@@ -1246,9 +1290,13 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
12461290
void handleChange(html.Event event) {
12471291
assert(isEnabled);
12481292

1249-
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
1293+
EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
1294+
newEditingState = determineCompositionState(newEditingState);
1295+
12501296
TextEditingDeltaState? newTextEditingDeltaState;
12511297
if (inputConfiguration.enableDeltaModel) {
1298+
editingDeltaState.composingOffset = newEditingState.composingBaseOffset;
1299+
editingDeltaState.composingExtent = newEditingState.composingExtentOffset;
12521300
newTextEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditingState, lastEditingState, editingDeltaState);
12531301
}
12541302

@@ -1295,12 +1343,6 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
12951343
}
12961344
}
12971345

1298-
void handleCompositionUpdate(html.Event event) {
1299-
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
1300-
editingDeltaState.composingOffset = newEditingState.baseOffset!;
1301-
editingDeltaState.composingExtent = newEditingState.extentOffset!;
1302-
}
1303-
13041346
void maybeSendAction(html.Event event) {
13051347
if (event is html.KeyboardEvent && event.keyCode == _kReturnKeyCode) {
13061348
onAction!(inputConfiguration.inputAction);
@@ -1450,7 +1492,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
14501492

14511493
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
14521494

1453-
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
1495+
addCompositionEventHandlers(activeDomElement);
14541496

14551497
// Position the DOM element after it is focused.
14561498
subscriptions.add(activeDomElement.onFocus.listen((_) {
@@ -1594,7 +1636,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
15941636

15951637
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
15961638

1597-
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
1639+
addCompositionEventHandlers(activeDomElement);
15981640

15991641
subscriptions.add(activeDomElement.onBlur.listen((_) {
16001642
if (windowHasFocus) {
@@ -1650,7 +1692,7 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
16501692

16511693
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
16521694

1653-
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
1695+
addCompositionEventHandlers(activeDomElement);
16541696

16551697
// Detects changes in text selection.
16561698
//

0 commit comments

Comments
 (0)