From 1b9fb67c428f67be7900012464ed3047d642efca Mon Sep 17 00:00:00 2001 From: Anthony Oleinik <48811365+antholeole@users.noreply.github.com> Date: Fri, 3 Jun 2022 16:31:47 -0700 Subject: [PATCH] Fix web editable text composing range (#33590) Flutter web framework now gets valid composing region updates from engine Co-authored-by: Anthony Oleinik --- ci/licenses_golden/licenses_flutter | 1 + lib/web_ui/lib/src/engine.dart | 1 + .../text_editing/composition_aware_mixin.dart | 85 ++++++ .../src/engine/text_editing/text_editing.dart | 78 ++++-- lib/web_ui/test/composition_test.dart | 260 ++++++++++++++++++ lib/web_ui/test/text_editing_test.dart | 53 +++- 6 files changed, 446 insertions(+), 32 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart create mode 100644 lib/web_ui/test/composition_test.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index e58fc07647657..e034878aaf2d7 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -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 diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index b6c325997af08..2983f3f132549 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -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'; diff --git a/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart b/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart new file mode 100644 index 0000000000000..fdab6ad3b582d --- /dev/null +++ b/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart @@ -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, + ); + } +} diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index dc31b9d083106..d171482e13b28 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -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 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. // diff --git a/lib/web_ui/test/composition_test.dart b/lib/web_ui/test/composition_test.dart new file mode 100644 index 0000000000000..54c543d5bd3c9 --- /dev/null +++ b/lib/web_ui/test/composition_test.dart @@ -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 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() + .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() + .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 deltaStream = + StreamController.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 containExpect = expectLater( + deltaStream.stream.first, + completion(isA() + .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 containExpect = expectLater( + deltaStream.stream.first, + completion(isA() + .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); + }); +} diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index b4f7822d2569d..18aa6cf107b26 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -1551,7 +1551,9 @@ Future testMain() async { { 'text': 'something', 'selectionBase': 9, - 'selectionExtent': 9 + 'selectionExtent': 9, + 'composingBase': null, + 'composingExtent': null } ], ); @@ -1575,7 +1577,9 @@ Future testMain() async { { 'text': 'something', 'selectionBase': 2, - 'selectionExtent': 5 + 'selectionExtent': 5, + 'composingBase': null, + 'composingExtent': null } ], ); @@ -1631,6 +1635,8 @@ Future testMain() async { 'deltaEnd': -1, 'selectionBase': 2, 'selectionExtent': 5, + 'composingBase': null, + 'composingExtent': null } ], } @@ -1709,7 +1715,9 @@ Future testMain() async { hintForFirstElement: { 'text': 'something', 'selectionBase': 9, - 'selectionExtent': 9 + 'selectionExtent': 9, + 'composingBase': null, + 'composingExtent': null } }, ], @@ -1748,6 +1756,8 @@ Future 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 testMain() async { 'text': 'something\nelse', 'selectionBase': 14, 'selectionExtent': 14, + 'composingBase': null, + 'composingExtent': null } ], ); @@ -1791,6 +1803,8 @@ Future testMain() async { 'text': 'something\nelse', 'selectionBase': 2, 'selectionExtent': 5, + 'composingBase': null, + 'composingExtent': null } ], ); @@ -2275,21 +2289,32 @@ Future 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)); + }); }); });