Skip to content

Commit

Permalink
[Web] Fix insertions/deletions at inverted selection for TextEditingD…
Browse files Browse the repository at this point in the history
…eltas (#44693)

Fixes issue where the delta range would be calculated wrong when the selection is inverted.
  • Loading branch information
Renzo-Olivares committed Sep 8, 2023
1 parent 2cacf55 commit 019715e
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 7 deletions.
15 changes: 15 additions & 0 deletions lib/web_ui/lib/src/engine/dom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,13 @@ class DomMouseEvent extends DomUIEvent {
external factory DomMouseEvent.arg2(JSString type, JSAny initDict);
}

@JS('InputEvent')
@staticInterop
class DomInputEvent extends DomUIEvent {
external factory DomInputEvent.arg1(JSString type);
external factory DomInputEvent.arg2(JSString type, JSAny initDict);
}

extension DomMouseEventExtension on DomMouseEvent {
@JS('clientX')
external JSNumber get _clientX;
Expand Down Expand Up @@ -2473,6 +2480,14 @@ DomMouseEvent createDomMouseEvent(String type, [Map<dynamic, dynamic>? init]) {
}
}

DomInputEvent createDomInputEvent(String type, [Map<dynamic, dynamic>? init]) {
if (init == null) {
return DomInputEvent.arg1(type.toJS);
} else {
return DomInputEvent.arg2(type.toJS, init.toJSAnyDeep);
}
}

@JS('PointerEvent')
@staticInterop
class DomPointerEvent extends DomMouseEvent {
Expand Down
17 changes: 10 additions & 7 deletions lib/web_ui/lib/src/engine/text_editing/text_editing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ class TextEditingDeltaState {
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.
// If the deletion is backward, the length is susbtracted from the [deltaEnd]
// If the deletion is backward, the length is subtracted from the [deltaEnd]
// that we set when beforeinput was fired to determine the [deltaStart].
// If the deletion is forward, [deltaStart] is set to the new editing state baseOffset
// and [deltaEnd] is set to [deltaStart] incremented by the length of the deletion.
Expand All @@ -561,9 +561,10 @@ class TextEditingDeltaState {
newTextEditingDeltaState.deltaEnd = newTextEditingDeltaState.deltaStart + deletedLength;
}
} else if (isTextBeingChangedAtActiveSelection) {
final bool isPreviousSelectionInverted = lastEditingState!.baseOffset! > lastEditingState.extentOffset!;
// 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!;
newTextEditingDeltaState.deltaStart = isPreviousSelectionInverted ? lastEditingState.extentOffset! : lastEditingState.baseOffset!;
}

// If we are composing then set the delta range to the composing region we
Expand Down Expand Up @@ -1411,23 +1412,25 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements
final String? inputType = getJsProperty<void>(event, 'inputType') as String?;

if (inputType != null) {
final bool isSelectionInverted = lastEditingState!.baseOffset! > lastEditingState!.extentOffset!;
final int deltaOffset = isSelectionInverted ? lastEditingState!.baseOffset! : lastEditingState!.extentOffset!;
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!;
editingDeltaState.deltaEnd = deltaOffset;
} 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!;
editingDeltaState.deltaStart = deltaOffset;
editingDeltaState.deltaEnd = deltaOffset;
} else if (eventData != null) {
// When event.data is not null 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!;
editingDeltaState.deltaStart = deltaOffset;
editingDeltaState.deltaEnd = deltaOffset;
}
}
}
Expand Down
150 changes: 150 additions & 0 deletions lib/web_ui/test/engine/text_editing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,156 @@ Future<void> testMain() async {
hideKeyboard();
});

test('Supports deletion at inverted selection', () async {
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': 'Hello world',
'selectionBase': 9,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));

const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

// The "setSizeAndTransform" message has to be here before we call
// checkInputEditingState, since on some platforms (e.g. Desktop Safari)
// we don't put the input element into the DOM until we get its correct
// dimensions from the framework.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

await waitForDesktopSafariFocus();

final DomHTMLInputElement input = textEditing!.strategy.domElement! as
DomHTMLInputElement;

final DomInputEvent testEvent = createDomInputEvent(
'beforeinput',
<Object?, Object?>{
'inputType': 'deleteContentBackward',
},
);
input.dispatchEvent(testEvent);

final EditingState editingState = EditingState(
text: 'Helld',
baseOffset: 3,
extentOffset: 3,
);
editingState.applyToDomElement(input);
input.dispatchEvent(createDomEvent('Event', 'input'));

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': 'Hello world',
'deltaText': '',
'deltaStart': 3,
'deltaEnd': 9,
'selectionBase': 3,
'selectionExtent': 3,
'composingBase': -1,
'composingExtent': -1
}
],
}
],
);
spy.messages.clear();

hideKeyboard();
// TODO(Renzo-Olivares): https://github.com/flutter/flutter/issues/134271
}, skip: isSafari);

test('Supports new line at inverted selection', () async {
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': 'Hello world',
'selectionBase': 9,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));

const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

// The "setSizeAndTransform" message has to be here before we call
// checkInputEditingState, since on some platforms (e.g. Desktop Safari)
// we don't put the input element into the DOM until we get its correct
// dimensions from the framework.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

await waitForDesktopSafariFocus();

final DomHTMLInputElement input = textEditing!.strategy.domElement! as
DomHTMLInputElement;

final DomInputEvent testEvent = createDomInputEvent(
'beforeinput',
<Object?, Object?>{
'inputType': 'insertLineBreak',
},
);
input.dispatchEvent(testEvent);

final EditingState editingState = EditingState(
text: 'Hel\nld',
baseOffset: 3,
extentOffset: 3,
);
editingState.applyToDomElement(input);
input.dispatchEvent(createDomEvent('Event', 'input'));

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': 'Hello world',
'deltaText': '\n',
'deltaStart': 3,
'deltaEnd': 9,
'selectionBase': 3,
'selectionExtent': 3,
'composingBase': -1,
'composingExtent': -1
}
],
}
],
);
spy.messages.clear();

hideKeyboard();
// TODO(Renzo-Olivares): https://github.com/flutter/flutter/issues/134271
}, skip: isSafari);

test('multiTextField Autofill sync updates back to Flutter', () async {
// Create a configuration with an AutofillGroup of four text fields.
const String hintForFirstElement = 'familyName';
Expand Down

0 comments on commit 019715e

Please sign in to comment.