mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
TextEditingDelta Support for the Web (flutter/engine#28527)
* Initial implementation of TextEditingDeltaState for the web * Capture composing region through compositionupdate and handle cases where there is an edit that occurs before the current cursor position * clean up unused code * clean up rest of logs * Make sure we initialize oldText in beforeInput * Clean up comments * more defaults * Add more comments * Move delta inferrence logic to TextEditingDeltaState * Add new listeners to rest of strategies * Fix existing tests * Fix tests * Add lastTextEditingDeltaState to test * fix tests * Add some preliminary tests for TextEditingDeltaState * Send as list to framework * Add composing region test * Address nits * Update tests * Try to fix tests * Prefer const with constant constructors * Clean up comments * Specify types * fix tests * Specify type annotations * batchDeltas -> deltas * Make eventData nullable so we dont compare with a 'null' string * Make TextEditingDeltaState mutable to avoid multiple copies * Fix analyzer * Fix test * Use safe browser api instead of directly accessing js_util * remove last prefix from editingDeltaState * Remove logs * fix merge * fix whitespace * revert composing changes * update comments * remove trailing whitespace * Add docs for TextEditingDeltaState * Normalize delta naming and use a copy instead of modifying function arguments * Update selection of delta in inferDeltaState instead of onChange * Fix tests, previously the selection was not set in inferDeltaState, now it is so the tests should reflect this change * Make a copy of delta instead of modifying function arguments * remove whitespace * Move some logic into inferDeltaState * whitespace * analyzer fix * Revert "analyzer fix" This reverts commit 786a52810cf59ed2e06be7100314d779807863d7. * Revert "whitespace" This reverts commit 98fad4709b9f153e47bc56ef6a2606f99e37eee2. * Revert "Move some logic into inferDeltaState" This reverts commit a600e9e567a7ef78bb9bb8dd3e5c86c06076f098. * pass _editingDeltaState instead of editingDeltaState to onChange for clarity * Add docs for beforeinput * Add docs for inferDeltaState * whitespace * Add more docs * update docs * update docs * Fix for insertion of a period following a double space within old text bounds * Fix accent insertion * clean up comments * Address comments for clarity aand regexp * Make composing and selection nullable * update docs * whitespace * address comments Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
parent
d232131bf0
commit
6f60286aa9
@ -440,6 +440,211 @@ class AutofillInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a range of text in the original string with the text given in the
|
||||
/// replacement string.
|
||||
String _replace(String originalText, String replacementText, ui.TextRange replacedRange) {
|
||||
assert(replacedRange.isValid);
|
||||
assert(replacedRange.start <= originalText.length && replacedRange.end <= originalText.length);
|
||||
|
||||
final ui.TextRange normalizedRange = ui.TextRange(start: math.min(replacedRange.start, replacedRange.end), end: math.max(replacedRange.start, replacedRange.end));
|
||||
|
||||
return normalizedRange.textBefore(originalText) + replacementText + normalizedRange.textAfter(originalText);
|
||||
}
|
||||
|
||||
/// The change between the last editing state and the current editing state
|
||||
/// of a text field.
|
||||
///
|
||||
/// This is packaged into a JSON and sent to the framework
|
||||
/// to be processed into a concrete [TextEditingDelta].
|
||||
class TextEditingDeltaState {
|
||||
TextEditingDeltaState({
|
||||
this.oldText = '',
|
||||
this.deltaText = '',
|
||||
this.deltaStart = -1,
|
||||
this.deltaEnd = -1,
|
||||
this.baseOffset,
|
||||
this.extentOffset,
|
||||
this.composingOffset,
|
||||
this.composingExtent,
|
||||
});
|
||||
|
||||
/// Infers the correct delta values based on information from the new editing state
|
||||
/// and the last editing state.
|
||||
///
|
||||
/// For a deletion we calculate the length of the deleted text by comparing the new
|
||||
/// and last editing states. We subtract this from the [deltaEnd] that we set when beforeinput
|
||||
/// was fired to determine the [deltaStart].
|
||||
///
|
||||
/// For a replacement at a selection we set the [deltaStart] to be the beginning of the selection
|
||||
/// from the last editing state.
|
||||
///
|
||||
/// For the composing region we check if a composing range was captured by the compositionupdate event,
|
||||
/// we have a non empty [deltaText], and that we did not have an active selection. An active selection
|
||||
/// would mean we are not composing.
|
||||
///
|
||||
/// We then verify that the delta we collected results in the text contained within the new editing state
|
||||
/// when applied to the last editing state. If it is not then we use our new editing state as the source of truth,
|
||||
/// and use regex to find the correct [deltaStart] and [deltaEnd].
|
||||
static TextEditingDeltaState inferDeltaState(EditingState newEditingState, EditingState? lastEditingState, TextEditingDeltaState lastTextEditingDeltaState) {
|
||||
final TextEditingDeltaState newTextEditingDeltaState = lastTextEditingDeltaState.copyWith();
|
||||
final bool previousSelectionWasCollapsed = lastEditingState?.baseOffset == lastEditingState?.extentOffset;
|
||||
final bool isTextBeingRemoved = newTextEditingDeltaState.deltaText.isEmpty && newTextEditingDeltaState.deltaEnd != -1;
|
||||
final bool isTextBeingChangedAtActiveSelection = newTextEditingDeltaState.deltaText.isNotEmpty && !previousSelectionWasCollapsed;
|
||||
|
||||
if (isTextBeingRemoved) {
|
||||
// When text is deleted outside of the composing region or is cut using the native toolbar,
|
||||
// we calculate the length of the deleted text by comparing the new and old editing state lengths.
|
||||
// This value is then subtracted from the end position of the delta to capture the deleted range.
|
||||
final int deletedLength = newTextEditingDeltaState.oldText.length - newEditingState.text!.length;
|
||||
newTextEditingDeltaState.deltaStart = newTextEditingDeltaState.deltaEnd - deletedLength;
|
||||
} else if (isTextBeingChangedAtActiveSelection) {
|
||||
// When a selection of text is replaced by a copy/paste operation we set the starting range
|
||||
// of the delta to be the beginning of the selection of the previous editing state.
|
||||
newTextEditingDeltaState.deltaStart = lastEditingState!.baseOffset!;
|
||||
}
|
||||
|
||||
// If we are composing then set the delta range to the composing region we
|
||||
// captured in compositionupdate.
|
||||
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;
|
||||
if (!isDeltaRangeEmpty) {
|
||||
// To verify the range of our delta we should compare the newEditingState's
|
||||
// text with the delta applied to the oldText. If they differ then capture
|
||||
// the correct delta range from the newEditingState's text value.
|
||||
//
|
||||
// We can assume the deltaText for additions and replacements to the text value
|
||||
// are accurate. What may not be accurate is the range of the delta.
|
||||
//
|
||||
// We can think of the newEditingState as our source of truth.
|
||||
//
|
||||
// This verification is needed for cases such as the insertion of a period
|
||||
// after a double space, and the insertion of an accented character through
|
||||
// a native composing menu.
|
||||
final ui.TextRange replacementRange = ui.TextRange(start: newTextEditingDeltaState.deltaStart, end: newTextEditingDeltaState.deltaEnd);
|
||||
final String textAfterDelta = _replace(
|
||||
newTextEditingDeltaState.oldText, newTextEditingDeltaState.deltaText,
|
||||
replacementRange);
|
||||
final bool isDeltaVerified = textAfterDelta == newEditingState.text!;
|
||||
|
||||
if (!isDeltaVerified) {
|
||||
// 1. Find all matches for deltaText.
|
||||
// 2. Apply matches/replacement to oldText until oldText matches the
|
||||
// new editing state's text value.
|
||||
final bool isPeriodInsertion = newTextEditingDeltaState.deltaText.contains('.');
|
||||
final RegExp deltaTextPattern = RegExp(RegExp.escape(newTextEditingDeltaState.deltaText));
|
||||
for (final Match match in deltaTextPattern.allMatches(newEditingState.text!)) {
|
||||
String textAfterMatch;
|
||||
int actualEnd;
|
||||
final bool isMatchWithinOldTextBounds = match.start >= 0 && match.end <= newTextEditingDeltaState.oldText.length;
|
||||
if (!isMatchWithinOldTextBounds) {
|
||||
actualEnd = match.start + newTextEditingDeltaState.deltaText.length - 1;
|
||||
textAfterMatch = _replace(
|
||||
newTextEditingDeltaState.oldText,
|
||||
newTextEditingDeltaState.deltaText,
|
||||
ui.TextRange(
|
||||
start: match.start,
|
||||
end: actualEnd,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
actualEnd = actualEnd = isPeriodInsertion? match.end - 1 : match.end;
|
||||
textAfterMatch = _replace(
|
||||
newTextEditingDeltaState.oldText,
|
||||
newTextEditingDeltaState.deltaText,
|
||||
ui.TextRange(
|
||||
start: match.start,
|
||||
end: actualEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (textAfterMatch == newEditingState.text!) {
|
||||
newTextEditingDeltaState.deltaStart = match.start;
|
||||
newTextEditingDeltaState.deltaEnd = actualEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update selection of the delta using information from the new editing state.
|
||||
newTextEditingDeltaState.baseOffset = newEditingState.baseOffset!;
|
||||
newTextEditingDeltaState.extentOffset = newEditingState.extentOffset!;
|
||||
|
||||
return newTextEditingDeltaState;
|
||||
}
|
||||
|
||||
/// The text before the text field was updated.
|
||||
String oldText;
|
||||
|
||||
/// The text that is being inserted/replaced into the text field.
|
||||
/// This will be an empty string for deletions and non text updates
|
||||
/// such as selection updates.
|
||||
String deltaText;
|
||||
|
||||
/// The position in the text field where the change begins.
|
||||
///
|
||||
/// Has a default value of -1 to signify an empty range.
|
||||
int deltaStart;
|
||||
|
||||
/// The position in the text field where the change ends.
|
||||
///
|
||||
/// Has a default value of -1 to signify an empty range.
|
||||
int deltaEnd;
|
||||
|
||||
/// The updated starting position of the selection in the text field.
|
||||
int? baseOffset;
|
||||
|
||||
/// The updated terminating position of the selection in the text field.
|
||||
int? extentOffset;
|
||||
|
||||
/// The starting position of the composing region.
|
||||
int? composingOffset;
|
||||
|
||||
/// The terminating position of the composing region.
|
||||
int? composingExtent;
|
||||
|
||||
Map<String, dynamic> toFlutter() => <String, dynamic>{
|
||||
'deltas': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'oldText': oldText,
|
||||
'deltaText': deltaText,
|
||||
'deltaStart': deltaStart,
|
||||
'deltaEnd': deltaEnd,
|
||||
'selectionBase': baseOffset,
|
||||
'selectionExtent': extentOffset,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
TextEditingDeltaState copyWith({
|
||||
String? oldText,
|
||||
String? deltaText,
|
||||
int? deltaStart,
|
||||
int? deltaEnd,
|
||||
int? baseOffset,
|
||||
int? extentOffset,
|
||||
int? composingOffset,
|
||||
int? composingExtent,
|
||||
}) {
|
||||
return TextEditingDeltaState(
|
||||
oldText: oldText ?? this.oldText,
|
||||
deltaText: deltaText ?? this.deltaText,
|
||||
deltaStart: deltaStart ?? this.deltaStart,
|
||||
deltaEnd: deltaEnd ?? this.deltaEnd,
|
||||
baseOffset: baseOffset ?? this.baseOffset,
|
||||
extentOffset: extentOffset ?? this.extentOffset,
|
||||
composingOffset: composingOffset ?? this.composingOffset,
|
||||
composingExtent: composingExtent ?? this.composingExtent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text and selection state of a text field.
|
||||
class EditingState {
|
||||
EditingState({this.text, int? baseOffset, int? extentOffset}) :
|
||||
@ -611,6 +816,7 @@ class InputConfiguration {
|
||||
const TextCapitalizationConfig.defaultCapitalization(),
|
||||
this.autofill,
|
||||
this.autofillGroup,
|
||||
this.enableDeltaModel = false,
|
||||
});
|
||||
|
||||
InputConfiguration.fromFrameworkMessage(
|
||||
@ -634,7 +840,8 @@ class InputConfiguration {
|
||||
autofillGroup = EngineAutofillForm.fromFrameworkMessage(
|
||||
flutterInputConfiguration.tryJson('autofill'),
|
||||
flutterInputConfiguration.tryList('fields'),
|
||||
);
|
||||
),
|
||||
enableDeltaModel = flutterInputConfiguration.tryBool('enableDeltaModel') ?? false;
|
||||
|
||||
/// The type of information being edited in the input control.
|
||||
final EngineInputType inputType;
|
||||
@ -659,6 +866,8 @@ class InputConfiguration {
|
||||
/// supported by Safari.
|
||||
final bool autocorrect;
|
||||
|
||||
final bool enableDeltaModel;
|
||||
|
||||
final AutofillInfo? autofill;
|
||||
|
||||
final EngineAutofillForm? autofillGroup;
|
||||
@ -666,7 +875,7 @@ class InputConfiguration {
|
||||
final TextCapitalizationConfig textCapitalization;
|
||||
}
|
||||
|
||||
typedef OnChangeCallback = void Function(EditingState? editingState);
|
||||
typedef OnChangeCallback = void Function(EditingState? editingState, TextEditingDeltaState? editingDeltaState);
|
||||
typedef OnActionCallback = void Function(String? inputAction);
|
||||
|
||||
/// Provides HTML DOM functionality for editable text.
|
||||
@ -854,6 +1063,12 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
|
||||
late InputConfiguration inputConfiguration;
|
||||
EditingState? lastEditingState;
|
||||
|
||||
TextEditingDeltaState? _editingDeltaState;
|
||||
TextEditingDeltaState get editingDeltaState {
|
||||
_editingDeltaState ??= TextEditingDeltaState(oldText: lastEditingState!.text!);
|
||||
return _editingDeltaState!;
|
||||
}
|
||||
|
||||
/// Styles associated with the editable text.
|
||||
EditableTextStyle? style;
|
||||
|
||||
@ -952,6 +1167,10 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
|
||||
|
||||
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
|
||||
|
||||
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
|
||||
|
||||
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
|
||||
|
||||
// Refocus on the activeDomElement after blur, so that user can keep editing the
|
||||
// text field.
|
||||
subscriptions.add(activeDomElement.onBlur.listen((_) {
|
||||
@ -983,6 +1202,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
|
||||
|
||||
isEnabled = false;
|
||||
lastEditingState = null;
|
||||
_editingDeltaState = null;
|
||||
style = null;
|
||||
geometry = null;
|
||||
|
||||
@ -1027,13 +1247,60 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
|
||||
assert(isEnabled);
|
||||
|
||||
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
|
||||
TextEditingDeltaState? newTextEditingDeltaState;
|
||||
if (inputConfiguration.enableDeltaModel) {
|
||||
newTextEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditingState, lastEditingState, editingDeltaState);
|
||||
}
|
||||
|
||||
if (newEditingState != lastEditingState) {
|
||||
lastEditingState = newEditingState;
|
||||
onChange!(lastEditingState);
|
||||
_editingDeltaState = newTextEditingDeltaState;
|
||||
onChange!(lastEditingState, _editingDeltaState);
|
||||
// Flush delta after it has been sent to framework.
|
||||
_editingDeltaState = null;
|
||||
}
|
||||
}
|
||||
|
||||
void handleBeforeInput(html.Event event) {
|
||||
// In some cases the beforeinput event is not fired such as when the selection
|
||||
// of a text field is updated. In this case only the oninput event is fired.
|
||||
// We still want a delta generated in these cases so we can properly update
|
||||
// the selection. We begin to set the deltaStart and deltaEnd in beforeinput
|
||||
// because a change in the selection will not have a delta range, it will only
|
||||
// have a baseOffset and extentOffset. If these are set inside of inferDeltaState
|
||||
// then the method will incorrectly report a deltaStart and deltaEnd for a non
|
||||
// text update delta.
|
||||
final String? eventData = getJsProperty<void>(event, 'data') as String?;
|
||||
final String? inputType = getJsProperty<void>(event, 'inputType') as String?;
|
||||
|
||||
if (inputType != null) {
|
||||
if (inputType.contains('delete')) {
|
||||
// The deltaStart is set in handleChange because there is where we get access
|
||||
// to the new selection baseOffset which is our new deltaStart.
|
||||
editingDeltaState.deltaText = '';
|
||||
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
|
||||
} else if (inputType == 'insertLineBreak'){
|
||||
// event.data is null on a line break, so we manually set deltaText as a line break by setting it to '\n'.
|
||||
editingDeltaState.deltaText = '\n';
|
||||
editingDeltaState.deltaStart = lastEditingState!.extentOffset!;
|
||||
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
|
||||
} else if (eventData != null) {
|
||||
// When event.data is not null we we will begin by considering this delta as an insertion
|
||||
// at the selection extentOffset. This may change due to logic in handleChange to handle
|
||||
// composition and other IME behaviors.
|
||||
editingDeltaState.deltaText = eventData;
|
||||
editingDeltaState.deltaStart = lastEditingState!.extentOffset!;
|
||||
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (inputConfiguration.inputType.submitActionOnEnter &&
|
||||
@ -1174,6 +1441,10 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
|
||||
|
||||
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
|
||||
|
||||
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
|
||||
|
||||
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
|
||||
|
||||
// Position the DOM element after it is focused.
|
||||
subscriptions.add(activeDomElement.onFocus.listen((_) {
|
||||
// Cancel previous timer if exists.
|
||||
@ -1305,6 +1576,10 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
|
||||
|
||||
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
|
||||
|
||||
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
|
||||
|
||||
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
|
||||
|
||||
subscriptions.add(activeDomElement.onBlur.listen((_) {
|
||||
if (windowHasFocus) {
|
||||
// Chrome on Android will hide the onscreen keyboard when you tap outside
|
||||
@ -1357,6 +1632,10 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
|
||||
|
||||
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
|
||||
|
||||
activeDomElement.addEventListener('beforeinput', handleBeforeInput);
|
||||
|
||||
activeDomElement.addEventListener('compositionupdate', handleCompositionUpdate);
|
||||
|
||||
// Detects changes in text selection.
|
||||
//
|
||||
// In Firefox, when cursor moves, neither selectionChange nor onInput
|
||||
@ -1736,6 +2015,20 @@ class TextEditingChannel {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends the 'TextInputClient.updateEditingStateWithDeltas' message to the framework.
|
||||
void updateEditingStateWithDelta(int? clientId, TextEditingDeltaState? editingDeltaState) {
|
||||
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
|
||||
'flutter/textinput',
|
||||
const JSONMethodCodec().encodeMethodCall(
|
||||
MethodCall('TextInputClient.updateEditingStateWithDeltas', <dynamic>[
|
||||
clientId,
|
||||
editingDeltaState!.toFlutter(),
|
||||
]),
|
||||
),
|
||||
_emptyCallback,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends the 'TextInputClient.performAction' message to the framework.
|
||||
void performAction(int? clientId, String? inputAction) {
|
||||
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
|
||||
@ -1829,8 +2122,12 @@ class HybridTextEditing {
|
||||
isEditing = true;
|
||||
strategy.enable(
|
||||
configuration!,
|
||||
onChange: (EditingState? editingState) {
|
||||
channel.updateEditingState(_clientId, editingState);
|
||||
onChange: (EditingState? editingState, TextEditingDeltaState? editingDeltaState) {
|
||||
if (configuration!.enableDeltaModel) {
|
||||
channel.updateEditingStateWithDelta(_clientId, editingDeltaState);
|
||||
} else {
|
||||
channel.updateEditingState(_clientId, editingState);
|
||||
}
|
||||
},
|
||||
onAction: (String? inputAction) {
|
||||
channel.performAction(_clientId, inputAction);
|
||||
|
||||
@ -104,7 +104,7 @@ void testMain() {
|
||||
int actionCount = 0;
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {
|
||||
onChange: (_, __) {
|
||||
changeCount++;
|
||||
},
|
||||
onAction: (_) {
|
||||
@ -164,7 +164,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
|
||||
@ -192,7 +192,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
|
||||
@ -228,7 +228,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
|
||||
@ -275,7 +275,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
multilineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
createTextFieldSemantics(
|
||||
@ -291,7 +291,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
|
||||
@ -314,7 +314,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
|
||||
@ -382,7 +382,7 @@ void testMain() {
|
||||
|
||||
strategy.enable(
|
||||
singlelineConfig,
|
||||
onChange: (_) {},
|
||||
onChange: (_, __) {},
|
||||
onAction: (_) {},
|
||||
);
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ const MethodCodec codec = JSONMethodCodec();
|
||||
|
||||
DefaultTextEditingStrategy? editingStrategy;
|
||||
EditingState? lastEditingState;
|
||||
TextEditingDeltaState? editingDeltaState;
|
||||
String? lastInputAction;
|
||||
|
||||
final InputConfiguration singlelineConfig = InputConfiguration(
|
||||
@ -46,8 +47,9 @@ final InputConfiguration multilineConfig = InputConfiguration(
|
||||
final Map<String, dynamic> flutterMultilineConfig =
|
||||
createFlutterConfig('multiline');
|
||||
|
||||
void trackEditingState(EditingState? editingState) {
|
||||
void trackEditingState(EditingState? editingState, TextEditingDeltaState? textEditingDeltaState) {
|
||||
lastEditingState = editingState;
|
||||
editingDeltaState = textEditingDeltaState;
|
||||
}
|
||||
|
||||
void trackInputAction(String? inputAction) {
|
||||
@ -61,6 +63,7 @@ void main() {
|
||||
void testMain() {
|
||||
tearDown(() {
|
||||
lastEditingState = null;
|
||||
editingDeltaState = null;
|
||||
lastInputAction = null;
|
||||
cleanTextEditingStrategy();
|
||||
cleanTestFlags();
|
||||
@ -1529,6 +1532,63 @@ void testMain() {
|
||||
hideKeyboard();
|
||||
});
|
||||
|
||||
test('Syncs the editing state back to Flutter - delta model', () {
|
||||
final MethodCall setClient = MethodCall(
|
||||
'TextInput.setClient', <dynamic>[123, createFlutterConfig('text', enableDeltaModel: true)]);
|
||||
sendFrameworkMessage(codec.encodeMethodCall(setClient));
|
||||
|
||||
const MethodCall setEditingState =
|
||||
MethodCall('TextInput.setEditingState', <String, dynamic>{
|
||||
'text': '',
|
||||
'selectionBase': -1,
|
||||
'selectionExtent': -1,
|
||||
});
|
||||
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
|
||||
|
||||
const MethodCall show = MethodCall('TextInput.show');
|
||||
sendFrameworkMessage(codec.encodeMethodCall(show));
|
||||
|
||||
final InputElement input = textEditing!.strategy.domElement! as InputElement;
|
||||
|
||||
input.value = 'something';
|
||||
input.dispatchEvent(Event.eventType('Event', 'input'));
|
||||
|
||||
spy.messages.clear();
|
||||
|
||||
input.setSelectionRange(2, 5);
|
||||
if (browserEngine == BrowserEngine.firefox) {
|
||||
final Event keyup = KeyboardEvent('keyup');
|
||||
textEditing!.strategy.domElement!.dispatchEvent(keyup);
|
||||
} else {
|
||||
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
|
||||
}
|
||||
|
||||
expect(spy.messages, hasLength(1));
|
||||
expect(spy.messages[0].channel, 'flutter/textinput');
|
||||
expect(spy.messages[0].methodName, 'TextInputClient.updateEditingStateWithDeltas');
|
||||
expect(
|
||||
spy.messages[0].methodArguments,
|
||||
<dynamic>[
|
||||
123, // Client ID
|
||||
<String, dynamic>{
|
||||
'deltas': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'oldText': 'something',
|
||||
'deltaText': '',
|
||||
'deltaStart': -1,
|
||||
'deltaEnd': -1,
|
||||
'selectionBase': 2,
|
||||
'selectionExtent': 5,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
);
|
||||
spy.messages.clear();
|
||||
|
||||
hideKeyboard();
|
||||
});
|
||||
|
||||
test('multiTextField Autofill sync updates back to Flutter', () {
|
||||
// Create a configuration with an AutofillGroup of four text fields.
|
||||
const String hintForFirstElement = 'familyName';
|
||||
@ -2174,6 +2234,95 @@ void testMain() {
|
||||
expect(editingState1 != editingState3, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('TextEditingDeltaState', () {
|
||||
// The selection baseOffset and extentOffset are not inferred by
|
||||
// TextEditingDeltaState.inferDeltaState so we do not verify them here.
|
||||
test('Verify correct delta is inferred - insertion', () {
|
||||
final EditingState newEditState = EditingState(text: 'world', baseOffset: 5, extentOffset: 5);
|
||||
final EditingState lastEditState = EditingState(text: 'worl', baseOffset: 4, extentOffset: 4);
|
||||
final TextEditingDeltaState deltaState = TextEditingDeltaState(oldText: 'worl', deltaText: 'd', deltaStart: 4, deltaEnd: 4, baseOffset: -1, extentOffset: -1, composingOffset: -1, composingExtent: -1);
|
||||
|
||||
final TextEditingDeltaState textEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditState, lastEditState, deltaState);
|
||||
|
||||
expect(textEditingDeltaState.oldText, 'worl');
|
||||
expect(textEditingDeltaState.deltaText, 'd');
|
||||
expect(textEditingDeltaState.deltaStart, 4);
|
||||
expect(textEditingDeltaState.deltaEnd, 4);
|
||||
expect(textEditingDeltaState.baseOffset, 5);
|
||||
expect(textEditingDeltaState.extentOffset, 5);
|
||||
expect(textEditingDeltaState.composingOffset, -1);
|
||||
expect(textEditingDeltaState.composingExtent, -1);
|
||||
});
|
||||
|
||||
test('Verify correct delta is inferred - deletion', () {
|
||||
final EditingState newEditState = EditingState(text: 'worl', baseOffset: 4, extentOffset: 4);
|
||||
final EditingState lastEditState = EditingState(text: 'world', baseOffset: 5, extentOffset: 5);
|
||||
final TextEditingDeltaState deltaState = TextEditingDeltaState(oldText: 'world', deltaText: '', deltaStart: 4, deltaEnd: 5, baseOffset: -1, extentOffset: -1, composingOffset: -1, composingExtent: -1);
|
||||
|
||||
final TextEditingDeltaState textEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditState, lastEditState, deltaState);
|
||||
|
||||
expect(textEditingDeltaState.oldText, 'world');
|
||||
expect(textEditingDeltaState.deltaText, '');
|
||||
expect(textEditingDeltaState.deltaStart, 4);
|
||||
expect(textEditingDeltaState.deltaEnd, 5);
|
||||
expect(textEditingDeltaState.baseOffset, 4);
|
||||
expect(textEditingDeltaState.extentOffset, 4);
|
||||
expect(textEditingDeltaState.composingOffset, -1);
|
||||
expect(textEditingDeltaState.composingExtent, -1);
|
||||
});
|
||||
|
||||
test('Verify correct delta is inferred - composing region replacement', () {
|
||||
final EditingState newEditState = EditingState(text: '你好吗', baseOffset: 3, extentOffset: 3);
|
||||
final EditingState lastEditState = EditingState(text: 'ni hao ma', baseOffset: 9, extentOffset: 9);
|
||||
final TextEditingDeltaState deltaState = TextEditingDeltaState(oldText: 'ni hao ma', deltaText: '你好吗', deltaStart: 9, deltaEnd: 9, baseOffset: -1, extentOffset: -1, composingOffset: 0, composingExtent: 9);
|
||||
|
||||
final TextEditingDeltaState textEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditState, lastEditState, deltaState);
|
||||
|
||||
expect(textEditingDeltaState.oldText, 'ni hao ma');
|
||||
expect(textEditingDeltaState.deltaText, '你好吗');
|
||||
expect(textEditingDeltaState.deltaStart, 0);
|
||||
expect(textEditingDeltaState.deltaEnd, 9);
|
||||
expect(textEditingDeltaState.baseOffset, 3);
|
||||
expect(textEditingDeltaState.extentOffset, 3);
|
||||
expect(textEditingDeltaState.composingOffset, 0);
|
||||
expect(textEditingDeltaState.composingExtent, 9);
|
||||
});
|
||||
|
||||
test('Verify correct delta is inferred for double space to insert a period', () {
|
||||
final EditingState newEditState = EditingState(text: 'hello. ', baseOffset: 7, extentOffset: 7);
|
||||
final EditingState lastEditState = EditingState(text: 'hello ', baseOffset: 6, extentOffset: 6);
|
||||
final TextEditingDeltaState deltaState = TextEditingDeltaState(oldText: 'hello ', deltaText: '. ', deltaStart: 6, deltaEnd: 6, baseOffset: -1, extentOffset: -1, composingOffset: -1, composingExtent: -1);
|
||||
|
||||
final TextEditingDeltaState textEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditState, lastEditState, deltaState);
|
||||
|
||||
expect(textEditingDeltaState.oldText, 'hello ');
|
||||
expect(textEditingDeltaState.deltaText, '. ');
|
||||
expect(textEditingDeltaState.deltaStart, 5);
|
||||
expect(textEditingDeltaState.deltaEnd, 6);
|
||||
expect(textEditingDeltaState.baseOffset, 7);
|
||||
expect(textEditingDeltaState.extentOffset, 7);
|
||||
expect(textEditingDeltaState.composingOffset, -1);
|
||||
expect(textEditingDeltaState.composingExtent, -1);
|
||||
});
|
||||
|
||||
test('Verify correct delta is inferred for accent menu', () {
|
||||
final EditingState newEditState = EditingState(text: 'à', baseOffset: 1, extentOffset: 1);
|
||||
final EditingState lastEditState = EditingState(text: 'a', baseOffset: 1, extentOffset: 1);
|
||||
final TextEditingDeltaState deltaState = TextEditingDeltaState(oldText: 'a', deltaText: 'à', deltaStart: 1, deltaEnd: 1, baseOffset: -1, extentOffset: -1, composingOffset: -1, composingExtent: -1);
|
||||
|
||||
final TextEditingDeltaState textEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditState, lastEditState, deltaState);
|
||||
|
||||
expect(textEditingDeltaState.oldText, 'a');
|
||||
expect(textEditingDeltaState.deltaText, 'à');
|
||||
expect(textEditingDeltaState.deltaStart, 0);
|
||||
expect(textEditingDeltaState.deltaEnd, 1);
|
||||
expect(textEditingDeltaState.baseOffset, 1);
|
||||
expect(textEditingDeltaState.extentOffset, 1);
|
||||
expect(textEditingDeltaState.composingOffset, -1);
|
||||
expect(textEditingDeltaState.composingExtent, -1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
KeyboardEvent dispatchKeyboardEvent(
|
||||
@ -2285,6 +2434,7 @@ Map<String, dynamic> createFlutterConfig(
|
||||
String? placeholderText,
|
||||
List<String>? autofillHintsForFields,
|
||||
bool decimal = false,
|
||||
bool enableDeltaModel = false,
|
||||
}) {
|
||||
return <String, dynamic>{
|
||||
'inputType': <String, dynamic>{
|
||||
@ -2301,6 +2451,7 @@ Map<String, dynamic> createFlutterConfig(
|
||||
if (autofillEnabled && autofillHintsForFields != null)
|
||||
'fields':
|
||||
createFieldValues(autofillHintsForFields, autofillHintsForFields),
|
||||
'enableDeltaModel': enableDeltaModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user