Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0953c69
fix: Skip updating selection range during composition
koji-1009 Jan 13, 2025
3e19c27
Apply suggestions from code review
koji-1009 Mar 21, 2025
347faf1
test: Add test case
koji-1009 Mar 21, 2025
043a852
Update engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
koji-1009 Mar 21, 2025
984d3f5
test: Fix test case
koji-1009 Mar 21, 2025
bbd217b
Update engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text…
koji-1009 Apr 7, 2025
3886c3b
Apply suggestions from code review
koji-1009 Apr 14, 2025
3387efa
remove: sync main
koji-1009 Apr 15, 2025
cae5264
fix: Disable shift + arrow key on web platform
koji-1009 Apr 15, 2025
53d4f90
test: Add test cases
koji-1009 Apr 15, 2025
4a70b0a
refactor: sort
koji-1009 Apr 15, 2025
96041b1
test: Remove unsupported shortcuts test
koji-1009 Apr 15, 2025
0f1b383
Revert "test: Remove unsupported shortcuts test"
koji-1009 Apr 16, 2025
256b691
Revert "refactor: sort"
koji-1009 Apr 16, 2025
b4c0c91
Revert "test: Add test cases"
koji-1009 Apr 16, 2025
b8b83f6
Revert "fix: Disable shift + arrow key on web platform"
koji-1009 Apr 16, 2025
56576be
fix: Reflecting the composing process to Japanese text input
koji-1009 Apr 17, 2025
26a2640
test: Fix test case
koji-1009 Apr 18, 2025
85715ed
test: Add unit test
koji-1009 Apr 18, 2025
a5a8d7d
Revert "test: Add unit test"
koji-1009 Apr 22, 2025
317d1cf
test: Add unit test
koji-1009 Apr 23, 2025
abc3526
fix: Support partically selected style
koji-1009 Apr 28, 2025
1d37d47
test: Update unit test
koji-1009 Apr 28, 2025
1b23ee0
feat: Hold composingBase
koji-1009 May 16, 2025
ece4d52
test: Add unit test
koji-1009 May 16, 2025
fe51d20
fix: unit test
koji-1009 May 17, 2025
6d52b03
fix: Support home and end key
koji-1009 May 24, 2025
f060af9
refactor: Fix type and rename
koji-1009 May 30, 2025
d70db66
refactor: Remove line
koji-1009 May 31, 2025
d8b1720
Merge branch 'master' into fix/web_composing_range
koji-1009 May 31, 2025
1eb4915
chore: run test
koji-1009 May 31, 2025
cb6c360
Merge branch 'master' into fix/web_composing_range
koji-1009 Jun 1, 2025
8a48530
Merge branch 'master' into fix/web_composing_range
koji-1009 Jun 2, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ mixin CompositionAwareMixin {
/// so it is safe to reference it to get the current composingText.
String? composingText;

/// The base offset of the composing text in the `InputElement` or `TextAreaElement`.
///
/// Will be null if composing just started, ended, or no composing is being done.
int? composingBase;

void addCompositionEventHandlers(DomHTMLElement domElement) {
domElement.addEventListener(_kCompositionStart, _compositionStartListener);
domElement.addEventListener(_kCompositionUpdate, _compositionUpdateListener);
Expand All @@ -61,6 +66,7 @@ mixin CompositionAwareMixin {

void _handleCompositionStart(DomEvent event) {
composingText = null;
composingBase = null;
}

void _handleCompositionUpdate(DomEvent event) {
Expand All @@ -71,22 +77,22 @@ mixin CompositionAwareMixin {

void _handleCompositionEnd(DomEvent event) {
composingText = null;
composingBase = null;
}

EditingState determineCompositionState(EditingState editingState) {
if (composingText == null) {
return editingState;
}

final int composingBase = editingState.extentOffset - composingText!.length;

if (composingBase < 0) {
composingBase ??= editingState.extentOffset - composingText!.length;
if (composingBase! < 0) {
return editingState;
}

return editingState.copyWith(
composingBaseOffset: composingBase,
composingExtentOffset: composingBase + composingText!.length,
composingExtentOffset: composingBase! + composingText!.length,
);
}
}
29 changes: 29 additions & 0 deletions engine/src/flutter/lib/web_ui/test/engine/composition_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,35 @@ Future<void> testMain() async {
),
);
});

test('should retain composing base offset if composing text area is changed', () {
const String composingText = '今日は寒い日です';

EditingState editingState = EditingState(text: '今日は寒い日です', baseOffset: 0, extentOffset: 8);

final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin =
_MockWithCompositionAwareMixin();
mockWithCompositionAwareMixin.composingText = composingText;

expect(
mockWithCompositionAwareMixin.determineCompositionState(editingState),
editingState.copyWith(composingBaseOffset: 0, composingExtentOffset: 8),
);

editingState = editingState.copyWith(baseOffset: 0, extentOffset: 3);

expect(
mockWithCompositionAwareMixin.determineCompositionState(editingState),
editingState.copyWith(composingBaseOffset: 0, composingExtentOffset: 8),
);

editingState = editingState.copyWith(baseOffset: 3, extentOffset: 6);

expect(
mockWithCompositionAwareMixin.determineCompositionState(editingState),
editingState.copyWith(composingBaseOffset: 0, composingExtentOffset: 8),
);
});
});
});

Expand Down
36 changes: 33 additions & 3 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5574,7 +5574,10 @@ class EditableTextState extends State<EditableText>
),
),
ScrollToDocumentBoundaryIntent: _makeOverridable(
CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary),
_WebComposingDisablingCallbackAction<ScrollToDocumentBoundaryIntent>(
this,
onInvoke: _scrollToDocumentBoundary,
),
),
ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll),

Expand Down Expand Up @@ -6482,7 +6485,13 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent>
}

