Unverified Commit a40c5c29 authored by YeungKC's avatar YeungKC Committed by GitHub

Migration text selection manipulation. (#86986)

Consolidate duplicated cut/copy/paste/selectall code so it can be done via Actions in the future.
parent 4d4f3372
...@@ -2165,23 +2165,20 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2165,23 +2165,20 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
/// ///
/// {@macro flutter.rendering.RenderEditable.cause} /// {@macro flutter.rendering.RenderEditable.cause}
void selectAll(SelectionChangedCause cause) { void selectAll(SelectionChangedCause cause) {
_setSelection( textSelectionDelegate.selectAll(cause);
selection!.copyWith(
baseOffset: 0,
extentOffset: textSelectionDelegate.textEditingValue.text.length,
),
cause,
);
} }
/// Copy current [selection] to [Clipboard]. /// Copy current [selection] to [Clipboard].
void copySelection() { ///
/// {@macro flutter.rendering.RenderEditable.cause}
void copySelection(SelectionChangedCause cause) {
final TextSelection selection = textSelectionDelegate.textEditingValue.selection; final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
final String text = textSelectionDelegate.textEditingValue.text;
assert(selection != null); assert(selection != null);
if (!selection.isCollapsed) { if (selection.isCollapsed) {
Clipboard.setData(ClipboardData(text: selection.textInside(text))); return;
} }
textSelectionDelegate.copySelection(cause);
} }
/// Cut current [selection] to Clipboard. /// Cut current [selection] to Clipboard.
...@@ -2192,18 +2189,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2192,18 +2189,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
return; return;
} }
final TextSelection selection = textSelectionDelegate.textEditingValue.selection; final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
final String text = textSelectionDelegate.textEditingValue.text;
assert(selection != null); assert(selection != null);
if (!selection.isCollapsed) { if (selection.isCollapsed) {
Clipboard.setData(ClipboardData(text: selection.textInside(text))); return;
_setTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) + selection.textAfter(text),
selection: TextSelection.collapsed(offset: math.min(selection.start, selection.end)),
),
cause,
);
} }
textSelectionDelegate.cutSelection(cause);
} }
/// Paste text from [Clipboard]. /// Paste text from [Clipboard].
...@@ -2216,22 +2207,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2216,22 +2207,12 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
return; return;
} }
final TextSelection selection = textSelectionDelegate.textEditingValue.selection; final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
final String text = textSelectionDelegate.textEditingValue.text;
assert(selection != null); assert(selection != null);
// Snapshot the input before using `await`. if (!selection.isValid) {
// See https://github.com/flutter/flutter/issues/11427 return;
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && selection.isValid) {
_setTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) + data.text! + selection.textAfter(text),
selection: TextSelection.collapsed(
offset: math.min(selection.start, selection.end) + data.text!.length,
),
),
cause,
);
} }
textSelectionDelegate.pasteText(cause);
} }
@override @override
......
...@@ -17,6 +17,7 @@ import 'dart:ui' show ...@@ -17,6 +17,7 @@ import 'dart:ui' show
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'package:vector_math/vector_math_64.dart' show Matrix4;
import '../../services.dart' show Clipboard, ClipboardData;
import 'autofill.dart'; import 'autofill.dart';
import 'message_codec.dart'; import 'message_codec.dart';
import 'platform_channel.dart'; import 'platform_channel.dart';
...@@ -804,15 +805,15 @@ enum SelectionChangedCause { ...@@ -804,15 +805,15 @@ enum SelectionChangedCause {
/// location of the cursor. /// location of the cursor.
/// ///
/// An example is when the user taps on select all in the tool bar. /// An example is when the user taps on select all in the tool bar.
toolBar, toolbar,
/// The user used the mouse to change the selection by dragging over a piece /// The user used the mouse to change the selection by dragging over a piece
/// of text. /// of text.
drag, drag,
} }
/// A mixin for manipulating the selection, to be used by the implementer /// A mixin for manipulating the selection, provided for toolbar or shortcut
/// of the toolbar widget. /// keys.
mixin TextSelectionDelegate { mixin TextSelectionDelegate {
/// Gets the current text input. /// Gets the current text input.
TextEditingValue get textEditingValue; TextEditingValue get textEditingValue;
...@@ -863,6 +864,117 @@ mixin TextSelectionDelegate { ...@@ -863,6 +864,117 @@ mixin TextSelectionDelegate {
/// Whether select all is enabled, must not be null. /// Whether select all is enabled, must not be null.
bool get selectAllEnabled => true; bool get selectAllEnabled => true;
/// Cut current selection to [Clipboard].
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the toolbar
/// will be hidden and the current selection will be scrolled into view.
void cutSelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
Clipboard.setData(ClipboardData(
text: selection.textInside(text),
));
userUpdateTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) + selection.textAfter(text),
selection: TextSelection.collapsed(
offset: selection.start,
),
),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
/// Paste text from [Clipboard].
///
/// If there is currently a selection, it will be replaced.
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the toolbar
/// will be hidden and the current selection will be scrolled into view.
Future<void> pasteText(SelectionChangedCause cause) async {
final TextEditingValue value = textEditingValue;
// Snapshot the input before using `await`.
// See https://github.com/flutter/flutter/issues/11427
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
userUpdateTextEditingValue(
TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text!
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start + data.text!.length,
),
),
cause,
);
}
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
/// Set the current selection to contain the entire text value.
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the selection
/// will be scrolled into view.
void selectAll(SelectionChangedCause cause) {
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: textEditingValue.selection.copyWith(
baseOffset: 0,
extentOffset: textEditingValue.text.length,
),
),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
}
}
/// Copy current selection to [Clipboard].
///
/// If [cause] is [SelectionChangedCause.toolbar], the position of
/// [bringIntoView] to selection will be called and hide toolbar.
void copySelection(SelectionChangedCause cause) {
final TextEditingValue value = textEditingValue;
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar(false);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
break;
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: value.text,
selection: TextSelection.collapsed(offset: value.selection.end),
),
cause,
);
break;
}
}
}
} }
/// An interface to receive information from [TextInput]. /// An interface to receive information from [TextInput].
......
...@@ -307,7 +307,7 @@ class _SelectAllTextAction extends TextEditingAction<SelectAllTextIntent> { ...@@ -307,7 +307,7 @@ class _SelectAllTextAction extends TextEditingAction<SelectAllTextIntent> {
class _CopySelectionTextAction extends TextEditingAction<CopySelectionTextIntent> { class _CopySelectionTextAction extends TextEditingAction<CopySelectionTextIntent> {
@override @override
Object? invoke(CopySelectionTextIntent intent, [BuildContext? context]) { Object? invoke(CopySelectionTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.copySelection(); textEditingActionTarget!.renderEditable.copySelection(SelectionChangedCause.keyboard);
} }
} }
......
...@@ -200,74 +200,24 @@ abstract class TextSelectionControls { ...@@ -200,74 +200,24 @@ abstract class TextSelectionControls {
return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
} }
// TODO(justinmc): This and other methods should be ported to Actions and /// Call [TextSelectionDelegate.cutSelection] to cut current selection.
// removed, along with their keyboard shortcut equivalents.
// https://github.com/flutter/flutter/issues/75004
/// Copy the current selection of the text field managed by the given
/// `delegate` to the [Clipboard]. Then, remove the selected text from the
/// text field and hide the toolbar.
/// ///
/// This is called by subclasses when their cut affordance is activated by /// This is called by subclasses when their cut affordance is activated by
/// the user. /// the user.
void handleCut(TextSelectionDelegate delegate) { void handleCut(TextSelectionDelegate delegate) {
final TextEditingValue value = delegate.textEditingValue; delegate.cutSelection(SelectionChangedCause.toolbar);
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
delegate.userUpdateTextEditingValue(
TextEditingValue(
text: value.selection.textBefore(value.text)
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start,
),
),
SelectionChangedCause.toolBar,
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
} }
/// Copy the current selection of the text field managed by the given /// Call [TextSelectionDelegate.copySelection] to copy current selection.
/// `delegate` to the [Clipboard]. Then, move the cursor to the end of the
/// text (collapsing the selection in the process), and hide the toolbar.
/// ///
/// This is called by subclasses when their copy affordance is activated by /// This is called by subclasses when their copy affordance is activated by
/// the user. /// the user.
void handleCopy(TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus) { void handleCopy(TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus) {
final TextEditingValue value = delegate.textEditingValue; delegate.copySelection(SelectionChangedCause.toolbar);
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
clipboardStatus?.update(); clipboardStatus?.update();
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
// Hide the toolbar, but keep the selection and keep the handles.
delegate.hideToolbar(false);
return;
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Collapse the selection and hide the toolbar and handles.
delegate.userUpdateTextEditingValue(
TextEditingValue(
text: value.text,
selection: TextSelection.collapsed(offset: value.selection.end),
),
SelectionChangedCause.toolBar,
);
delegate.hideToolbar();
return;
}
} }
/// Paste the current clipboard selection (obtained from [Clipboard]) into /// Call [TextSelectionDelegate.pasteText] to paste text.
/// the text field managed by the given `delegate`, replacing its current
/// selection, if any. Then, hide the toolbar.
/// ///
/// This is called by subclasses when their paste affordance is activated by /// This is called by subclasses when their paste affordance is activated by
/// the user. /// the user.
...@@ -277,44 +227,18 @@ abstract class TextSelectionControls { ...@@ -277,44 +227,18 @@ abstract class TextSelectionControls {
/// implemented. /// implemented.
// TODO(ianh): https://github.com/flutter/flutter/issues/11427 // TODO(ianh): https://github.com/flutter/flutter/issues/11427
Future<void> handlePaste(TextSelectionDelegate delegate) async { Future<void> handlePaste(TextSelectionDelegate delegate) async {
final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`. delegate.pasteText(SelectionChangedCause.toolbar);
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
delegate.userUpdateTextEditingValue(
TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text!
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start + data.text!.length,
),
),
SelectionChangedCause.toolBar,
);
}
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
} }
/// Adjust the selection of the text field managed by the given `delegate` so /// Call [TextSelectionDelegate.selectAll] to set the current selection to
/// that everything is selected. /// contain the entire text value.
/// ///
/// Does not hide the toolbar. /// Does not hide the toolbar.
/// ///
/// This is called by subclasses when their select-all affordance is activated /// This is called by subclasses when their select-all affordance is activated
/// by the user. /// by the user.
void handleSelectAll(TextSelectionDelegate delegate) { void handleSelectAll(TextSelectionDelegate delegate) {
delegate.userUpdateTextEditingValue( delegate.selectAll(SelectionChangedCause.toolbar);
TextEditingValue(
text: delegate.textEditingValue.text,
selection: TextSelection(
baseOffset: 0,
extentOffset: delegate.textEditingValue.text.length,
),
),
SelectionChangedCause.toolBar,
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
} }
} }
......
...@@ -8107,9 +8107,97 @@ void main() { ...@@ -8107,9 +8107,97 @@ void main() {
await tester.pump(); await tester.pump();
expect(fadeTransition.toString(), contains('DISPOSED')); expect(fadeTransition.toString(), contains('DISPOSED'));
// On web, using keyboard for selection is handled by the browser. // On web, using keyboard for selection is handled by the browser.
}, skip: kIsWeb); // [intended] }, skip: kIsWeb); // [intended]
testWidgets('Selection will be scrolled into view with SelectionChangedCause', (WidgetTester tester) async {
final GlobalKey<EditableTextState> key = GlobalKey<EditableTextState>();
final String text = List<int>.generate(64, (int index) => index).join('\n');
final TextEditingController controller = TextEditingController(text: text);
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
height: 32,
child: EditableText(
key: key,
focusNode: focusNode,
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
controller: controller,
scrollController: scrollController,
maxLines: 2,
),
),
),
),
),
);
final TextSelectionDelegate textSelectionDelegate = key.currentState!;
late double maxScrollExtent;
Future<void> resetSelectionAndScrollOffset([bool setMaxScrollExtent = true]) async {
controller.value = controller.value.copyWith(
text: text,
selection: controller.selection.copyWith(baseOffset: 0, extentOffset: 1),
);
await tester.pump();
final double targetOffset = setMaxScrollExtent ? scrollController.position.maxScrollExtent : 0.0;
scrollController.jumpTo(targetOffset);
await tester.pumpAndSettle();
maxScrollExtent = scrollController.position.maxScrollExtent;
expect(scrollController.offset, targetOffset);
}
// Cut
await resetSelectionAndScrollOffset();
textSelectionDelegate.cutSelection(SelectionChangedCause.keyboard);
await tester.pump();
expect(scrollController.offset, maxScrollExtent);
await resetSelectionAndScrollOffset();
textSelectionDelegate.cutSelection(SelectionChangedCause.toolbar);
await tester.pump();
expect(scrollController.offset.roundToDouble(), 0.0);
// Paste
await resetSelectionAndScrollOffset();
textSelectionDelegate.pasteText(SelectionChangedCause.keyboard);
await tester.pump();
expect(scrollController.offset, maxScrollExtent);
await resetSelectionAndScrollOffset();
textSelectionDelegate.pasteText(SelectionChangedCause.toolbar);
await tester.pump();
expect(scrollController.offset.roundToDouble(), 0.0);
// Select all
await resetSelectionAndScrollOffset(false);
textSelectionDelegate.selectAll(SelectionChangedCause.keyboard);
await tester.pump();
expect(scrollController.offset, 0.0);
await resetSelectionAndScrollOffset(false);
textSelectionDelegate.selectAll(SelectionChangedCause.toolbar);
await tester.pump();
expect(scrollController.offset.roundToDouble(), maxScrollExtent);
// Copy
await resetSelectionAndScrollOffset();
textSelectionDelegate.copySelection(SelectionChangedCause.keyboard);
await tester.pump();
expect(scrollController.offset, maxScrollExtent);
await resetSelectionAndScrollOffset();
textSelectionDelegate.copySelection(SelectionChangedCause.toolbar);
await tester.pump();
expect(scrollController.offset.roundToDouble(), 0.0);
});
} }
class UnsettableController extends TextEditingController { class UnsettableController extends TextEditingController {
......
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