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';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'framework.dart';
import 'shortcuts.dart';
import 'text_editing_intents.dart';
/// A [Shortcuts] widget with the shortcuts used for the default text editing
/// behavior.
/// A widget with the shortcuts used for the default text editing behavior.
///
/// 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
......@@ -145,16 +145,16 @@ import 'text_editing_intents.dart';
/// See also:
///
/// * [WidgetsApp], which creates a DefaultTextEditingShortcuts.
class DefaultTextEditingShortcuts extends Shortcuts {
/// Creates a [Shortcuts] widget that provides the default text editing
class DefaultTextEditingShortcuts extends StatelessWidget {
/// Creates a [DefaultTextEditingShortcuts] widget that provides the default text editing
/// shortcuts on the current platform.
DefaultTextEditingShortcuts({
const DefaultTextEditingShortcuts({
super.key,
required super.child,
}) : super(
debugLabel: '<Default Text Editing Shortcuts>',
shortcuts: _shortcuts,
);
required this.child,
});
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
// These are shortcuts are shared between most platforms except macOS for it
// uses different modifier keys as the line/word modifier.
......@@ -353,7 +353,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// Web handles its text selection natively and doesn't use any of these
// 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])
...<SingleActivator, Intent>{
SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DoNothingAndStopPropagationTextIntent(),
......@@ -412,10 +412,6 @@ class DefaultTextEditingShortcuts extends Shortcuts {
};
static Map<ShortcutActivator, Intent> get _shortcuts {
if (kIsWeb) {
return _webShortcuts;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return _androidShortcuts;
......@@ -431,4 +427,29 @@ class DefaultTextEditingShortcuts extends Shortcuts {
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() {
' FocusTraversalGroup\n'
' _ActionsMarker\n'
' Actions\n'
'${kIsWeb
? ' _ShortcutsMarker\n'
' Semantics\n'
' _FocusMarker\n'
' Focus\n'
' Shortcuts\n'
: ''}'
' _ShortcutsMarker\n'
' Semantics\n'
' _FocusMarker\n'
' Focus\n'
' Shortcuts\n'
' DefaultTextEditingShortcuts\n'
' _ShortcutsMarker\n'
' Semantics\n'
......
......@@ -707,7 +707,7 @@ void main() {
expect(selectAllSpy.invoked, isTrue);
expect(copySpy.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);
......
......@@ -11,22 +11,22 @@ class MockClipboard {
final bool hasStringsThrows;
dynamic _clipboardData = <String, dynamic>{
dynamic clipboardData = <String, dynamic>{
'text': null,
};
Future<Object?> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
return clipboardData;
case 'Clipboard.hasStrings':
if (hasStringsThrows)
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?;
return <String, bool>{'value': text != null && text.isNotEmpty};
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
clipboardData = methodCall.arguments;
break;
}
return null;
......
......@@ -7,6 +7,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
Future<void> sendKeyCombination(
WidgetTester tester,
SingleActivator activator,
......@@ -40,6 +42,18 @@ Iterable<SingleActivator> allModifierVariants(LogicalKeyboardKey trigger) {
}
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 =
'Now is the time for\n' // 20
'all good people\n' // 20 + 16 => 36
......@@ -1797,4 +1811,328 @@ void main() {
));
}, variant: macOSOnly);
}, 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