@override
bool get isActionEnabled => state._value.selection.isValid;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we might want to do the same with the below intents. I noticed that when composing the field does not scroll in reaction to these intents in a native multi-line text area. Do you observe the same?

    ScrollToDocumentBoundaryIntent: _makeOverridable(
      CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary),
    ),
    ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll),

we can do that by extending CallbackAction, maybe something like:

class _WebComposingDisablingCallbackAction<T extends Intent> extends CallbackAction<T> { }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Using the macOS “ことえり” IME, I typed Japanese in the TextArea. I then pressed fn key + arrow left and fn key + arrow right to send home and end events.

textarea.mov

Is the operation captured on video the operation we should be checking?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for checking that, yes fn key + arrow left and fn key + arrow right, and also fn key + arrow up and fn key + arrow down.

My simple example below it seems to do nothing for fn key + arrow left/right, and goes through the composing menu when doing fn key + arrow up/down.

Screen.Recording.2025-05-23.at.10.11.30.AM.mov

In your example it looks like if the composing spreads across multiple lines then the shortcuts will actually scroll?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I understand what you are pointing out.

native.mov

fn key + top/bottom key should not allow scrolling while composing.

If selectBase == composingBaseOffset && selectionExtent == composingExtentOffset, all scrolling stops. This occurs mainly when converting short strings such as "こんにちは".

If selectionBase != composingBaseOffset || selectionExtent != composingExtentOffset, the area to be converted is moved. This causes the screen to scroll for long sentences.
Even in the case of short sentences, if change the conversion target with shift key + arrow key, this state will occur.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Supoort home key (fn + arrow left) and end key (fn + arrow right). 79d5ed8

However, pageUp key (fn + arrow up) and pageDown key (fn + arrow down) would also require modification of app.dart. The current implementation does not determine whether or not composing is in progress and cannot be handled by simply modifying editable_text.dart.

#105497
#107602

ScrollIntent: Action<ScrollIntent>.overridable(
context: context,
defaultAction: ScrollAction(),
),


I understand that it should be fixed, but is it possible to separate the PR? #159671 is an important issue for Japanese IMEs. Compared to the shift + arrow left/right problem, the pageUp/pageDown problem has a smaller impact...

Copy link
Copy Markdown
Contributor

@Renzo-Olivares Renzo-Olivares May 28, 2025

Choose a reason for hiding this comment

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

Separating the scrolling parts into a separate PR sounds good to me! Thank you for looking into that.

bool get isActionEnabled {
if (kIsWeb && state.widget.selectionEnabled && state._value.composing.isValid) {
return false;
}

return state._value.selection.isValid;
}
}

class _UpdateTextSelectionVerticallyAction<T extends DirectionalCaretMovementIntent>
Expand Down Expand Up @@ -6562,7 +6571,28 @@ class _UpdateTextSelectionVerticallyAction<T extends DirectionalCaretMovementInt
}

@override
bool get isActionEnabled => state._value.selection.isValid;
bool get isActionEnabled {
if (kIsWeb && state.widget.selectionEnabled && state._value.composing.isValid) {
return false;
}

return state._value.selection.isValid;
}
}

class _WebComposingDisablingCallbackAction<T extends Intent> extends CallbackAction<T> {
_WebComposingDisablingCallbackAction(this.state, {required super.onInvoke});

final EditableTextState state;

@override
bool get isActionEnabled {
if (kIsWeb && state.widget.selectionEnabled && state._value.composing.isValid) {
return false;
}

return super.isActionEnabled;
}
}

class _SelectAllAction extends ContextAction<SelectAllTextIntent> {
Expand Down
159 changes: 159 additions & 0 deletions packages/flutter/test/widgets/editable_text_shortcuts_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2864,4 +2864,163 @@ void main() {
);
});
}, skip: !kIsWeb); // [intended] specific tests target web.

group(
'Web does not accept',
() {
testWidgets('character modifier + arrowLeft in composing', (WidgetTester tester) async {
const SingleActivator arrowLeft = SingleActivator(
LogicalKeyboardKey.arrowLeft,
shift: true,
);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, arrowLeft);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: arrowLeft.toString(),
);
});

testWidgets('character modifier + arrowRight in composing', (WidgetTester tester) async {
const SingleActivator arrowRight = SingleActivator(
LogicalKeyboardKey.arrowLeft,
shift: true,
);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, arrowRight);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: arrowRight.toString(),
);
});

testWidgets('character modifier + arrowUp in composing', (WidgetTester tester) async {
const SingleActivator arrowUp = SingleActivator(LogicalKeyboardKey.arrowUp, shift: true);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, arrowUp);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: arrowUp.toString(),
);
});

testWidgets('character modifier + arrowDown in composing', (WidgetTester tester) async {
const SingleActivator arrowDown = SingleActivator(
LogicalKeyboardKey.arrowDown,
shift: true,
);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, arrowDown);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: arrowDown.toString(),
);
});

testWidgets('home in composing', (WidgetTester tester) async {
const SingleActivator home = SingleActivator(LogicalKeyboardKey.home);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, home);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: home.toString(),
);
});

testWidgets('end in composing', (WidgetTester tester) async {
const SingleActivator end = SingleActivator(LogicalKeyboardKey.end);

controller.value = const TextEditingValue(
text: testText,
selection: TextSelection(baseOffset: 0, extentOffset: 3),
composing: TextRange(start: 0, end: 3),
);

await tester.pumpWidget(buildEditableText(style: const TextStyle(fontSize: 12)));
await tester.pumpAndSettle();

await sendKeyCombination(tester, end);
await tester.pump();

// selection should not change.
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 3),
reason: end.toString(),
);
});
},
skip: !kIsWeb, // [intended] specific tests target web.
);
}