Unverified Commit 533816d1 authored by chunhtai's avatar chunhtai Committed by GitHub

Refactor web text editing shortcuts (#103377)

parent 1ea7d24e
...@@ -6,11 +6,11 @@ import 'package:flutter/foundation.dart'; ...@@ -6,11 +6,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'framework.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'text_editing_intents.dart'; import 'text_editing_intents.dart';
/// A [Shortcuts] widget with the shortcuts used for the default text editing /// A widget with the shortcuts used for the default text editing behavior.
/// behavior.
/// ///
/// This default behavior can be overridden by placing a [Shortcuts] widget /// This default behavior can be overridden by placing a [Shortcuts] widget
/// lower in the widget tree than this. See the [Action] class for an example /// lower in the widget tree than this. See the [Action] class for an example
...@@ -145,16 +145,16 @@ import 'text_editing_intents.dart'; ...@@ -145,16 +145,16 @@ import 'text_editing_intents.dart';
/// See also: /// See also:
/// ///
/// * [WidgetsApp], which creates a DefaultTextEditingShortcuts. /// * [WidgetsApp], which creates a DefaultTextEditingShortcuts.
class DefaultTextEditingShortcuts extends Shortcuts { class DefaultTextEditingShortcuts extends StatelessWidget {
/// Creates a [Shortcuts] widget that provides the default text editing /// Creates a [DefaultTextEditingShortcuts] widget that provides the default text editing
/// shortcuts on the current platform. /// shortcuts on the current platform.
DefaultTextEditingShortcuts({ const DefaultTextEditingShortcuts({
super.key, super.key,
required super.child, required this.child,
}) : super( });
debugLabel: '<Default Text Editing Shortcuts>',
shortcuts: _shortcuts, /// {@macro flutter.widgets.ProxyWidget.child}
); final Widget child;
// These are shortcuts are shared between most platforms except macOS for it // These are shortcuts are shared between most platforms except macOS for it
// uses different modifier keys as the line/word modifier. // uses different modifier keys as the line/word modifier.
...@@ -353,7 +353,7 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -353,7 +353,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// Web handles its text selection natively and doesn't use any of these // Web handles its text selection natively and doesn't use any of these
// shortcuts in Flutter. // shortcuts in Flutter.
static final Map<ShortcutActivator, Intent> _webShortcuts = <ShortcutActivator, Intent>{ static final Map<ShortcutActivator, Intent> _webDisablingTextShortcuts = <ShortcutActivator, Intent>{
for (final bool pressShift in const <bool>[true, false]) for (final bool pressShift in const <bool>[true, false])
...<SingleActivator, Intent>{ ...<SingleActivator, Intent>{
SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DoNothingAndStopPropagationTextIntent(), SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DoNothingAndStopPropagationTextIntent(),
...@@ -412,10 +412,6 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -412,10 +412,6 @@ class DefaultTextEditingShortcuts extends Shortcuts {
}; };
static Map<ShortcutActivator, Intent> get _shortcuts { static Map<ShortcutActivator, Intent> get _shortcuts {
if (kIsWeb) {
return _webShortcuts;
}
switch (defaultTargetPlatform) { switch (defaultTargetPlatform) {
case TargetPlatform.android: case TargetPlatform.android:
return _androidShortcuts; return _androidShortcuts;
...@@ -431,4 +427,29 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -431,4 +427,29 @@ class DefaultTextEditingShortcuts extends Shortcuts {
return _windowsShortcuts; return _windowsShortcuts;
} }
} }
@override
Widget build(BuildContext context) {
Widget result = child;
if (kIsWeb) {
// On the web, these shortcuts make sure of the following:
//
// 1. Shortcuts fired when an EditableText is focused are ignored and
// forwarded to the browser by the EditableText's Actions, because it
// maps DoNothingAndStopPropagationTextIntent to DoNothingAction.
// 2. Shortcuts fired when no EditableText is focused will still trigger
// _shortcuts assuming DoNothingAndStopPropagationTextIntent is
// unhandled elsewhere.
result = Shortcuts(
debugLabel: '<Web Disabling Text Editing Shortcuts>',
shortcuts: _webDisablingTextShortcuts,
child: result
);
}
return Shortcuts(
debugLabel: '<Default Text Editing Shortcuts>',
shortcuts: _shortcuts,
child: result
);
}
} }
...@@ -192,10 +192,18 @@ void main() { ...@@ -192,10 +192,18 @@ void main() {
' FocusTraversalGroup\n' ' FocusTraversalGroup\n'
' _ActionsMarker\n' ' _ActionsMarker\n'
' Actions\n' ' Actions\n'
'${kIsWeb
? ' _ShortcutsMarker\n'
' Semantics\n'
' _FocusMarker\n'
' Focus\n'
' Shortcuts\n'
: ''}'
' _ShortcutsMarker\n' ' _ShortcutsMarker\n'
' Semantics\n' ' Semantics\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Focus\n' ' Focus\n'
' Shortcuts\n'
' DefaultTextEditingShortcuts\n' ' DefaultTextEditingShortcuts\n'
' _ShortcutsMarker\n' ' _ShortcutsMarker\n'
' Semantics\n' ' Semantics\n'
......
...@@ -707,7 +707,7 @@ void main() { ...@@ -707,7 +707,7 @@ void main() {
expect(selectAllSpy.invoked, isTrue); expect(selectAllSpy.invoked, isTrue);
expect(copySpy.invoked, isTrue); expect(copySpy.invoked, isTrue);
expect(pasteSpy.invoked, isTrue); expect(pasteSpy.invoked, isTrue);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), skip: kIsWeb); // [intended] Web uses a different set of shortcuts. }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
} }
typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation);
......
...@@ -11,22 +11,22 @@ class MockClipboard { ...@@ -11,22 +11,22 @@ class MockClipboard {
final bool hasStringsThrows; final bool hasStringsThrows;
dynamic _clipboardData = <String, dynamic>{ dynamic clipboardData = <String, dynamic>{
'text': null, 'text': null,
}; };
Future<Object?> handleMethodCall(MethodCall methodCall) async { Future<Object?> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) { switch (methodCall.method) {
case 'Clipboard.getData': case 'Clipboard.getData':
return _clipboardData; return clipboardData;
case 'Clipboard.hasStrings': case 'Clipboard.hasStrings':
if (hasStringsThrows) if (hasStringsThrows)
throw Exception(); throw Exception();
final Map<String, dynamic>? clipboardDataMap = _clipboardData as Map<String, dynamic>?; final Map<String, dynamic>? clipboardDataMap = clipboardData as Map<String, dynamic>?;
final String? text = clipboardDataMap?['text'] as String?; final String? text = clipboardDataMap?['text'] as String?;
return <String, bool>{'value': text != null && text.isNotEmpty}; return <String, bool>{'value': text != null && text.isNotEmpty};
case 'Clipboard.setData': case 'Clipboard.setData':
_clipboardData = methodCall.arguments; clipboardData = methodCall.arguments;
break; break;
} }
return null; return null;
......
...@@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; ...@@ -7,6 +7,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
Future<void> sendKeyCombination( Future<void> sendKeyCombination(
WidgetTester tester, WidgetTester tester,
SingleActivator activator, SingleActivator activator,
...@@ -40,6 +42,18 @@ Iterable<SingleActivator> allModifierVariants(LogicalKeyboardKey trigger) { ...@@ -40,6 +42,18 @@ Iterable<SingleActivator> allModifierVariants(LogicalKeyboardKey trigger) {
} }
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
setUp(() async {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
await Clipboard.setData(const ClipboardData(text: 'empty'));
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
const String testText = const String testText =
'Now is the time for\n' // 20 'Now is the time for\n' // 20
'all good people\n' // 20 + 16 => 36 'all good people\n' // 20 + 16 => 36
...@@ -1797,4 +1811,328 @@ void main() { ...@@ -1797,4 +1811,328 @@ void main() {
)); ));
}, variant: macOSOnly); }, variant: macOSOnly);
}, skip: kIsWeb); // [intended] on web these keys are handled by the browser. }, skip: kIsWeb); // [intended] on web these keys are handled by the browser.
group('Web does not accept', () {
final TargetPlatformVariant allExceptApple = TargetPlatformVariant(TargetPlatform.values.toSet()..removeAll(<TargetPlatform>[TargetPlatform.macOS, TargetPlatform.iOS]));
const TargetPlatformVariant appleOnly = TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.iOS });
group('macOS shortcuts', () {
testWidgets('word modifier + arrowLeft', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 7, // Before the first "the"
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 7));
}, variant: appleOnly);
testWidgets('word modifier + arrowRight', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 7, // Before the first "the"
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 7));
}, variant: appleOnly);
testWidgets('line modifier + arrowLeft', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 24, // Before the "good".
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 24,));
}, variant: appleOnly);
testWidgets('line modifier + arrowRight', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 24, // Before the "good".
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(
offset: 24, // Before the newline character.
));
}, variant: appleOnly);
testWidgets('word modifier + arrow key movement', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection(
baseOffset: 24,
extentOffset: 43,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 24,
extentOffset: 43,
));
controller.selection = const TextSelection(
baseOffset: 43,
extentOffset: 24,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 43,
extentOffset: 24,
));
controller.selection = const TextSelection(
baseOffset: 24,
extentOffset: 43,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 24,
extentOffset: 43,
));
// "good" to "come" is selected.
controller.selection = const TextSelection(
baseOffset: 43,
extentOffset: 24,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 43,
extentOffset: 24,
));
}, variant: appleOnly);
testWidgets('line modifier + arrow key movement', (WidgetTester tester) async {
controller.text = testText;
// "good" to "come" is selected.
controller.selection = const TextSelection(
baseOffset: 24,
extentOffset: 43,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 24,
extentOffset: 43,
));
// "good" to "come" is selected.
controller.selection = const TextSelection(
baseOffset: 43,
extentOffset: 24,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 43,
extentOffset: 24,
));
// "good" to "come" is selected.
controller.selection = const TextSelection(
baseOffset: 24,
extentOffset: 43,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 24,
extentOffset: 43,
));
// "good" to "come" is selected.
controller.selection = const TextSelection(
baseOffset: 43,
extentOffset: 24,
);
await tester.pump();
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection(
baseOffset: 43,
extentOffset: 24,
));
}, variant: appleOnly);
});
testWidgets('vertical movement', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 0,
);
await tester.pumpWidget(buildEditableText());
for (final SingleActivator activator in allModifierVariants(LogicalKeyboardKey.arrowDown)) {
await sendKeyCombination(tester, activator);
await tester.pump();
expect(controller.text, testText);
expect(
controller.selection,
const TextSelection.collapsed(offset: 0),
reason: activator.toString(),
);
}
}, variant: TargetPlatformVariant.all());
testWidgets('horizontal movement', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 0,
);
await tester.pumpWidget(buildEditableText());
for (final SingleActivator activator in allModifierVariants(LogicalKeyboardKey.arrowRight)) {
await sendKeyCombination(tester, activator);
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 0));
}
}, variant: TargetPlatformVariant.all());
testWidgets('select all non apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 0,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 0));
}, variant: allExceptApple);
testWidgets('select all apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(
offset: 0,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 0));
}, variant: appleOnly);
testWidgets('copy non apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection(
baseOffset: 0,
extentOffset: 4,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
await tester.pump();
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'empty');
}, variant: allExceptApple);
testWidgets('copy apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection(
baseOffset: 0,
extentOffset: 4,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, meta: true));
await tester.pump();
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'empty');
}, variant: appleOnly);
testWidgets('cut non apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection(
baseOffset: 0,
extentOffset: 4,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyX, control: true));
await tester.pump();
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'empty');
expect(controller.selection, const TextSelection(
baseOffset: 0,
extentOffset: 4,
));
}, variant: allExceptApple);
testWidgets('cut apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection(
baseOffset: 0,
extentOffset: 4,
);
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyX, meta: true));
await tester.pump();
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'empty');
expect(controller.selection, const TextSelection(
baseOffset: 0,
extentOffset: 4,
));
}, variant: appleOnly);
testWidgets('paste non apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(offset: 0);
mockClipboard.clipboardData = <String, dynamic>{
'text': 'some text',
};
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyV, control: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 0));
expect(controller.text, testText);
}, variant: allExceptApple);
testWidgets('paste apple', (WidgetTester tester) async {
controller.text = testText;
controller.selection = const TextSelection.collapsed(offset: 0);
mockClipboard.clipboardData = <String, dynamic>{
'text': 'some text',
};
await tester.pumpWidget(buildEditableText());
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyV, meta: true));
await tester.pump();
expect(controller.selection, const TextSelection.collapsed(offset: 0));
expect(controller.text, testText);
}, variant: appleOnly);
}, skip: !kIsWeb);// [intended] specific tests target web.
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment