Fix web editable text composing range (flutter/engine#33590)

Flutter web framework now gets valid composing region updates from engine

Co-authored-by: Anthony Oleinik <oleina@google.com>
This commit is contained in:
Anthony Oleinik 2022-06-03 16:31:47 -07:00 committed by GitHub
parent 6f236556a3
commit 92c0646dd4
6 changed files with 446 additions and 32 deletions

View File

@ -1133,6 +1133,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/unicode_range.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_break_properties.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_breaker.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart

View File

@ -154,6 +154,7 @@ export 'engine/text/unicode_range.dart';
export 'engine/text/word_break_properties.dart';
export 'engine/text/word_breaker.dart';
export 'engine/text_editing/autofill_hint.dart';
export 'engine/text_editing/composition_aware_mixin.dart';
export 'engine/text_editing/input_type.dart';
export 'engine/text_editing/text_capitalization.dart';
export 'engine/text_editing/text_editing.dart';

View File

@ -0,0 +1,85 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:html' as html;
import 'text_editing.dart';
/// Provides default functionality for listening to HTML composition events.
///
/// A class with this mixin generally calls [determineCompositionState] in order to update
/// an [EditingState] with new composition values; namely, [EditingState.composingBaseOffset]
/// and [EditingState.composingExtentOffset].
///
/// A class with this mixin should call [addCompositionEventHandlers] on initalization, and
/// [removeCompositionEventHandlers] on deinitalization.
///
/// See also:
///
/// * [EditingState], the state of a text field that [CompositionAwareMixin] updates.
/// * [DefaultTextEditingStrategy], the primary implementer of [CompositionAwareMixin].
mixin CompositionAwareMixin {
/// The name of the HTML composition event type that triggers on starting a composition.
static const String _kCompositionStart = 'compositionstart';
/// The name of the browser composition event type that triggers on updating a composition.
static const String _kCompositionUpdate = 'compositionupdate';
/// The name of the browser composition event type that triggers on ending a composition.
static const String _kCompositionEnd = 'compositionend';
late final html.EventListener _compositionStartListener = _handleCompositionStart;
late final html.EventListener _compositionUpdateListener = _handleCompositionUpdate;
late final html.EventListener _compositionEndListener = _handleCompositionEnd;
/// The currently composing text in the `domElement`.
///
/// Will be null if composing just started, ended, or no composing is being done.
/// This member is kept up to date provided compositionEventHandlers are in place,
/// so it is safe to reference it to get the current composingText.
String? composingText;
void addCompositionEventHandlers(html.HtmlElement domElement) {
domElement.addEventListener(_kCompositionStart, _compositionStartListener);
domElement.addEventListener(_kCompositionUpdate, _compositionUpdateListener);
domElement.addEventListener(_kCompositionEnd, _compositionEndListener);
}
void removeCompositionEventHandlers(html.HtmlElement domElement) {
domElement.removeEventListener(_kCompositionStart, _compositionStartListener);
domElement.removeEventListener(_kCompositionUpdate, _compositionUpdateListener);
domElement.removeEventListener(_kCompositionEnd, _compositionEndListener);
}
void _handleCompositionStart(html.Event event) {
composingText = null;
}
void _handleCompositionUpdate(html.Event event) {
if (event is html.CompositionEvent) {
composingText = event.data;
}
}
void _handleCompositionEnd(html.Event event) {
composingText = null;
}
EditingState determineCompositionState(EditingState editingState) {
if (editingState.baseOffset == null || composingText == null || editingState.text == null) {
return editingState;
}
final int composingBase = editingState.baseOffset! - composingText!.length;
if (composingBase < 0) {
return editingState;
}
return editingState.copyWith(
composingBaseOffset: composingBase,
composingExtentOffset: composingBase + composingText!.length,
);
}
}

View File

@ -20,6 +20,7 @@ import '../services.dart';
import '../text/paragraph.dart';
import '../util.dart';
import 'autofill_hint.dart';
import 'composition_aware_mixin.dart';
import 'input_type.dart';
import 'text_capitalization.dart';
@ -508,7 +509,6 @@ class TextEditingDeltaState {
final bool isCurrentlyComposing = newTextEditingDeltaState.composingOffset != null && newTextEditingDeltaState.composingOffset != newTextEditingDeltaState.composingExtent;
if (newTextEditingDeltaState.deltaText.isNotEmpty && previousSelectionWasCollapsed && isCurrentlyComposing) {
newTextEditingDeltaState.deltaStart = newTextEditingDeltaState.composingOffset!;
newTextEditingDeltaState.deltaEnd = newTextEditingDeltaState.composingExtent!;
}
final bool isDeltaRangeEmpty = newTextEditingDeltaState.deltaStart == -1 && newTextEditingDeltaState.deltaStart == newTextEditingDeltaState.deltaEnd;
@ -618,6 +618,8 @@ class TextEditingDeltaState {
'deltaEnd': deltaEnd,
'selectionBase': baseOffset,
'selectionExtent': extentOffset,
'composingBase': composingOffset,
'composingExtent': composingExtent
},
],
};
@ -647,7 +649,13 @@ class TextEditingDeltaState {
/// The current text and selection state of a text field.
class EditingState {
EditingState({this.text, int? baseOffset, int? extentOffset}) :
EditingState({
this.text,
int? baseOffset,
int? extentOffset,
this.composingBaseOffset,
this.composingExtentOffset
}) :
// Don't allow negative numbers. Pick the smallest selection index for base.
baseOffset = math.max(0, math.min(baseOffset ?? 0, extentOffset ?? 0)),
// Don't allow negative numbers. Pick the greatest selection index for extent.
@ -674,14 +682,20 @@ class EditingState {
/// valid selection range for input DOM elements.
factory EditingState.fromFrameworkMessage(
Map<String, dynamic> flutterEditingState) {
final String? text = flutterEditingState.tryString('text');
final int selectionBase = flutterEditingState.readInt('selectionBase');
final int selectionExtent = flutterEditingState.readInt('selectionExtent');
final String? text = flutterEditingState.tryString('text');
final int? composingBase = flutterEditingState.tryInt('composingBase');
final int? composingExtent = flutterEditingState.tryInt('composingExtent');
return EditingState(
text: text,
baseOffset: selectionBase,
extentOffset: selectionExtent,
composingBaseOffset: composingBase,
composingExtentOffset: composingExtent
);
}
@ -708,6 +722,22 @@ class EditingState {
}
}
EditingState copyWith({
String? text,
int? baseOffset,
int? extentOffset,
int? composingBaseOffset,
int? composingExtentOffset,
}) {
return EditingState(
text: text ?? this.text,
baseOffset: baseOffset ?? this.baseOffset,
extentOffset: extentOffset ?? this.extentOffset,
composingBaseOffset: composingBaseOffset ?? this.composingBaseOffset,
composingExtentOffset: composingExtentOffset ?? this.composingExtentOffset,
);
}
/// The counterpart of [EditingState.fromFrameworkMessage]. It generates a Map that
/// can be sent to Flutter.
// TODO(mdebbar): Should we get `selectionAffinity` and other properties from flutter's editing state?
@ -715,6 +745,8 @@ class EditingState {
'text': text,
'selectionBase': baseOffset,
'selectionExtent': extentOffset,
'composingBase': composingBaseOffset,
'composingExtent': composingExtentOffset,
};
/// The current text being edited.
@ -726,11 +758,19 @@ class EditingState {
/// The offset at which the text selection terminates.
final int? extentOffset;
/// The offset at which [CompositionAwareMixin.composingText] begins, if any.
final int? composingBaseOffset;
/// The offset at which [CompositionAwareMixin.composingText] terminates, if any.
final int? composingExtentOffset;
/// Whether the current editing state is valid or not.
bool get isValid => baseOffset! >= 0 && extentOffset! >= 0;
@override
int get hashCode => Object.hash(text, baseOffset, extentOffset);
int get hashCode => Object.hash(
text, baseOffset, extentOffset, composingBaseOffset, composingExtentOffset
);
@override
bool operator ==(Object other) {
@ -743,13 +783,15 @@ class EditingState {
return other is EditingState &&
other.text == text &&
other.baseOffset == baseOffset &&
other.extentOffset == extentOffset;
other.extentOffset == extentOffset &&
other.composingBaseOffset == composingBaseOffset &&
other.composingExtentOffset == composingExtentOffset;
}
@override
String toString() {
return assertionsEnabled
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset)'
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset, composingBase:$composingBaseOffset, composingExtent:$composingExtentOffset)'
: super.toString();
}
@ -1038,7 +1080,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
///
/// Unless a formfactor/browser requires specific implementation for a specific
/// strategy the methods in this class should be used.
abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements TextEditingStrategy {
final HybridTextEditing owner;
DefaultTextEditingStrategy(this.owner);
@ -1169,7 +1211,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
addCompositionEventHandlers(activeDomElement);
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
@ -1210,6 +1252,8 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
subscriptions[i].cancel();
}
subscriptions.clear();
removeCompositionEventHandlers(activeDomElement);
// If focused element is a part of a form, it needs to stay on the DOM
// until the autofill context of the form is finalized.
// More details on `TextInput.finishAutofillContext` call.
@ -1246,9 +1290,13 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
void handleChange(html.Event event) {
assert(isEnabled);
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
newEditingState = determineCompositionState(newEditingState);
TextEditingDeltaState? newTextEditingDeltaState;
if (inputConfiguration.enableDeltaModel) {
editingDeltaState.composingOffset = newEditingState.composingBaseOffset;
editingDeltaState.composingExtent = newEditingState.composingExtentOffset;
newTextEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditingState, lastEditingState, editingDeltaState);
}
@ -1295,12 +1343,6 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}
}
void handleCompositionUpdate(html.Event event) {
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
editingDeltaState.composingOffset = newEditingState.baseOffset!;
editingDeltaState.composingExtent = newEditingState.extentOffset!;
}
void maybeSendAction(html.Event event) {
if (event is html.KeyboardEvent && event.keyCode == _kReturnKeyCode) {
onAction!(inputConfiguration.inputAction);
@ -1450,7 +1492,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
addCompositionEventHandlers(activeDomElement);
// Position the DOM element after it is focused.
subscriptions.add(activeDomElement.onFocus.listen((_) {
@ -1594,7 +1636,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
addCompositionEventHandlers(activeDomElement);
subscriptions.add(activeDomElement.onBlur.listen((_) {
if (windowHasFocus) {
@ -1650,7 +1692,7 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
addCompositionEventHandlers(activeDomElement);
// Detects changes in text selection.
//

View File

@ -0,0 +1,260 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:html' as html;
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine/browser_detection.dart';
import 'package:ui/src/engine/initialization.dart';
import 'package:ui/src/engine/text_editing/composition_aware_mixin.dart';
import 'package:ui/src/engine/text_editing/input_type.dart';
import 'package:ui/src/engine/text_editing/text_editing.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
class _MockWithCompositionAwareMixin with CompositionAwareMixin {
// These variables should be equal to their counterparts in CompositionAwareMixin.
// Separate so the counterparts in CompositionAwareMixin can be private.
static const String _kCompositionUpdate = 'compositionupdate';
static const String _kCompositionStart = 'compositionstart';
static const String _kCompositionEnd = 'compositionend';
}
html.InputElement get _inputElement {
return defaultTextEditingRoot.querySelectorAll('input').first as html.InputElement;
}
GloballyPositionedTextEditingStrategy _enableEditingStrategy({
required bool deltaModel,
void Function(EditingState?, TextEditingDeltaState?)? onChange,
}) {
final HybridTextEditing owner = HybridTextEditing();
owner.configuration = InputConfiguration(inputType: EngineInputType.text, enableDeltaModel: deltaModel);
final GloballyPositionedTextEditingStrategy editingStrategy =
GloballyPositionedTextEditingStrategy(owner);
owner.debugTextEditingStrategyOverride = editingStrategy;
editingStrategy.enable(owner.configuration!, onChange: onChange ?? (_, __) {}, onAction: (_) {});
return editingStrategy;
}
Future<void> testMain() async {
await initializeEngine();
const String fakeComposingText = 'ImComposingText';
group('$CompositionAwareMixin', () {
late TextEditingStrategy editingStrategy;
setUp(() {
editingStrategy = _enableEditingStrategy(deltaModel: false);
});
tearDown(() {
editingStrategy.disable();
});
group('composition end', () {
test('should reset composing text on handle composition end', () {
final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin =
_MockWithCompositionAwareMixin();
mockWithCompositionAwareMixin.composingText = fakeComposingText;
mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement);
_inputElement.dispatchEvent(html.Event(_MockWithCompositionAwareMixin._kCompositionEnd));
expect(mockWithCompositionAwareMixin.composingText, null);
});
});
group('composition start', () {
test('should reset composing text on handle composition start', () {
final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin =
_MockWithCompositionAwareMixin();
mockWithCompositionAwareMixin.composingText = fakeComposingText;
mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement);
_inputElement.dispatchEvent(html.Event(_MockWithCompositionAwareMixin._kCompositionStart));
expect(mockWithCompositionAwareMixin.composingText, null);
});
});
group('composition update', () {
test('should set composing text to event composing text', () {
const String fakeEventText = 'IAmComposingThis';
final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin =
_MockWithCompositionAwareMixin();
mockWithCompositionAwareMixin.composingText = fakeComposingText;
mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement);
_inputElement.dispatchEvent(html.CompositionEvent(
_MockWithCompositionAwareMixin._kCompositionUpdate,
data: fakeEventText
));
expect(mockWithCompositionAwareMixin.composingText, fakeEventText);
});
});
group('determine composition state', () {
test('should return new composition state if valid new composition', () {
const int baseOffset = 100;
const String composingText = 'composeMe';
final EditingState editingState = EditingState(
extentOffset: baseOffset,
text: 'testing',
baseOffset: baseOffset,
);
final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin =
_MockWithCompositionAwareMixin();
mockWithCompositionAwareMixin.composingText = composingText;
const int expectedComposingBase = baseOffset - composingText.length;
expect(
mockWithCompositionAwareMixin.determineCompositionState(editingState),
editingState.copyWith(
composingBaseOffset: expectedComposingBase,
composingExtentOffset: expectedComposingBase + composingText.length));
});
});
});
group('composing range', () {
late GloballyPositionedTextEditingStrategy editingStrategy;
setUp(() {
editingStrategy = _enableEditingStrategy(deltaModel: false);
});
tearDown(() {
editingStrategy.disable();
});
test('should be [0, compostionStrLength] on new composition', () {
const String composingText = 'hi';
_inputElement.dispatchEvent(html.CompositionEvent(_MockWithCompositionAwareMixin._kCompositionUpdate, data: composingText));
// Set the selection text.
_inputElement.value = composingText;
_inputElement.dispatchEvent(html.Event.eventType('Event', 'input'));
expect(
editingStrategy.lastEditingState,
isA<EditingState>()
.having((EditingState editingState) => editingState.composingBaseOffset,
'composingBaseOffset', 0)
.having((EditingState editingState) => editingState.composingExtentOffset,
'composingExtentOffset', composingText.length));
});
test(
'should be [beforeComposingText - composingText, compostionStrLength] on composition in the middle of text',
() {
const String composingText = 'hi';
const String beforeComposingText = 'beforeComposingText';
const String afterComposingText = 'afterComposingText';
// Type in the text box, then move cursor to the middle.
_inputElement.value = '$beforeComposingText$afterComposingText';
_inputElement.setSelectionRange(beforeComposingText.length, beforeComposingText.length);
_inputElement.dispatchEvent(html.CompositionEvent(
_MockWithCompositionAwareMixin._kCompositionUpdate,
data: composingText
));
// Flush editing state (since we did not compositionend).
_inputElement.dispatchEvent(html.Event.eventType('Event', 'input'));
expect(
editingStrategy.lastEditingState,
isA<EditingState>()
.having((EditingState editingState) => editingState.composingBaseOffset!,
'composingBaseOffset', beforeComposingText.length - composingText.length)
.having((EditingState editingState) => editingState.composingExtentOffset,
'composingExtentOffset', beforeComposingText.length));
});
});
group('Text Editing Delta Model', () {
late GloballyPositionedTextEditingStrategy editingStrategy;
final StreamController<TextEditingDeltaState?> deltaStream =
StreamController<TextEditingDeltaState?>.broadcast();
setUp(() {
editingStrategy = _enableEditingStrategy(
deltaModel: true,
onChange: (_, TextEditingDeltaState? deltaState) => deltaStream.add(deltaState)
);
});
tearDown(() {
editingStrategy.disable();
});
test('should have newly entered composing characters', () async {
const String newComposingText = 'n';
editingStrategy.setEditingState(EditingState(text: newComposingText, baseOffset: 1, extentOffset: 1));
final Future<dynamic> containExpect = expectLater(
deltaStream.stream.first,
completion(isA<TextEditingDeltaState>()
.having((TextEditingDeltaState deltaState) => deltaState.composingOffset, 'composingOffset', 0)
.having((TextEditingDeltaState deltaState) => deltaState.composingExtent, 'composingExtent', newComposingText.length)
));
_inputElement.dispatchEvent(html.CompositionEvent(
_MockWithCompositionAwareMixin._kCompositionUpdate,
data: newComposingText));
await containExpect;
});
test('should emit changed composition', () async {
const String newComposingCharsInOrder = 'hiCompose';
for (int currCharIndex = 0; currCharIndex < newComposingCharsInOrder.length; currCharIndex++) {
final String currComposingSubstr = newComposingCharsInOrder.substring(0, currCharIndex + 1);
editingStrategy.setEditingState(
EditingState(text: currComposingSubstr, baseOffset: currCharIndex + 1, extentOffset: currCharIndex + 1)
);
final Future<dynamic> containExpect = expectLater(
deltaStream.stream.first,
completion(isA<TextEditingDeltaState>()
.having((TextEditingDeltaState deltaState) => deltaState.composingOffset, 'composingOffset', 0)
.having((TextEditingDeltaState deltaState) => deltaState.composingExtent, 'composingExtent', currCharIndex + 1)
));
_inputElement.dispatchEvent(html.CompositionEvent(
_MockWithCompositionAwareMixin._kCompositionUpdate,
data: currComposingSubstr));
await containExpect;
}
},
// TODO(antholeole): This test fails on Firefox because of how it orders events;
// it's likely that this will be fixed by https://github.com/flutter/flutter/issues/105243.
// Until the refactor gets merged, this test should run on all other browsers to prevent
// regressions in the meantime.
skip: browserEngine == BrowserEngine.firefox);
});
}

View File

@ -1551,7 +1551,9 @@ Future<void> testMain() async {
<String, dynamic>{
'text': 'something',
'selectionBase': 9,
'selectionExtent': 9
'selectionExtent': 9,
'composingBase': null,
'composingExtent': null
}
],
);
@ -1575,7 +1577,9 @@ Future<void> testMain() async {
<String, dynamic>{
'text': 'something',
'selectionBase': 2,
'selectionExtent': 5
'selectionExtent': 5,
'composingBase': null,
'composingExtent': null
}
],
);
@ -1631,6 +1635,8 @@ Future<void> testMain() async {
'deltaEnd': -1,
'selectionBase': 2,
'selectionExtent': 5,
'composingBase': null,
'composingExtent': null
}
],
}
@ -1709,7 +1715,9 @@ Future<void> testMain() async {
hintForFirstElement: <String, dynamic>{
'text': 'something',
'selectionBase': 9,
'selectionExtent': 9
'selectionExtent': 9,
'composingBase': null,
'composingExtent': null
}
},
],
@ -1748,6 +1756,8 @@ Future<void> testMain() async {
'text': 'foo\nbar',
'selectionBase': 2,
'selectionExtent': 3,
'composingBase': null,
'composingExtent': null
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
checkTextAreaEditingState(textarea, 'foo\nbar', 2, 3);
@ -1777,6 +1787,8 @@ Future<void> testMain() async {
'text': 'something\nelse',
'selectionBase': 14,
'selectionExtent': 14,
'composingBase': null,
'composingExtent': null
}
],
);
@ -1791,6 +1803,8 @@ Future<void> testMain() async {
'text': 'something\nelse',
'selectionBase': 2,
'selectionExtent': 5,
'composingBase': null,
'composingExtent': null
}
],
);
@ -2275,21 +2289,32 @@ Future<void> testMain() async {
expect(_editingState.extentOffset, 2);
});
test('Compare two editing states', () {
final InputElement input = defaultTextEditingRoot.querySelector('input')! as InputElement;
input.value = 'Test';
input.selectionStart = 1;
input.selectionEnd = 2;
group('comparing editing states', () {
test('From dom element', () {
final InputElement input = defaultTextEditingRoot.querySelector('input')! as InputElement;
input.value = 'Test';
input.selectionStart = 1;
input.selectionEnd = 2;
final EditingState editingState1 = EditingState.fromDomElement(input);
final EditingState editingState2 = EditingState.fromDomElement(input);
final EditingState editingState1 = EditingState.fromDomElement(input);
final EditingState editingState2 = EditingState.fromDomElement(input);
input.setSelectionRange(1, 3);
input.setSelectionRange(1, 3);
final EditingState editingState3 = EditingState.fromDomElement(input);
final EditingState editingState3 = EditingState.fromDomElement(input);
expect(editingState1 == editingState2, isTrue);
expect(editingState1 != editingState3, isTrue);
expect(editingState1 == editingState2, isTrue);
expect(editingState1 != editingState3, isTrue);
});
test('takes composition range into account', () {
final EditingState editingState1 = EditingState(composingBaseOffset: 1, composingExtentOffset: 2);
final EditingState editingState2 = EditingState(composingBaseOffset: 1, composingExtentOffset: 2);
final EditingState editingState3 = EditingState(composingBaseOffset: 4, composingExtentOffset: 8);
expect(editingState1, editingState2);
expect(editingState1, isNot(editingState3));
});
});
});