mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
6f236556a3
commit
92c0646dd4
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
//
|
||||
|
||||
260
engine/src/flutter/lib/web_ui/test/composition_test.dart
Normal file
260
engine/src/flutter/lib/web_ui/test/composition_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user