Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mdebbar This test currently fails because there is no DomInputEvent defined for InputEvent which contains inputType. In handleBeforeInput we check the inputType in the logic. Is there a way to test this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR to fix the tests: Renzo-Olivares#87

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix! I was trying to avoid adding to the DOM API but if that's the only way to test this I think its okay. InputEvent is a basic building block like KeyboardEvent and other related events so it would be good to add.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It seems to me that extending DOM bindings where needed is better than ad-hoc wrapper just for the test.

},
);
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