Context Menus (#107193)

* Can show context menus anywhere in the app, not just on text.
* Unifies all desktop/mobile context menus to go through one class (ContextMenuController).
* All context menus are now just plain widgets that can be fully customized.
* Existing default context menus can be customized and reused.
diff --git a/examples/api/lib/material/context_menu/context_menu_controller.0.dart b/examples/api/lib/material/context_menu/context_menu_controller.0.dart
new file mode 100644
index 0000000..0bf5ae5
--- /dev/null
+++ b/examples/api/lib/material/context_menu/context_menu_controller.0.dart
@@ -0,0 +1,159 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This sample demonstrates allowing a context menu to be shown in a widget
+// subtree in response to user gestures.
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+void main() => runApp(const MyApp());
+
+/// A builder that includes an Offset to draw the context menu at.
+typedef ContextMenuBuilder = Widget Function(BuildContext context, Offset offset);
+
+class MyApp extends StatelessWidget {
+  const MyApp({super.key});
+
+  void _showDialog (BuildContext context) {
+    Navigator.of(context).push(
+      DialogRoute<void>(
+        context: context,
+        builder: (BuildContext context) =>
+          const AlertDialog(title: Text('You clicked print!')),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Context menu outside of text'),
+        ),
+        body: _ContextMenuRegion(
+          contextMenuBuilder: (BuildContext context, Offset offset) {
+            // The custom context menu will look like the default context menu
+            // on the current platform with a single 'Print' button.
+            return AdaptiveTextSelectionToolbar.buttonItems(
+              anchors: TextSelectionToolbarAnchors(
+                primaryAnchor: offset,
+              ),
+              buttonItems: <ContextMenuButtonItem>[
+                ContextMenuButtonItem(
+                  onPressed: () {
+                    ContextMenuController.removeAny();
+                    _showDialog(context);
+                  },
+                  label: 'Print',
+                ),
+              ],
+            );
+          },
+          // In this case this wraps a big open space in a GestureDetector in
+          // order to show the context menu, but it could also wrap a single
+          // wiget like an Image to give it a context menu.
+          child: ListView(
+            children: <Widget>[
+              Container(height: 20.0),
+              const Text('Right click or long press anywhere (not just on this text!) to show the custom menu.'),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// Shows and hides the context menu based on user gestures.
+///
+/// By default, shows the menu on right clicks and long presses.
+class _ContextMenuRegion extends StatefulWidget {
+  /// Creates an instance of [_ContextMenuRegion].
+  const _ContextMenuRegion({
+    required this.child,
+    required this.contextMenuBuilder,
+  });
+
+  /// Builds the context menu.
+  final ContextMenuBuilder contextMenuBuilder;
+
+  /// The child widget that will be listened to for gestures.
+  final Widget child;
+
+  @override
+  State<_ContextMenuRegion> createState() => _ContextMenuRegionState();
+}
+
+class _ContextMenuRegionState extends State<_ContextMenuRegion> {
+  Offset? _longPressOffset;
+
+  final ContextMenuController _contextMenuController = ContextMenuController();
+
+  static bool get _longPressEnabled {
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.iOS:
+        return true;
+      case TargetPlatform.macOS:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        return false;
+    }
+  }
+
+  void _onSecondaryTapUp(TapUpDetails details) {
+    _show(details.globalPosition);
+  }
+
+  void _onTap() {
+    if (!_contextMenuController.isShown) {
+      return;
+    }
+    _hide();
+  }
+
+  void _onLongPressStart(LongPressStartDetails details) {
+    _longPressOffset = details.globalPosition;
+  }
+
+  void _onLongPress() {
+    assert(_longPressOffset != null);
+    _show(_longPressOffset!);
+    _longPressOffset = null;
+  }
+
+  void _show(Offset position) {
+    _contextMenuController.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return widget.contextMenuBuilder(context, position);
+      },
+    );
+  }
+
+  void _hide() {
+    _contextMenuController.remove();
+  }
+
+  @override
+  void dispose() {
+    _hide();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      behavior: HitTestBehavior.opaque,
+      onSecondaryTapUp: _onSecondaryTapUp,
+      onTap: _onTap,
+      onLongPress: _longPressEnabled ? _onLongPress : null,
+      onLongPressStart: _longPressEnabled ? _onLongPressStart : null,
+      child: widget.child,
+    );
+  }
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart
new file mode 100644
index 0000000..c932abc
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart
@@ -0,0 +1,64 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This example demonstrates showing the default buttons, but customizing their
+// appearance.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+void main() => runApp(MyApp());
+
+class MyApp extends StatelessWidget {
+  MyApp({super.key});
+
+  final TextEditingController _controller = TextEditingController(
+    text: 'Right click or long press to see the menu with custom buttons.',
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Custom button appearance'),
+        ),
+        body: Center(
+          child: Column(
+            children: <Widget>[
+              const SizedBox(height: 20.0),
+              TextField(
+                controller: _controller,
+                contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
+                  return AdaptiveTextSelectionToolbar(
+                    anchors: editableTextState.contextMenuAnchors,
+                    // Build the default buttons, but make them look custom.
+                    // In a real project you may want to build different
+                    // buttons depending on the platform.
+                    children: editableTextState.contextMenuButtonItems.map((ContextMenuButtonItem buttonItem) {
+                      return CupertinoButton(
+                        borderRadius: null,
+                        color: const Color(0xffaaaa00),
+                        disabledColor: const Color(0xffaaaaff),
+                        onPressed: buttonItem.onPressed,
+                        padding: const EdgeInsets.all(10.0),
+                        pressedOpacity: 0.7,
+                        child: SizedBox(
+                          width: 200.0,
+                          child: Text(
+                            CupertinoTextSelectionToolbarButton.getButtonLabel(context, buttonItem),
+                          ),
+                        ),
+                      );
+                    }).toList(),
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart
new file mode 100644
index 0000000..4f599d8
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart
@@ -0,0 +1,83 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This example demonstrates showing a custom context menu only when some
+// narrowly defined text is selected.
+
+import 'package:flutter/material.dart';
+
+void main() => runApp(MyApp());
+
+const String emailAddress = 'me@example.com';
+const String text = 'Select the email address and open the menu: $emailAddress';
+
+class MyApp extends StatelessWidget {
+  MyApp({super.key});
+
+  final TextEditingController _controller = TextEditingController(
+    text: text,
+  );
+
+  void _showDialog (BuildContext context) {
+    Navigator.of(context).push(
+      DialogRoute<void>(
+        context: context,
+        builder: (BuildContext context) =>
+          const AlertDialog(title: Text('You clicked send email!')),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Custom button for emails'),
+        ),
+        body: Center(
+          child: Column(
+            children: <Widget>[
+              Container(height: 20.0),
+              TextField(
+                controller: _controller,
+                contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
+                  final List<ContextMenuButtonItem> buttonItems =
+                      editableTextState.contextMenuButtonItems;
+                  // Here we add an "Email" button to the default TextField
+                  // context menu for the current platform, but only if an email
+                  // address is currently selected.
+                  final TextEditingValue value = _controller.value;
+                  if (_isValidEmail(value.selection.textInside(value.text))) {
+                    buttonItems.insert(0, ContextMenuButtonItem(
+                      label: 'Send email',
+                      onPressed: () {
+                        ContextMenuController.removeAny();
+                        _showDialog(context);
+                      },
+                    ));
+                  }
+                  return AdaptiveTextSelectionToolbar.buttonItems(
+                    anchors: editableTextState.contextMenuAnchors,
+                    buttonItems: buttonItems,
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+bool _isValidEmail(String text) {
+  return RegExp(
+    r'(?<name>[a-zA-Z0-9]+)'
+    r'@'
+    r'(?<domain>[a-zA-Z0-9]+)'
+    r'\.'
+    r'(?<topLevelDomain>[a-zA-Z0-9]+)',
+  ).hasMatch(text);
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart
new file mode 100644
index 0000000..cd8c686
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart
@@ -0,0 +1,107 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This example demonstrates how to create a custom toolbar that retains the
+// look of the default buttons for the current platform.
+
+import 'package:flutter/material.dart';
+
+void main() => runApp(MyApp());
+
+class MyApp extends StatelessWidget {
+  MyApp({super.key});
+
+  final TextEditingController _controller = TextEditingController(
+    text: 'Right click or long press to see the menu with a custom toolbar.',
+  );
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Custom toolbar, default-looking buttons'),
+        ),
+        body: Center(
+          child: Column(
+            children: <Widget>[
+              const SizedBox(height: 20.0),
+              TextField(
+                controller: _controller,
+                contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
+                  return _MyTextSelectionToolbar(
+                    anchor: editableTextState.contextMenuAnchors.primaryAnchor,
+                    // getAdaptiveButtons creates the default button widgets for
+                    // the current platform.
+                    children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
+                      context,
+                      // These buttons just close the menu when clicked.
+                      <ContextMenuButtonItem>[
+                        ContextMenuButtonItem(
+                          label: 'One',
+                          onPressed: () => ContextMenuController.removeAny(),
+                        ),
+                        ContextMenuButtonItem(
+                          label: 'Two',
+                          onPressed: () => ContextMenuController.removeAny(),
+                        ),
+                        ContextMenuButtonItem(
+                          label: 'Three',
+                          onPressed: () => ContextMenuController.removeAny(),
+                        ),
+                        ContextMenuButtonItem(
+                          label: 'Four',
+                          onPressed: () => ContextMenuController.removeAny(),
+                        ),
+                        ContextMenuButtonItem(
+                          label: 'Five',
+                          onPressed: () => ContextMenuController.removeAny(),
+                        ),
+                      ],
+                    ).toList(),
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// A simple, yet totally custom, text selection toolbar.
+///
+/// Displays its children in a scrollable grid.
+class _MyTextSelectionToolbar extends StatelessWidget {
+  const _MyTextSelectionToolbar({
+    required this.anchor,
+    required this.children,
+  });
+
+  final Offset anchor;
+  final List<Widget> children;
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: <Widget>[
+        Positioned(
+          top: anchor.dy,
+          left: anchor.dx,
+          child: Container(
+            width: 200.0,
+            height: 200.0,
+            color: Colors.cyanAccent.withOpacity(0.5),
+            child: GridView.count(
+              padding: const EdgeInsets.all(12.0),
+              crossAxisCount: 2,
+              children: children,
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}
diff --git a/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart b/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart
new file mode 100644
index 0000000..9f40adc
--- /dev/null
+++ b/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart
@@ -0,0 +1,68 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This example demonstrates a custom context menu in non-editable text using
+// SelectionArea.
+
+import 'package:flutter/material.dart';
+
+void main() => runApp(const MyApp());
+
+const String text = 'I am some text inside of SelectionArea. Right click or long press me to show the customized context menu.';
+
+class MyApp extends StatelessWidget {
+  const MyApp({super.key});
+
+  void _showDialog (BuildContext context) {
+    Navigator.of(context).push(
+      DialogRoute<void>(
+        context: context,
+        builder: (BuildContext context) =>
+          const AlertDialog(title: Text('You clicked print!')),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('Context menu anywhere'),
+        ),
+        body: Center(
+          child: SizedBox(
+            width: 200.0,
+            child: SelectionArea(
+              contextMenuBuilder: (
+                BuildContext context,
+                SelectableRegionState selectableRegionState,
+              ) {
+                return AdaptiveTextSelectionToolbar.buttonItems(
+                  anchors: selectableRegionState.contextMenuAnchors,
+                  buttonItems: <ContextMenuButtonItem>[
+                    ...selectableRegionState.contextMenuButtonItems,
+                    ContextMenuButtonItem(
+                      onPressed: () {
+                        ContextMenuController.removeAny();
+                        _showDialog(context);
+                      },
+                      label: 'Print',
+                    ),
+                  ],
+                );
+              },
+              child: ListView(
+                children: const <Widget>[
+                  SizedBox(height: 20.0),
+                  Text(text),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/examples/api/test/material/context_menu/context_menu_controller.0_test.dart b/examples/api/test/material/context_menu/context_menu_controller.0_test.dart
new file mode 100644
index 0000000..48aee51
--- /dev/null
+++ b/examples/api/test/material/context_menu/context_menu_controller.0_test.dart
@@ -0,0 +1,44 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/context_menu_controller.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('showing and hiding the custom context menu in the whole app', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const example.MyApp(),
+    );
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Right clicking the middle of the app shows the custom context menu.
+    final Offset center = tester.getCenter(find.byType(Scaffold));
+    final TestGesture gesture = await tester.startGesture(
+      center,
+      kind: PointerDeviceKind.mouse,
+      buttons: kSecondaryMouseButton,
+    );
+    await tester.pump();
+    await gesture.up();
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.text('Print'), findsOneWidget);
+
+    // Tap to dismiss.
+    await tester.tapAt(center);
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Long pressing also shows the custom context menu.
+    await tester.longPressAt(center);
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.text('Print'), findsOneWidget);
+  });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart
new file mode 100644
index 0000000..38c28ee
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart
@@ -0,0 +1,36 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('showing and hiding the context menu in TextField with custom buttons', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      example.MyApp(),
+    );
+
+    await tester.tap(find.byType(EditableText));
+    await tester.pump();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Long pressing the field shows the default context menu but with custom
+    // buttons.
+    await tester.longPress(find.byType(EditableText));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.byType(CupertinoButton), findsAtLeastNWidgets(1));
+
+    // Tap to dismiss.
+    await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+    expect(find.byType(CupertinoButton), findsNothing);
+  });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart
new file mode 100644
index 0000000..bee046b
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart
@@ -0,0 +1,69 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.1.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('showing and hiding the custom context menu in TextField with a specific selection', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      example.MyApp(),
+    );
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Right clicking the Text in the TextField shows the custom context menu,
+    // but no email button since no email address is selected.
+    TestGesture gesture = await tester.startGesture(
+      tester.getTopLeft(find.text(example.text)),
+      kind: PointerDeviceKind.mouse,
+      buttons: kSecondaryMouseButton,
+    );
+    await tester.pump();
+    await gesture.up();
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.text('Send email'), findsNothing);
+
+    // Tap to dismiss.
+    await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Select the email address.
+    final EditableTextState state =
+      tester.state<EditableTextState>(find.byType(EditableText));
+    state.updateEditingValue(state.textEditingValue.copyWith(
+      selection: TextSelection(
+        baseOffset: example.text.indexOf(example.emailAddress),
+        extentOffset: example.text.length,
+      ),
+    ));
+    await tester.pump();
+
+    // Right clicking the Text in the TextField shows the custom context menu
+    // with the email button.
+    gesture = await tester.startGesture(
+      tester.getCenter(find.text(example.text)),
+      kind: PointerDeviceKind.mouse,
+      buttons: kSecondaryMouseButton,
+    );
+    await tester.pump();
+    await gesture.up();
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.text('Send email'), findsOneWidget);
+
+    // Tap to dismiss.
+    await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+  });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart
new file mode 100644
index 0000000..90f2a23
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart
@@ -0,0 +1,64 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.2.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('showing and hiding the context menu in TextField with a custom toolbar', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      example.MyApp(),
+    );
+
+    await tester.tap(find.byType(EditableText));
+    await tester.pump();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Long pressing the field shows the custom context menu.
+    await tester.longPress(find.byType(EditableText));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // The buttons use the default widgets but with custom labels.
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+        break;
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+        expect(find.byType(TextSelectionToolbarTextButton), findsAtLeastNWidgets(1));
+        break;
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(DesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+        break;
+      case TargetPlatform.macOS:
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+        break;
+    }
+    expect(find.text('Copy'), findsNothing);
+    expect(find.text('Cut'), findsNothing);
+    expect(find.text('Paste'), findsNothing);
+    expect(find.text('Select all'), findsNothing);
+
+    // Tap to dismiss.
+    await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+    expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+    expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+    expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+    expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+    expect(find.text('Copy'), findsNothing);
+    expect(find.text('Cut'), findsNothing);
+    expect(find.text('Paste'), findsNothing);
+    expect(find.text('Select all'), findsNothing);
+  });
+}
diff --git a/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart
new file mode 100644
index 0000000..090eba4
--- /dev/null
+++ b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart
@@ -0,0 +1,41 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/selectable_region_toolbar_builder.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('showing and hiding the custom context menu on SelectionArea', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const example.MyApp(),
+    );
+
+    // Allow the selection overlay geometry to be created.
+    await tester.pump();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Right clicking the Text in the SelectionArea shows the custom context
+    // menu.
+    final TestGesture gesture = await tester.startGesture(
+      tester.getCenter(find.text(example.text)),
+      kind: PointerDeviceKind.mouse,
+      buttons: kSecondaryMouseButton,
+    );
+    await tester.pump();
+    await gesture.up();
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    expect(find.text('Print'), findsOneWidget);
+
+    // Tap to dismiss.
+    await tester.tapAt(tester.getCenter(find.byType(Scaffold)));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+  });
+}
diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart
index 72df249..af3de26 100644
--- a/packages/flutter/lib/cupertino.dart
+++ b/packages/flutter/lib/cupertino.dart
@@ -23,6 +23,7 @@
 library cupertino;
 
 export 'src/cupertino/activity_indicator.dart';
+export 'src/cupertino/adaptive_text_selection_toolbar.dart';
 export 'src/cupertino/app.dart';
 export 'src/cupertino/bottom_tab_bar.dart';
 export 'src/cupertino/button.dart';
@@ -33,6 +34,8 @@
 export 'src/cupertino/date_picker.dart';
 export 'src/cupertino/debug.dart';
 export 'src/cupertino/desktop_text_selection.dart';
+export 'src/cupertino/desktop_text_selection_toolbar.dart';
+export 'src/cupertino/desktop_text_selection_toolbar_button.dart';
 export 'src/cupertino/dialog.dart';
 export 'src/cupertino/form_row.dart';
 export 'src/cupertino/form_section.dart';
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index 33e77f8..795bb64 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -22,6 +22,7 @@
 
 export 'src/material/about.dart';
 export 'src/material/action_chip.dart';
+export 'src/material/adaptive_text_selection_toolbar.dart';
 export 'src/material/animated_icons.dart';
 export 'src/material/app.dart';
 export 'src/material/app_bar.dart';
@@ -64,6 +65,8 @@
 export 'src/material/date_picker.dart';
 export 'src/material/debug.dart';
 export 'src/material/desktop_text_selection.dart';
+export 'src/material/desktop_text_selection_toolbar.dart';
+export 'src/material/desktop_text_selection_toolbar_button.dart';
 export 'src/material/dialog.dart';
 export 'src/material/dialog_theme.dart';
 export 'src/material/divider.dart';
diff --git a/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart
new file mode 100644
index 0000000..9850179
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart
@@ -0,0 +1,226 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart' show defaultTargetPlatform;
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
+import 'text_selection_toolbar.dart';
+import 'text_selection_toolbar_button.dart';
+
+/// The default Cupertino context menu for text selection for the current
+/// platform with the given children.
+///
+/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms}
+/// Builds the mobile Cupertino context menu on all mobile platforms, not just
+/// iOS, and builds the desktop Cupertino context menu on all desktop platforms,
+/// not just MacOS. For a widget that builds the native-looking context menu for
+/// all platforms, see [AdaptiveTextSelectionToolbar].
+/// {@endtemplate}
+///
+/// See also:
+///
+/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget
+///   but for all platforms, not just the Cupertino-styled platforms.
+/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds
+///   the Cupertino button Widgets for the current platform given
+///   [ContextMenuButtonItem]s.
+class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget {
+  /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+  /// given [children].
+  ///
+  /// See also:
+  ///
+  /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+  /// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list
+  ///   of [ContextMenuButtonItem]s instead of [children] widgets.
+  /// {@endtemplate}
+  /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+  /// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the
+  ///   default Cupertino children for an editable field.
+  /// {@endtemplate}
+  /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+  /// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the
+  ///   default Cupertino children for an [EditableText].
+  /// {@endtemplate}
+  /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+  /// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the
+  ///   Cupertino children for content that is selectable but not editable.
+  /// {@endtemplate}
+  const CupertinoAdaptiveTextSelectionToolbar({
+    super.key,
+    required this.children,
+    required this.anchors,
+  }) : buttonItems = null;
+
+  /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose
+  /// children will be built from the given [buttonItems].
+  ///
+  /// See also:
+  ///
+  /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+  /// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children
+  ///   directly as a list of widgets.
+  /// {@endtemplate}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+  const CupertinoAdaptiveTextSelectionToolbar.buttonItems({
+    super.key,
+    required this.buttonItems,
+    required this.anchors,
+  }) : children = null;
+
+  /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+  /// default children for an editable field.
+  ///
+  /// If a callback is null, then its corresponding button will not be built.
+  ///
+  /// See also:
+  ///
+  /// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but
+  ///   includes Material and Cupertino toolbars.
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+  CupertinoAdaptiveTextSelectionToolbar.editable({
+    super.key,
+    required ClipboardStatus clipboardStatus,
+    required VoidCallback? onCopy,
+    required VoidCallback? onCut,
+    required VoidCallback? onPaste,
+    required VoidCallback? onSelectAll,
+    required this.anchors,
+  }) : children = null,
+       buttonItems = EditableText.getEditableButtonItems(
+         clipboardStatus: clipboardStatus,
+         onCopy: onCopy,
+         onCut: onCut,
+         onPaste: onPaste,
+         onSelectAll: onSelectAll,
+       );
+
+  /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+  /// default children for an [EditableText].
+  ///
+  /// See also:
+  ///
+  /// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this
+  ///   but includes Material and Cupertino toolbars.
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+  CupertinoAdaptiveTextSelectionToolbar.editableText({
+    super.key,
+    required EditableTextState editableTextState,
+  }) : children = null,
+       buttonItems = editableTextState.contextMenuButtonItems,
+       anchors = editableTextState.contextMenuAnchors;
+
+  /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+  /// default children for selectable, but not editable, content.
+  ///
+  /// See also:
+  ///
+  /// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but
+  ///   includes Material and Cupertino toolbars.
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+  CupertinoAdaptiveTextSelectionToolbar.selectable({
+    super.key,
+    required VoidCallback onCopy,
+    required VoidCallback onSelectAll,
+    required SelectionGeometry selectionGeometry,
+    required this.anchors,
+  }) : children = null,
+       buttonItems = SelectableRegion.getSelectableButtonItems(
+         selectionGeometry: selectionGeometry,
+         onCopy: onCopy,
+         onSelectAll: onSelectAll,
+       );
+
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
+  final TextSelectionToolbarAnchors anchors;
+
+  /// The children of the toolbar, typically buttons.
+  final List<Widget>? children;
+
+  /// The [ContextMenuButtonItem]s that will be turned into the correct button
+  /// widgets for the current platform.
+  final List<ContextMenuButtonItem>? buttonItems;
+
+  /// Returns a List of Widgets generated by turning [buttonItems] into the
+  /// the default context menu buttons for Cupertino on the current platform.
+  ///
+  /// This is useful when building a text selection toolbar with the default
+  /// button appearance for the given platform, but where the toolbar and/or the
+  /// button actions and labels may be custom.
+  ///
+  /// Does not build Material buttons. On non-Apple platforms, Cupertino buttons
+  /// will still be used, because the Cupertino library does not access the
+  /// Material library. To get the native-looking buttons on every platform, use
+  /// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material
+  /// library.
+  ///
+  /// See also:
+  ///
+  /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material
+  ///   equivalent of this class and builds only the Material buttons. It
+  ///   includes a live example of using `getAdaptiveButtons`.
+  static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.iOS:
+        return buttonItems.map((ContextMenuButtonItem buttonItem) {
+          return CupertinoTextSelectionToolbarButton.buttonItem(
+            buttonItem: buttonItem,
+          );
+        });
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+      case TargetPlatform.macOS:
+        return buttonItems.map((ContextMenuButtonItem buttonItem) {
+          return CupertinoDesktopTextSelectionToolbarButton.buttonItem(
+            buttonItem: buttonItem,
+          );
+        });
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // If there aren't any buttons to build, build an empty toolbar.
+    if ((children?.isEmpty ?? false) || (buttonItems?.isEmpty ?? false)) {
+      return const SizedBox.shrink();
+    }
+
+    final List<Widget> resultChildren = children
+        ?? getAdaptiveButtons(context, buttonItems!).toList();
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.iOS:
+      case TargetPlatform.fuchsia:
+        return CupertinoTextSelectionToolbar(
+          anchorAbove: anchors.primaryAnchor,
+          anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor,
+          children: resultChildren,
+        );
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+      case TargetPlatform.macOS:
+        return CupertinoDesktopTextSelectionToolbar(
+          anchor: anchors.primaryAnchor,
+          children: resultChildren,
+        );
+    }
+  }
+}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
index 9711d66..88b9d9b 100644
--- a/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
@@ -3,33 +3,18 @@
 // found in the LICENSE file.
 
 import 'package:flutter/foundation.dart' show clampDouble;
-import 'package:flutter/gestures.dart';
 import 'package:flutter/widgets.dart';
 
-import 'button.dart';
-import 'colors.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
 import 'localizations.dart';
-import 'theme.dart';
 
-// Minimal padding from all edges of the selection toolbar to all edges of the
-// screen.
-const double _kToolbarScreenPadding = 8.0;
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
-// a Macbook Pro.
-const double _kToolbarWidth = 222.0;
-const Radius _kToolbarBorderRadius = Radius.circular(4.0);
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.16 on a
-// Macbook Pro.
-const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
-  color: Color(0xFFBBBBBB),
-  darkColor: Color(0xFF505152),
-);
-const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
-  color: Color(0xffECE8E6),
-  darkColor: Color(0xff302928),
-);
+/// MacOS Cupertino styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+class _CupertinoDesktopTextSelectionHandleControls extends CupertinoDesktopTextSelectionControls with TextSelectionHandleControls {
+}
 
 /// Desktop Cupertino styled text selection controls.
 ///
@@ -42,7 +27,11 @@
     return Size.zero;
   }
 
-  /// Builder for the Mac-style copy/paste text selection toolbar.
+  /// Builder for the MacOS-style copy/paste text selection toolbar.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   Widget buildToolbar(
     BuildContext context,
@@ -80,6 +69,10 @@
     return Offset.zero;
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void handleSelectAll(TextSelectionDelegate delegate) {
     super.handleSelectAll(delegate);
@@ -87,7 +80,15 @@
   }
 }
 
-/// Text selection controls that follows Mac design conventions.
+/// Text selection handle controls that follow MacOS design conventions.
+@Deprecated(
+  'Use `cupertinoDesktopTextSelectionControls` instead. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls cupertinoDesktopTextSelectionHandleControls =
+    _CupertinoDesktopTextSelectionHandleControls();
+
+/// Text selection controls that follows MacOS design conventions.
 final TextSelectionControls cupertinoDesktopTextSelectionControls =
     CupertinoDesktopTextSelectionControls();
 
@@ -145,8 +146,8 @@
 
   @override
   void dispose() {
-    super.dispose();
     widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+    super.dispose();
   }
 
   @override
@@ -180,7 +181,7 @@
         items.add(onePhysicalPixelVerticalDivider);
       }
 
-      items.add(_CupertinoDesktopTextSelectionToolbarButton.text(
+      items.add(CupertinoDesktopTextSelectionToolbarButton.text(
         context: context,
         onPressed: onPressed,
         text: text,
@@ -206,182 +207,9 @@
       return const SizedBox.shrink();
     }
 
-    return _CupertinoDesktopTextSelectionToolbar(
+    return CupertinoDesktopTextSelectionToolbar(
       anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
       children: items,
     );
   }
 }
-
-/// A Mac-style text selection toolbar.
-///
-/// Typically displays buttons for text manipulation, e.g. copying and pasting
-/// text.
-///
-/// Tries to position itself as closely as possible to [anchor] while remaining
-/// fully on-screen.
-///
-/// See also:
-///
-///  * [TextSelectionControls.buildToolbar], where this is used by default to
-///    build a Mac-style toolbar.
-///  * [TextSelectionToolbar], which is similar, but builds an Android-style
-///    toolbar.
-class _CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
-  /// Creates an instance of CupertinoTextSelectionToolbar.
-  const _CupertinoDesktopTextSelectionToolbar({
-    required this.anchor,
-    required this.children,
-  }) : assert(children.length > 0);
-
-  /// The point at which the toolbar will attempt to position itself as closely
-  /// as possible.
-  final Offset anchor;
-
-  /// {@macro flutter.material.TextSelectionToolbar.children}
-  ///
-  /// See also:
-  ///   * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
-  ///     Mac-style text selection toolbar text button.
-  final List<Widget> children;
-
-  // Builds a toolbar just like the default Mac toolbar, with the right color
-  // background, padding, and rounded corners.
-  static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
-    return Container(
-      width: _kToolbarWidth,
-      decoration: BoxDecoration(
-        color: _kToolbarBackgroundColor.resolveFrom(context),
-        border: Border.all(
-          color: _kToolbarBorderColor.resolveFrom(context),
-        ),
-        borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
-      ),
-      child: Padding(
-        padding: const EdgeInsets.symmetric(
-          // This value was measured from a screenshot of TextEdit on MacOS
-          // 10.15.7 on a Macbook Pro.
-          vertical: 3.0,
-        ),
-        child: child,
-      ),
-    );
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    assert(debugCheckHasMediaQuery(context));
-    final MediaQueryData mediaQuery = MediaQuery.of(context);
-
-    final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
-    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
-
-    return Padding(
-      padding: EdgeInsets.fromLTRB(
-        _kToolbarScreenPadding,
-        paddingAbove,
-        _kToolbarScreenPadding,
-        _kToolbarScreenPadding,
-      ),
-      child: CustomSingleChildLayout(
-        delegate: DesktopTextSelectionToolbarLayoutDelegate(
-          anchor: anchor - localAdjustment,
-        ),
-        child: _defaultToolbarBuilder(context, Column(
-          mainAxisSize: MainAxisSize.min,
-          children: children,
-        )),
-      ),
-    );
-  }
-}
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
-// a Macbook Pro.
-const TextStyle _kToolbarButtonFontStyle = TextStyle(
-  inherit: false,
-  fontSize: 14.0,
-  letterSpacing: -0.15,
-  fontWeight: FontWeight.w400,
-);
-
-// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
-// Macbook Pro.
-const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
-  20.0,
-  0.0,
-  20.0,
-  3.0,
-);
-
-/// A button in the style of the Mac context menu buttons.
-class _CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
-  /// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
-  const _CupertinoDesktopTextSelectionToolbarButton({
-    required this.onPressed,
-    required this.child,
-  });
-
-  /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
-  /// a [Text] widget styled like the default Mac context menu button.
-  _CupertinoDesktopTextSelectionToolbarButton.text({
-    required BuildContext context,
-    required this.onPressed,
-    required String text,
-  }) : child = Text(
-         text,
-         overflow: TextOverflow.ellipsis,
-         style: _kToolbarButtonFontStyle.copyWith(
-           color: const CupertinoDynamicColor.withBrightness(
-             color: CupertinoColors.black,
-             darkColor: CupertinoColors.white,
-           ).resolveFrom(context),
-         ),
-       );
-
-  /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
-  final VoidCallback onPressed;
-
-  /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
-  final Widget child;
-
-  @override
-  _CupertinoDesktopTextSelectionToolbarButtonState createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
-}
-
-class _CupertinoDesktopTextSelectionToolbarButtonState extends State<_CupertinoDesktopTextSelectionToolbarButton> {
-  bool _isHovered = false;
-
-  void _onEnter(PointerEnterEvent event) {
-    setState(() {
-      _isHovered = true;
-    });
-  }
-
-  void _onExit(PointerExitEvent event) {
-    setState(() {
-      _isHovered = false;
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return SizedBox(
-      width: double.infinity,
-      child: MouseRegion(
-        onEnter: _onEnter,
-        onExit: _onExit,
-        child: CupertinoButton(
-          alignment: Alignment.centerLeft,
-          borderRadius: null,
-          color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
-          minSize: 0.0,
-          onPressed: widget.onPressed,
-          padding: _kToolbarButtonPadding,
-          pressedOpacity: 0.7,
-          child: widget.child,
-        ),
-      ),
-    );
-  }
-}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart
new file mode 100644
index 0000000..9900765
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart
@@ -0,0 +1,114 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/widgets.dart';
+
+import 'colors.dart';
+
+// The minimum padding from all edges of the selection toolbar to all edges of
+// the screen.
+const double _kToolbarScreenPadding = 8.0;
+
+// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
+// a Macbook Pro.
+const double _kToolbarWidth = 222.0;
+const Radius _kToolbarBorderRadius = Radius.circular(4.0);
+const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric(
+  vertical: 3.0,
+);
+
+// These values were measured from a screenshot of TextEdit on macOS 10.16 on a
+// Macbook Pro.
+const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
+  color: Color(0xFFBBBBBB),
+  darkColor: Color(0xFF505152),
+);
+const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
+  color: Color(0xffECE8E6),
+  darkColor: Color(0xff302928),
+);
+
+/// A macOS-style text selection toolbar.
+///
+/// Typically displays buttons for text manipulation, e.g. copying and pasting
+/// text.
+///
+/// Tries to position itself as closely as possible to [anchor] while remaining
+/// fully inside the viewport.
+///
+/// See also:
+///
+///  * [CupertinoAdaptiveTextSelectionToolbar], where this is used to build the
+///    toolbar for desktop platforms.
+///  * [AdaptiveTextSelectionToolbar], where this is used to build the toolbar on
+///    macOS.
+///  * [DesktopTextSelectionToolbar], which is similar but builds a
+///    Material-style desktop toolbar.
+class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
+  /// Creates a const instance of CupertinoTextSelectionToolbar.
+  const CupertinoDesktopTextSelectionToolbar({
+    super.key,
+    required this.anchor,
+    required this.children,
+  }) : assert(children.length > 0);
+
+  /// {@macro flutter.material.DesktopTextSelectionToolbar.anchor}
+  final Offset anchor;
+
+  /// {@macro flutter.material.TextSelectionToolbar.children}
+  ///
+  /// See also:
+  ///   * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
+  ///     macOS-style text selection toolbar text button.
+  final List<Widget> children;
+
+  // Builds a toolbar just like the default Mac toolbar, with the right color
+  // background, padding, and rounded corners.
+  static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
+    return Container(
+      width: _kToolbarWidth,
+      decoration: BoxDecoration(
+        color: _kToolbarBackgroundColor.resolveFrom(context),
+        border: Border.all(
+          color: _kToolbarBorderColor.resolveFrom(context),
+        ),
+        borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
+      ),
+      child: Padding(
+        padding: _kToolbarPadding,
+        child: child,
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    assert(debugCheckHasMediaQuery(context));
+    final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+    final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
+    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+
+    return Padding(
+      padding: EdgeInsets.fromLTRB(
+        _kToolbarScreenPadding,
+        paddingAbove,
+        _kToolbarScreenPadding,
+        _kToolbarScreenPadding,
+      ),
+      child: CustomSingleChildLayout(
+        delegate: DesktopTextSelectionToolbarLayoutDelegate(
+          anchor: anchor - localAdjustment,
+        ),
+        child: _defaultToolbarBuilder(
+          context,
+          Column(
+            mainAxisSize: MainAxisSize.min,
+            children: children,
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart
new file mode 100644
index 0000000..b035f62
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart
@@ -0,0 +1,131 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/widgets.dart';
+
+import 'button.dart';
+import 'colors.dart';
+import 'text_selection_toolbar_button.dart';
+import 'theme.dart';
+
+// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
+// a Macbook Pro.
+const TextStyle _kToolbarButtonFontStyle = TextStyle(
+  inherit: false,
+  fontSize: 14.0,
+  letterSpacing: -0.15,
+  fontWeight: FontWeight.w400,
+);
+
+// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
+// Macbook Pro.
+const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
+  20.0,
+  0.0,
+  20.0,
+  3.0,
+);
+
+/// A button in the style of the Mac context menu buttons.
+class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
+  /// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
+  ///
+  /// [child] cannot be null.
+  const CupertinoDesktopTextSelectionToolbarButton({
+    super.key,
+    required this.onPressed,
+    required Widget this.child,
+  }) : assert(child != null),
+       buttonItem = null;
+
+  /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
+  /// a [Text] widget styled like the default Mac context menu button.
+  CupertinoDesktopTextSelectionToolbarButton.text({
+    super.key,
+    required BuildContext context,
+    required this.onPressed,
+    required String text,
+  }) : buttonItem = null,
+       child = Text(
+         text,
+         overflow: TextOverflow.ellipsis,
+         style: _kToolbarButtonFontStyle.copyWith(
+           color: const CupertinoDynamicColor.withBrightness(
+             color: CupertinoColors.black,
+             darkColor: CupertinoColors.white,
+           ).resolveFrom(context),
+         ),
+       );
+
+  /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
+  /// the given [ContextMenuButtonItem].
+  ///
+  /// [buttonItem] cannot be null.
+  CupertinoDesktopTextSelectionToolbarButton.buttonItem({
+    super.key,
+    required ContextMenuButtonItem this.buttonItem,
+  }) : assert(buttonItem != null),
+       onPressed = buttonItem.onPressed,
+       child = null;
+
+  /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+  final VoidCallback onPressed;
+
+  /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
+  final Widget? child;
+
+  /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+  final ContextMenuButtonItem? buttonItem;
+
+  @override
+  State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
+}
+
+class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> {
+  bool _isHovered = false;
+
+  void _onEnter(PointerEnterEvent event) {
+    setState(() {
+      _isHovered = true;
+    });
+  }
+
+  void _onExit(PointerExitEvent event) {
+    setState(() {
+      _isHovered = false;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final Widget child = widget.child ?? Text(
+      CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
+      overflow: TextOverflow.ellipsis,
+      style: _kToolbarButtonFontStyle.copyWith(
+        color: const CupertinoDynamicColor.withBrightness(
+          color: CupertinoColors.black,
+          darkColor: CupertinoColors.white,
+        ).resolveFrom(context),
+      ),
+    );
+    return SizedBox(
+      width: double.infinity,
+      child: MouseRegion(
+        onEnter: _onEnter,
+        onExit: _onExit,
+        child: CupertinoButton(
+          alignment: Alignment.centerLeft,
+          borderRadius: null,
+          color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
+          minSize: 0.0,
+          onPressed: widget.onPressed,
+          padding: _kToolbarButtonPadding,
+          pressedOpacity: 0.7,
+          child: child,
+        ),
+      ),
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index 65806d1..a4d0db0 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -10,6 +10,7 @@
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'colors.dart';
 import 'desktop_text_selection.dart';
 import 'icons.dart';
@@ -234,7 +235,11 @@
     this.textAlignVertical,
     this.textDirection,
     this.readOnly = false,
-    ToolbarOptions? toolbarOptions,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
+    this.toolbarOptions,
     this.showCursor,
     this.autofocus = false,
     this.obscuringCharacter = '•',
@@ -273,6 +278,7 @@
     this.restorationId,
     this.scribbleEnabled = true,
     this.enableIMEPersonalizedLearning = true,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.spellCheckConfiguration,
     this.magnifierConfiguration,
   }) : assert(textAlign != null),
@@ -313,31 +319,7 @@
        ),
        assert(enableIMEPersonalizedLearning != null),
        keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
-       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
-       toolbarOptions = toolbarOptions ??
-           (obscureText
-               ? (readOnly
-                   // No point in even offering "Select All" in a read-only obscured
-                   // field.
-                   ? const ToolbarOptions()
-                   // Writable, but obscured.
-                   : const ToolbarOptions(
-                       selectAll: true,
-                       paste: true,
-                     ))
-               : (readOnly
-                   // Read-only, not obscured.
-                   ? const ToolbarOptions(
-                       selectAll: true,
-                       copy: true,
-                     )
-                   // Writable, not obscured.
-                   : const ToolbarOptions(
-                       copy: true,
-                       cut: true,
-                       selectAll: true,
-                       paste: true,
-                     )));
+       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
 
   /// Creates a borderless iOS-style text field.
   ///
@@ -397,7 +379,11 @@
     this.textAlignVertical,
     this.textDirection,
     this.readOnly = false,
-    ToolbarOptions? toolbarOptions,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
+    this.toolbarOptions,
     this.showCursor,
     this.autofocus = false,
     this.obscuringCharacter = '•',
@@ -436,6 +422,7 @@
     this.restorationId,
     this.scribbleEnabled = true,
     this.enableIMEPersonalizedLearning = true,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.spellCheckConfiguration,
     this.magnifierConfiguration,
   }) : assert(textAlign != null),
@@ -477,31 +464,7 @@
        assert(clipBehavior != null),
        assert(enableIMEPersonalizedLearning != null),
        keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
-       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
-       toolbarOptions = toolbarOptions ??
-           (obscureText
-               ? (readOnly
-                   // No point in even offering "Select All" in a read-only obscured
-                   // field.
-                   ? const ToolbarOptions()
-                   // Writable, but obscured.
-                   : const ToolbarOptions(
-                       selectAll: true,
-                       paste: true,
-                     ))
-               : (readOnly
-                   // Read-only, not obscured.
-                   ? const ToolbarOptions(
-                       selectAll: true,
-                       copy: true,
-                     )
-                   // Writable, not obscured.
-                   : const ToolbarOptions(
-                       copy: true,
-                       cut: true,
-                       selectAll: true,
-                       paste: true,
-                     )));
+       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
 
   /// Controls the text being edited.
   ///
@@ -605,7 +568,11 @@
   /// If not set, select all and paste will default to be enabled. Copy and cut
   /// will be disabled if [obscureText] is true. If [readOnly] is true,
   /// paste and cut will be disabled regardless.
-  final ToolbarOptions toolbarOptions;
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
+  final ToolbarOptions? toolbarOptions;
 
   /// {@macro flutter.material.InputDecorator.textAlignVertical}
   final TextAlignVertical? textAlignVertical;
@@ -787,6 +754,21 @@
   /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
   final bool enableIMEPersonalizedLearning;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  ///
+  /// If not provided, will build a default menu based on the platform.
+  ///
+  /// See also:
+  ///
+  ///  * [CupertinoAdaptiveTextSelectionToolbar], which is built by default.
+  final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+  static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+    return CupertinoAdaptiveTextSelectionToolbar.editableText(
+      editableTextState: editableTextState,
+    );
+  }
+
   /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
   ///
   /// {@macro flutter.widgets.magnifier.intro}
@@ -1226,12 +1208,12 @@
       case TargetPlatform.android:
       case TargetPlatform.fuchsia:
       case TargetPlatform.linux:
-        textSelectionControls ??= cupertinoTextSelectionControls;
+        textSelectionControls ??= cupertinoTextSelectionHandleControls;
         break;
 
       case TargetPlatform.macOS:
       case TargetPlatform.windows:
-        textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+        textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
         handleDidGainAccessibilityFocus = () {
           // Automatically activate the TextField when it receives accessibility focus.
           if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) {
@@ -1380,6 +1362,7 @@
             restorationId: 'editable',
             scribbleEnabled: widget.scribbleEnabled,
             enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+            contextMenuBuilder: widget.contextMenuBuilder,
             spellCheckConfiguration: spellCheckConfiguration,
           ),
         ),
diff --git a/packages/flutter/lib/src/cupertino/text_form_field_row.dart b/packages/flutter/lib/src/cupertino/text_form_field_row.dart
index e46a62d..a77669d 100644
--- a/packages/flutter/lib/src/cupertino/text_form_field_row.dart
+++ b/packages/flutter/lib/src/cupertino/text_form_field_row.dart
@@ -5,6 +5,7 @@
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'colors.dart';
 import 'form_row.dart';
 import 'text_field.dart';
@@ -116,6 +117,10 @@
     TextAlignVertical? textAlignVertical,
     bool autofocus = false,
     bool readOnly = false,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
     ToolbarOptions? toolbarOptions,
     bool? showCursor,
     String obscuringCharacter = '•',
@@ -151,6 +156,7 @@
       fontWeight: FontWeight.w400,
       color: CupertinoColors.placeholderText,
     ),
+    EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
   })  : assert(initialValue == null || controller == null),
         assert(textAlign != null),
         assert(autofocus != null),
@@ -234,6 +240,7 @@
                 autofillHints: autofillHints,
                 placeholder: placeholder,
                 placeholderStyle: placeholderStyle,
+                contextMenuBuilder: contextMenuBuilder,
               ),
             );
           },
@@ -262,6 +269,12 @@
   /// initialize its [TextEditingController.text] with [initialValue].
   final TextEditingController? controller;
 
+  static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+    return CupertinoAdaptiveTextSelectionToolbar.editableText(
+      editableTextState: editableTextState,
+    );
+  }
+
   @override
   FormFieldState<String> createState() => _CupertinoTextFormFieldRowState();
 }
diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart
index 67d004a..512ec71 100644
--- a/packages/flutter/lib/src/cupertino/text_selection.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection.dart
@@ -22,142 +22,6 @@
 // screen. Eyeballed value.
 const double _kArrowScreenPadding = 26.0;
 
-// Generates the child that's passed into CupertinoTextSelectionToolbar.
-class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
-  const _CupertinoTextSelectionControlsToolbar({
-    required this.clipboardStatus,
-    required this.endpoints,
-    required this.globalEditableRegion,
-    required this.handleCopy,
-    required this.handleCut,
-    required this.handlePaste,
-    required this.handleSelectAll,
-    required this.selectionMidpoint,
-    required this.textLineHeight,
-  });
-
-  final ClipboardStatusNotifier? clipboardStatus;
-  final List<TextSelectionPoint> endpoints;
-  final Rect globalEditableRegion;
-  final VoidCallback? handleCopy;
-  final VoidCallback? handleCut;
-  final VoidCallback? handlePaste;
-  final VoidCallback? handleSelectAll;
-  final Offset selectionMidpoint;
-  final double textLineHeight;
-
-  @override
-  _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
-}
-
-class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
-  void _onChangedClipboardStatus() {
-    setState(() {
-      // Inform the widget that the value of clipboardStatus has changed.
-    });
-  }
-
-  @override
-  void initState() {
-    super.initState();
-    widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
-  }
-
-  @override
-  void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
-    super.didUpdateWidget(oldWidget);
-    if (oldWidget.clipboardStatus != widget.clipboardStatus) {
-      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
-      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
-    }
-  }
-
-  @override
-  void dispose() {
-    super.dispose();
-    widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    // Don't render the menu until the state of the clipboard is known.
-    if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
-      return const SizedBox.shrink();
-    }
-
-    assert(debugCheckHasMediaQuery(context));
-    final MediaQueryData mediaQuery = MediaQuery.of(context);
-
-    // The toolbar should appear below the TextField when there is not enough
-    // space above the TextField to show it, assuming there's always enough
-    // space at the bottom in this case.
-    final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
-      _kArrowScreenPadding + mediaQuery.padding.left,
-      mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
-    );
-
-    final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
-    final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
-
-    // The y-coordinate has to be calculated instead of directly quoting
-    // selectionMidpoint.dy, since the caller
-    // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
-    // going to be facing up or down.
-    final Offset anchorAbove = Offset(
-      anchorX,
-      anchorTop,
-    );
-    final Offset anchorBelow = Offset(
-      anchorX,
-      widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
-    );
-
-    final List<Widget> items = <Widget>[];
-    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
-    final Widget onePhysicalPixelVerticalDivider =
-        SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
-
-    void addToolbarButton(
-      String text,
-      VoidCallback onPressed,
-    ) {
-      if (items.isNotEmpty) {
-        items.add(onePhysicalPixelVerticalDivider);
-      }
-
-      items.add(CupertinoTextSelectionToolbarButton.text(
-        onPressed: onPressed,
-        text: text,
-      ));
-    }
-
-    if (widget.handleCut != null) {
-      addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
-    }
-    if (widget.handleCopy != null) {
-      addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
-    }
-    if (widget.handlePaste != null
-        && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
-      addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
-    }
-    if (widget.handleSelectAll != null) {
-      addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
-    }
-
-    // If there is no option available, build an empty widget.
-    if (items.isEmpty) {
-      return const SizedBox.shrink();
-    }
-
-    return CupertinoTextSelectionToolbar(
-      anchorAbove: anchorAbove,
-      anchorBelow: anchorBelow,
-      children: items,
-    );
-  }
-}
-
 /// Draws a single text selection handle with a bar and a ball.
 class _TextSelectionHandlePainter extends CustomPainter {
   const _TextSelectionHandlePainter(this.color);
@@ -190,6 +54,17 @@
   bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
 }
 
+/// iOS Cupertino styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+@Deprecated(
+  'Use `CupertinoTextSelectionControls`. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+class CupertinoTextSelectionHandleControls extends CupertinoTextSelectionControls with TextSelectionHandleControls {
+}
+
 /// iOS Cupertino styled text selection controls.
 ///
 /// The [cupertinoTextSelectionControls] global variable has a
@@ -205,6 +80,10 @@
   }
 
   /// Builder for iOS-style copy/paste text selection toolbar.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   Widget buildToolbar(
     BuildContext context,
@@ -213,7 +92,7 @@
     Offset selectionMidpoint,
     List<TextSelectionPoint> endpoints,
     TextSelectionDelegate delegate,
-    ClipboardStatusNotifier? clipboardStatus,
+    ValueNotifier<ClipboardStatus>? clipboardStatus,
     Offset? lastSecondaryTapDownPosition,
   ) {
     return _CupertinoTextSelectionControlsToolbar(
@@ -305,5 +184,150 @@
   }
 }
 
-/// Text selection controls that follows iOS design conventions.
-final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls();
+/// Text selection handle controls that follow iOS design conventions.
+@Deprecated(
+  'Use `cupertinoTextSelectionControls` instead. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls cupertinoTextSelectionHandleControls =
+    CupertinoTextSelectionHandleControls();
+
+/// Text selection controls that follow iOS design conventions.
+final TextSelectionControls cupertinoTextSelectionControls =
+    CupertinoTextSelectionControls();
+
+// Generates the child that's passed into CupertinoTextSelectionToolbar.
+class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
+  const _CupertinoTextSelectionControlsToolbar({
+    required this.clipboardStatus,
+    required this.endpoints,
+    required this.globalEditableRegion,
+    required this.handleCopy,
+    required this.handleCut,
+    required this.handlePaste,
+    required this.handleSelectAll,
+    required this.selectionMidpoint,
+    required this.textLineHeight,
+  });
+
+  final ValueNotifier<ClipboardStatus>? clipboardStatus;
+  final List<TextSelectionPoint> endpoints;
+  final Rect globalEditableRegion;
+  final VoidCallback? handleCopy;
+  final VoidCallback? handleCut;
+  final VoidCallback? handlePaste;
+  final VoidCallback? handleSelectAll;
+  final Offset selectionMidpoint;
+  final double textLineHeight;
+
+  @override
+  _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
+}
+
+class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
+  void _onChangedClipboardStatus() {
+    setState(() {
+      // Inform the widget that the value of clipboardStatus has changed.
+    });
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
+  }
+
+  @override
+  void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.clipboardStatus != widget.clipboardStatus) {
+      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
+    }
+  }
+
+  @override
+  void dispose() {
+    widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // Don't render the menu until the state of the clipboard is known.
+    if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
+      return const SizedBox.shrink();
+    }
+
+    assert(debugCheckHasMediaQuery(context));
+    final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+    // The toolbar should appear below the TextField when there is not enough
+    // space above the TextField to show it, assuming there's always enough
+    // space at the bottom in this case.
+    final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
+      _kArrowScreenPadding + mediaQuery.padding.left,
+      mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
+    );
+
+    final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
+    final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
+
+    // The y-coordinate has to be calculated instead of directly quoting
+    // selectionMidpoint.dy, since the caller
+    // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
+    // going to be facing up or down.
+    final Offset anchorAbove = Offset(
+      anchorX,
+      anchorTop,
+    );
+    final Offset anchorBelow = Offset(
+      anchorX,
+      widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
+    );
+
+    final List<Widget> items = <Widget>[];
+    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
+    final Widget onePhysicalPixelVerticalDivider =
+        SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
+
+    void addToolbarButton(
+      String text,
+      VoidCallback onPressed,
+    ) {
+      if (items.isNotEmpty) {
+        items.add(onePhysicalPixelVerticalDivider);
+      }
+
+      items.add(CupertinoTextSelectionToolbarButton.text(
+        onPressed: onPressed,
+        text: text,
+      ));
+    }
+
+    if (widget.handleCut != null) {
+      addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
+    }
+    if (widget.handleCopy != null) {
+      addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
+    }
+    if (widget.handlePaste != null
+        && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
+      addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
+    }
+    if (widget.handleSelectAll != null) {
+      addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
+    }
+
+    // If there is no option available, build an empty widget.
+    if (items.isEmpty) {
+      return const SizedBox.shrink();
+    }
+
+    return CupertinoTextSelectionToolbar(
+      anchorAbove: anchorAbove,
+      anchorBelow: anchorBelow,
+      children: items,
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
index 82cece4..a630b3d 100644
--- a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
@@ -5,6 +5,7 @@
 import 'dart:collection';
 import 'dart:ui' as ui;
 
+import 'package:flutter/foundation.dart' show clampDouble;
 import 'package:flutter/rendering.dart';
 import 'package:flutter/widgets.dart';
 
@@ -21,6 +22,10 @@
 const double _kToolbarScreenPadding = 8.0;
 const Size _kToolbarArrowSize = Size(14.0, 7.0);
 
+// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
+// screen. Eyeballed value.
+const double _kArrowScreenPadding = 26.0;
+
 // Values extracted from https://developer.apple.com/design/resources/.
 const Radius _kToolbarBorderRadius = Radius.circular(8);
 
@@ -45,6 +50,13 @@
   Widget child,
 );
 
+class _CupertinoToolbarButtonDivider extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
+  }
+}
+
 /// An iOS-style text selection toolbar.
 ///
 /// Typically displays buttons for text manipulation, e.g. copying and pasting
@@ -58,8 +70,8 @@
 ///
 /// See also:
 ///
-///  * [TextSelectionControls.buildToolbar], where this is used by default to
-///    build an iOS-style toolbar.
+///  * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+///    platform.
 ///  * [TextSelectionToolbar], which is similar, but builds an Android-style
 ///    toolbar.
 class CupertinoTextSelectionToolbar extends StatelessWidget {
@@ -91,6 +103,19 @@
   /// default Cupertino toolbar.
   final CupertinoToolbarBuilder toolbarBuilder;
 
+  // Add the visial vertical line spacer between children buttons.
+  static List<Widget> _addChildrenSpacers(List<Widget> children) {
+    final List<Widget> nextChildren = <Widget>[];
+    for (int i = 0; i < children.length; i++) {
+      final Widget child = children[i];
+      if (i != 0) {
+        nextChildren.add(_CupertinoToolbarButtonDivider());
+      }
+      nextChildren.add(child);
+    }
+    return nextChildren;
+  }
+
   // Builds a toolbar just like the default iOS toolbar, with the right color
   // background and a rounded cutout with an arrow.
   static Widget _defaultToolbarBuilder(BuildContext context, Offset anchor, bool isAbove, Widget child) {
@@ -115,8 +140,19 @@
         + _kToolbarHeight;
     final bool fitsAbove = anchorAbove.dy >= toolbarHeightNeeded;
 
-    const Offset contentPaddingAdjustment = Offset(0.0, _kToolbarContentDistance);
-    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+    // The arrow, which points to the anchor, has some margin so it can't get
+    // too close to the horizontal edges of the screen.
+    final double leftMargin = _kArrowScreenPadding + mediaQuery.padding.left;
+    final double rightMargin = mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding;
+
+    final Offset anchorAboveAdjusted = Offset(
+      clampDouble(anchorAbove.dx, leftMargin, rightMargin),
+      anchorAbove.dy - _kToolbarContentDistance - paddingAbove,
+    );
+    final Offset anchorBelowAdjusted = Offset(
+      clampDouble(anchorBelow.dx, leftMargin, rightMargin),
+      anchorBelow.dy - _kToolbarContentDistance + paddingAbove,
+    );
 
     return Padding(
       padding: EdgeInsets.fromLTRB(
@@ -127,15 +163,15 @@
       ),
       child: CustomSingleChildLayout(
         delegate: TextSelectionToolbarLayoutDelegate(
-          anchorAbove: anchorAbove - localAdjustment - contentPaddingAdjustment,
-          anchorBelow: anchorBelow - localAdjustment + contentPaddingAdjustment,
+          anchorAbove: anchorAboveAdjusted,
+          anchorBelow: anchorBelowAdjusted,
           fitsAbove: fitsAbove,
         ),
         child: _CupertinoTextSelectionToolbarContent(
-          anchor: fitsAbove ? anchorAbove : anchorBelow,
+          anchor: fitsAbove ? anchorAboveAdjusted : anchorBelowAdjusted,
           isAbove: fitsAbove,
           toolbarBuilder: toolbarBuilder,
-          children: children,
+          children: _addChildrenSpacers(children),
         ),
       ),
     );
diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
index b934b6e..6cc1cc4 100644
--- a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
@@ -6,6 +6,8 @@
 
 import 'button.dart';
 import 'colors.dart';
+import 'debug.dart';
+import 'localizations.dart';
 
 const TextStyle _kToolbarButtonFontStyle = TextStyle(
   inherit: false,
@@ -24,11 +26,14 @@
 /// A button in the style of the iOS text selection toolbar buttons.
 class CupertinoTextSelectionToolbarButton extends StatelessWidget {
   /// Create an instance of [CupertinoTextSelectionToolbarButton].
+  ///
+  /// [child] cannot be null.
   const CupertinoTextSelectionToolbarButton({
     super.key,
     this.onPressed,
-    required this.child,
-  });
+    required Widget this.child,
+  }) : assert(child != null),
+       buttonItem = null;
 
   /// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is
   /// a [Text] widget styled like the default iOS text selection toolbar button.
@@ -36,7 +41,8 @@
     super.key,
     this.onPressed,
     required String text,
-  }) : child = Text(
+  }) : buttonItem = null,
+       child = Text(
          text,
          overflow: TextOverflow.ellipsis,
          style: _kToolbarButtonFontStyle.copyWith(
@@ -44,20 +50,67 @@
          ),
        );
 
+  /// Create an instance of [CupertinoTextSelectionToolbarButton] from the given
+  /// [ContextMenuButtonItem].
+  ///
+  /// [buttonItem] cannot be null.
+  CupertinoTextSelectionToolbarButton.buttonItem({
+    super.key,
+    required ContextMenuButtonItem this.buttonItem,
+  }) : assert(buttonItem != null),
+       child = null,
+       onPressed = buttonItem.onPressed;
+
   /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
   /// The child of this button.
   ///
   /// Usually a [Text] or an [Icon].
   /// {@endtemplate}
-  final Widget child;
+  final Widget? child;
 
   /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
   /// Called when this button is pressed.
   /// {@endtemplate}
   final VoidCallback? onPressed;
 
+  /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+  /// The buttonItem used to generate the button when using
+  /// [CupertinoTextSelectionToolbarButton.buttonItem].
+  /// {@endtemplate}
+  final ContextMenuButtonItem? buttonItem;
+
+  /// Returns the default button label String for the button of the given
+  /// [ContextMenuButtonItem]'s [ContextMenuButtonType].
+  static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
+    if (buttonItem.label != null) {
+      return buttonItem.label!;
+    }
+
+    assert(debugCheckHasCupertinoLocalizations(context));
+    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
+    switch (buttonItem.type) {
+      case ContextMenuButtonType.cut:
+        return localizations.cutButtonLabel;
+      case ContextMenuButtonType.copy:
+        return localizations.copyButtonLabel;
+      case ContextMenuButtonType.paste:
+        return localizations.pasteButtonLabel;
+      case ContextMenuButtonType.selectAll:
+        return localizations.selectAllButtonLabel;
+      case ContextMenuButtonType.custom:
+        return '';
+    }
+  }
+
   @override
   Widget build(BuildContext context) {
+    final Widget child = this.child ?? Text(
+      getButtonLabel(context, buttonItem!),
+      overflow: TextOverflow.ellipsis,
+      style: _kToolbarButtonFontStyle.copyWith(
+        color: onPressed != null ? CupertinoColors.white : CupertinoColors.inactiveGray,
+      ),
+    );
     return CupertinoButton(
       borderRadius: null,
       color: _kToolbarBackgroundColor,
diff --git a/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart
new file mode 100644
index 0000000..d0ec3eb
--- /dev/null
+++ b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart
@@ -0,0 +1,319 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/rendering.dart';
+
+import 'debug.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
+import 'material_localizations.dart';
+import 'text_selection_toolbar.dart';
+import 'text_selection_toolbar_text_button.dart';
+import 'theme.dart';
+
+/// The default context menu for text selection for the current platform.
+///
+/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders}
+/// Typically, this widget would be passed to `contextMenuBuilder` in a
+/// supported parent widget, such as:
+///
+/// * [EditableText.contextMenuBuilder]
+/// * [TextField.contextMenuBuilder]
+/// * [CupertinoTextField.contextMenuBuilder]
+/// * [SelectionArea.contextMenuBuilder]
+/// * [SelectableText.contextMenuBuilder]
+/// {@endtemplate}
+///
+/// See also:
+///
+/// * [EditableText.getEditableButtonItems], which returns the default
+///   [ContextMenuButtonItem]s for [EditableText] on the platform.
+/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+///   Widgets for the current platform given [ContextMenuButtonItem]s.
+/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this
+///   widget but only for Cupertino context menus.
+/// * [TextSelectionToolbar], the default toolbar for Android.
+/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms
+///    other than MacOS.
+/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS.
+/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS.
+class AdaptiveTextSelectionToolbar extends StatelessWidget {
+  /// Create an instance of [AdaptiveTextSelectionToolbar] with the
+  /// given [children].
+  ///
+  /// See also:
+  ///
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of
+  ///   [ContextMenuButtonItem]s instead of [children] widgets.
+  /// {@endtemplate}
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.editable}
+  /// * [AdaptiveTextSelectionToolbar.editable], which builds the default
+  ///   children for an editable field.
+  /// {@endtemplate}
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText}
+  /// * [AdaptiveTextSelectionToolbar.editableText], which builds the default
+  ///   children for an [EditableText].
+  /// {@endtemplate}
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable}
+  /// * [AdaptiveTextSelectionToolbar.selectable], which builds the default
+  ///   children for content that is selectable but not editable.
+  /// {@endtemplate}
+  const AdaptiveTextSelectionToolbar({
+    super.key,
+    required this.children,
+    required this.anchors,
+  }) : buttonItems = null;
+
+  /// Create an instance of [AdaptiveTextSelectionToolbar] whose children will
+  /// be built from the given [buttonItems].
+  ///
+  /// See also:
+  ///
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.new}
+  /// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as
+  ///   a list of widgets.
+  /// {@endtemplate}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+  const AdaptiveTextSelectionToolbar.buttonItems({
+    super.key,
+    required this.buttonItems,
+    required this.anchors,
+  }) : children = null;
+
+  /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+  /// children for an editable field.
+  ///
+  /// If a callback is null, then its corresponding button will not be built.
+  ///
+  /// See also:
+  ///
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+  AdaptiveTextSelectionToolbar.editable({
+    super.key,
+    required ClipboardStatus clipboardStatus,
+    required VoidCallback? onCopy,
+    required VoidCallback? onCut,
+    required VoidCallback? onPaste,
+    required VoidCallback? onSelectAll,
+    required this.anchors,
+  }) : children = null,
+       buttonItems = EditableText.getEditableButtonItems(
+         clipboardStatus: clipboardStatus,
+         onCopy: onCopy,
+         onCut: onCut,
+         onPaste: onPaste,
+         onSelectAll: onSelectAll,
+       );
+
+  /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+  /// children for an [EditableText].
+  ///
+  /// See also:
+  ///
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+  AdaptiveTextSelectionToolbar.editableText({
+    super.key,
+    required EditableTextState editableTextState,
+  }) : children = null,
+       buttonItems = editableTextState.contextMenuButtonItems,
+       anchors = editableTextState.contextMenuAnchors;
+
+  /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+  /// children for selectable, but not editable, content.
+  ///
+  /// See also:
+  ///
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+  AdaptiveTextSelectionToolbar.selectable({
+    super.key,
+    required VoidCallback onCopy,
+    required VoidCallback onSelectAll,
+    required SelectionGeometry selectionGeometry,
+    required this.anchors,
+  }) : children = null,
+       buttonItems = SelectableRegion.getSelectableButtonItems(
+         selectionGeometry: selectionGeometry,
+         onCopy: onCopy,
+         onSelectAll: onSelectAll,
+       );
+
+  /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+  /// children for a [SelectableRegion].
+  ///
+  /// See also:
+  ///
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+  /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+  AdaptiveTextSelectionToolbar.selectableRegion({
+    super.key,
+    required SelectableRegionState selectableRegionState,
+  }) : children = null,
+       buttonItems = selectableRegionState.contextMenuButtonItems,
+       anchors = selectableRegionState.contextMenuAnchors;
+
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+  /// The [ContextMenuButtonItem]s that will be turned into the correct button
+  /// widgets for the current platform.
+  /// {@endtemplate}
+  final List<ContextMenuButtonItem>? buttonItems;
+
+  /// The children of the toolbar, typically buttons.
+  final List<Widget>? children;
+
+  /// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors}
+  /// The location on which to anchor the menu.
+  /// {@endtemplate}
+  final TextSelectionToolbarAnchors anchors;
+
+  /// Returns the default button label String for the button of the given
+  /// [ContextMenuButtonType] on any platform.
+  static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
+    if (buttonItem.label != null) {
+      return buttonItem.label!;
+    }
+
+    switch (Theme.of(context).platform) {
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        return CupertinoTextSelectionToolbarButton.getButtonLabel(
+          context,
+          buttonItem,
+        );
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        assert(debugCheckHasMaterialLocalizations(context));
+        final MaterialLocalizations localizations = MaterialLocalizations.of(context);
+        switch (buttonItem.type) {
+          case ContextMenuButtonType.cut:
+            return localizations.cutButtonLabel;
+          case ContextMenuButtonType.copy:
+            return localizations.copyButtonLabel;
+          case ContextMenuButtonType.paste:
+            return localizations.pasteButtonLabel;
+          case ContextMenuButtonType.selectAll:
+            return localizations.selectAllButtonLabel;
+          case ContextMenuButtonType.custom:
+            return '';
+        }
+    }
+  }
+
+  /// Returns a List of Widgets generated by turning [buttonItems] into the
+  /// the default context menu buttons for the current platform.
+  ///
+  /// This is useful when building a text selection toolbar with the default
+  /// button appearance for the given platform, but where the toolbar and/or the
+  /// button actions and labels may be custom.
+  ///
+  /// {@tool dartpad}
+  /// This sample demonstrates how to use `getAdaptiveButtons` to generate
+  /// default button widgets in a custom toolbar.
+  ///
+  /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart **
+  /// {@end-tool}
+  ///
+  /// See also:
+  ///
+  /// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the
+  ///   Cupertino equivalent of this class and builds only the Cupertino
+  ///   buttons.
+  static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
+    switch (Theme.of(context).platform) {
+      case TargetPlatform.iOS:
+        return buttonItems.map((ContextMenuButtonItem buttonItem) {
+            return CupertinoTextSelectionToolbarButton.text(
+              onPressed: buttonItem.onPressed,
+              text: getButtonLabel(context, buttonItem),
+            );
+          });
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.android:
+        final List<Widget> buttons = <Widget>[];
+        for (int i = 0; i < buttonItems.length; i++) {
+          final ContextMenuButtonItem buttonItem = buttonItems[i];
+          buttons.add(TextSelectionToolbarTextButton(
+            padding: TextSelectionToolbarTextButton.getPadding(i, buttonItems.length),
+            onPressed: buttonItem.onPressed,
+            child: Text(getButtonLabel(context, buttonItem)),
+          ));
+        }
+        return buttons;
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        return buttonItems.map((ContextMenuButtonItem buttonItem) {
+          return DesktopTextSelectionToolbarButton.text(
+            context: context,
+            onPressed: buttonItem.onPressed,
+            text: getButtonLabel(context, buttonItem),
+          );
+        });
+      case TargetPlatform.macOS:
+        return buttonItems.map((ContextMenuButtonItem buttonItem) {
+          return CupertinoDesktopTextSelectionToolbarButton.text(
+            context: context,
+            onPressed: buttonItem.onPressed,
+            text: getButtonLabel(context, buttonItem),
+          );
+        });
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // If there aren't any buttons to build, build an empty toolbar.
+    if ((children != null && children!.isEmpty)
+      || (buttonItems != null && buttonItems!.isEmpty)) {
+      return const SizedBox.shrink();
+    }
+
+    final List<Widget> resultChildren = children != null
+        ? children!
+        : getAdaptiveButtons(context, buttonItems!).toList();
+
+    switch (Theme.of(context).platform) {
+      case TargetPlatform.iOS:
+        return CupertinoTextSelectionToolbar(
+          anchorAbove: anchors.primaryAnchor,
+          anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
+          children: resultChildren,
+        );
+      case TargetPlatform.android:
+        return TextSelectionToolbar(
+          anchorAbove: anchors.primaryAnchor,
+          anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
+          children: resultChildren,
+        );
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        return DesktopTextSelectionToolbar(
+          anchor: anchors.primaryAnchor,
+          children: resultChildren,
+        );
+      case TargetPlatform.macOS:
+        return CupertinoDesktopTextSelectionToolbar(
+          anchor: anchors.primaryAnchor,
+          children: resultChildren,
+        );
+    }
+  }
+}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection.dart b/packages/flutter/lib/src/material/desktop_text_selection.dart
index fbe6848..b67c371 100644
--- a/packages/flutter/lib/src/material/desktop_text_selection.dart
+++ b/packages/flutter/lib/src/material/desktop_text_selection.dart
@@ -3,20 +3,19 @@
 // found in the LICENSE file.
 
 import 'package:flutter/foundation.dart' show clampDouble;
-import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 
-import 'colors.dart';
-import 'constants.dart';
 import 'debug.dart';
-import 'material.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
 import 'material_localizations.dart';
-import 'text_button.dart';
-import 'text_selection_toolbar.dart';
-import 'theme.dart';
 
-const double _kToolbarScreenPadding = 8.0;
-const double _kToolbarWidth = 222.0;
+/// Desktop Material styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+class _DesktopTextSelectionHandleControls extends DesktopTextSelectionControls with TextSelectionHandleControls {
+}
 
 /// Desktop Material styled text selection controls.
 ///
@@ -30,6 +29,10 @@
   }
 
   /// Builder for the Material-style desktop copy/paste text selection toolbar.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   Widget buildToolbar(
     BuildContext context,
@@ -67,6 +70,10 @@
     return Offset.zero;
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   bool canSelectAll(TextSelectionDelegate delegate) {
     // Allow SelectAll when selection is not collapsed, unless everything has
@@ -77,6 +84,10 @@
            !(value.selection.start == 0 && value.selection.end == value.text.length);
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void handleSelectAll(TextSelectionDelegate delegate) {
     super.handleSelectAll(delegate);
@@ -84,7 +95,17 @@
   }
 }
 
-/// Text selection controls that loosely follows Material design conventions.
+/// Desktop text selection handle controls that loosely follow Material design
+/// conventions.
+@Deprecated(
+  'Use `desktopTextSelectionControls` instead. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls desktopTextSelectionHandleControls =
+    _DesktopTextSelectionHandleControls();
+
+/// Desktop text selection controls that loosely follow Material design
+/// conventions.
 final TextSelectionControls desktopTextSelectionControls =
     DesktopTextSelectionControls();
 
@@ -142,8 +163,8 @@
 
   @override
   void dispose() {
-    super.dispose();
     widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+    super.dispose();
   }
 
   @override
@@ -173,7 +194,7 @@
       String text,
       VoidCallback onPressed,
     ) {
-      items.add(_DesktopTextSelectionToolbarButton.text(
+      items.add(DesktopTextSelectionToolbarButton.text(
         context: context,
         onPressed: onPressed,
         text: text,
@@ -199,153 +220,9 @@
       return const SizedBox.shrink();
     }
 
-    return _DesktopTextSelectionToolbar(
+    return DesktopTextSelectionToolbar(
       anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
       children: items,
     );
   }
 }
-
-/// A Material-style desktop text selection toolbar.
-///
-/// Typically displays buttons for text manipulation, e.g. copying and pasting
-/// text.
-///
-/// Tries to position itself as closely as possible to [anchor] while remaining
-/// fully on-screen.
-///
-/// See also:
-///
-///  * [_DesktopTextSelectionControls.buildToolbar], where this is used by
-///    default to build a Material-style desktop toolbar.
-///  * [TextSelectionToolbar], which is similar, but builds an Android-style
-///    toolbar.
-class _DesktopTextSelectionToolbar extends StatelessWidget {
-  /// Creates an instance of _DesktopTextSelectionToolbar.
-  const _DesktopTextSelectionToolbar({
-    required this.anchor,
-    required this.children,
-  }) : assert(children.length > 0);
-
-  /// The point at which the toolbar will attempt to position itself as closely
-  /// as possible.
-  final Offset anchor;
-
-  /// {@macro flutter.material.TextSelectionToolbar.children}
-  ///
-  /// See also:
-  ///   * [DesktopTextSelectionToolbarButton], which builds a default
-  ///     Material-style desktop text selection toolbar text button.
-  final List<Widget> children;
-
-  // Builds a desktop toolbar in the Material style.
-  static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
-    return SizedBox(
-      width: _kToolbarWidth,
-      child: Material(
-        borderRadius: const BorderRadius.all(Radius.circular(7.0)),
-        clipBehavior: Clip.antiAlias,
-        elevation: 1.0,
-        type: MaterialType.card,
-        child: child,
-      ),
-    );
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    assert(debugCheckHasMediaQuery(context));
-    final MediaQueryData mediaQuery = MediaQuery.of(context);
-
-    final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
-    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
-
-    return Padding(
-      padding: EdgeInsets.fromLTRB(
-        _kToolbarScreenPadding,
-        paddingAbove,
-        _kToolbarScreenPadding,
-        _kToolbarScreenPadding,
-      ),
-      child: CustomSingleChildLayout(
-        delegate: DesktopTextSelectionToolbarLayoutDelegate(
-          anchor: anchor - localAdjustment,
-        ),
-        child: _defaultToolbarBuilder(context, Column(
-          mainAxisSize: MainAxisSize.min,
-          children: children,
-        )),
-      ),
-    );
-  }
-}
-
-const TextStyle _kToolbarButtonFontStyle = TextStyle(
-  inherit: false,
-  fontSize: 14.0,
-  letterSpacing: -0.15,
-  fontWeight: FontWeight.w400,
-);
-
-const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
-  20.0,
-  0.0,
-  20.0,
-  3.0,
-);
-
-/// A [TextButton] for the Material desktop text selection toolbar.
-class _DesktopTextSelectionToolbarButton extends StatelessWidget {
-  /// Creates an instance of DesktopTextSelectionToolbarButton.
-  const _DesktopTextSelectionToolbarButton({
-    required this.onPressed,
-    required this.child,
-  });
-
-  /// Create an instance of [_DesktopTextSelectionToolbarButton] whose child is
-  /// a [Text] widget in the style of the Material text selection toolbar.
-  _DesktopTextSelectionToolbarButton.text({
-    required BuildContext context,
-    required this.onPressed,
-    required String text,
-  }) : child = Text(
-         text,
-         overflow: TextOverflow.ellipsis,
-         style: _kToolbarButtonFontStyle.copyWith(
-           color: Theme.of(context).colorScheme.brightness == Brightness.dark
-               ? Colors.white
-               : Colors.black87,
-         ),
-       );
-
-  /// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
-  final VoidCallback onPressed;
-
-  /// {@macro flutter.material.TextSelectionToolbarTextButton.child}
-  final Widget child;
-
-  @override
-  Widget build(BuildContext context) {
-    // TODO(hansmuller): Should be colorScheme.onSurface
-    final ThemeData theme = Theme.of(context);
-    final bool isDark = theme.colorScheme.brightness == Brightness.dark;
-    final Color foregroundColor = isDark ? Colors.white : Colors.black87;
-
-    return SizedBox(
-      width: double.infinity,
-      child: TextButton(
-        style: TextButton.styleFrom(
-          alignment: Alignment.centerLeft,
-          enabledMouseCursor: SystemMouseCursors.basic,
-          disabledMouseCursor: SystemMouseCursors.basic,
-          foregroundColor: foregroundColor,
-          shape: const RoundedRectangleBorder(),
-          minimumSize: const Size(kMinInteractiveDimension, 36.0),
-          padding: _kToolbarButtonPadding,
-        ),
-        onPressed: onPressed,
-        child: child,
-      ),
-    );
-  }
-}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart b/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart
new file mode 100644
index 0000000..e1dbfe6
--- /dev/null
+++ b/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart
@@ -0,0 +1,93 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/widgets.dart';
+
+import 'material.dart';
+import 'text_selection_toolbar.dart';
+
+// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
+// a Macbook Pro.
+const double _kToolbarScreenPadding = 8.0;
+const double _kToolbarWidth = 222.0;
+
+/// A Material-style desktop text selection toolbar.
+///
+/// Typically displays buttons for text manipulation, e.g. copying and pasting
+/// text.
+///
+/// Tries to position its top left corner as closely as possible to [anchor]
+/// while remaining fully inside the viewport.
+///
+/// See also:
+///
+///  * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+///    platform.
+///  * [TextSelectionToolbar], which is similar, but builds an Android-style
+///    toolbar.
+class DesktopTextSelectionToolbar extends StatelessWidget {
+  /// Creates a const instance of DesktopTextSelectionToolbar.
+  const DesktopTextSelectionToolbar({
+    super.key,
+    required this.anchor,
+    required this.children,
+  }) : assert(children.length > 0);
+
+  /// {@template flutter.material.DesktopTextSelectionToolbar.anchor}
+  /// The point where the toolbar will attempt to position itself as closely as
+  /// possible.
+  /// {@endtemplate}
+  final Offset anchor;
+
+  /// {@macro flutter.material.TextSelectionToolbar.children}
+  ///
+  /// See also:
+  ///   * [DesktopTextSelectionToolbarButton], which builds a default
+  ///     Material-style desktop text selection toolbar text button.
+  final List<Widget> children;
+
+  // Builds a desktop toolbar in the Material style.
+  static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
+    return SizedBox(
+      width: _kToolbarWidth,
+      child: Material(
+        borderRadius: const BorderRadius.all(Radius.circular(7.0)),
+        clipBehavior: Clip.antiAlias,
+        elevation: 1.0,
+        type: MaterialType.card,
+        child: child,
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    assert(debugCheckHasMediaQuery(context));
+    final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+    final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
+    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+
+    return Padding(
+      padding: EdgeInsets.fromLTRB(
+        _kToolbarScreenPadding,
+        paddingAbove,
+        _kToolbarScreenPadding,
+        _kToolbarScreenPadding,
+      ),
+      child: CustomSingleChildLayout(
+        delegate: DesktopTextSelectionToolbarLayoutDelegate(
+          anchor: anchor - localAdjustment,
+        ),
+        child: _defaultToolbarBuilder(
+          context,
+          Column(
+            mainAxisSize: MainAxisSize.min,
+            children: children,
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart b/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart
new file mode 100644
index 0000000..1b0ca20
--- /dev/null
+++ b/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart
@@ -0,0 +1,83 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import 'colors.dart';
+import 'constants.dart';
+import 'text_button.dart';
+import 'theme.dart';
+
+const TextStyle _kToolbarButtonFontStyle = TextStyle(
+  inherit: false,
+  fontSize: 14.0,
+  letterSpacing: -0.15,
+  fontWeight: FontWeight.w400,
+);
+
+const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
+  20.0,
+  0.0,
+  20.0,
+  3.0,
+);
+
+/// A [TextButton] for the Material desktop text selection toolbar.
+class DesktopTextSelectionToolbarButton extends StatelessWidget {
+  /// Creates an instance of DesktopTextSelectionToolbarButton.
+  const DesktopTextSelectionToolbarButton({
+    super.key,
+    required this.onPressed,
+    required this.child,
+  });
+
+  /// Create an instance of [DesktopTextSelectionToolbarButton] whose child is
+  /// a [Text] widget in the style of the Material text selection toolbar.
+  DesktopTextSelectionToolbarButton.text({
+    super.key,
+    required BuildContext context,
+    required this.onPressed,
+    required String text,
+  }) : child = Text(
+         text,
+         overflow: TextOverflow.ellipsis,
+         style: _kToolbarButtonFontStyle.copyWith(
+           color: Theme.of(context).colorScheme.brightness == Brightness.dark
+               ? Colors.white
+               : Colors.black87,
+         ),
+       );
+
+  /// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
+  final VoidCallback onPressed;
+
+  /// {@macro flutter.material.TextSelectionToolbarTextButton.child}
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    // TODO(hansmuller): Should be colorScheme.onSurface
+    final ThemeData theme = Theme.of(context);
+    final bool isDark = theme.colorScheme.brightness == Brightness.dark;
+    final Color foregroundColor = isDark ? Colors.white : Colors.black87;
+
+    return SizedBox(
+      width: double.infinity,
+      child: TextButton(
+        style: TextButton.styleFrom(
+          alignment: Alignment.centerLeft,
+          enabledMouseCursor: SystemMouseCursors.basic,
+          disabledMouseCursor: SystemMouseCursors.basic,
+          foregroundColor: foregroundColor,
+          shape: const RoundedRectangleBorder(),
+          minimumSize: const Size(kMinInteractiveDimension, 36.0),
+          padding: _kToolbarButtonPadding,
+        ),
+        onPressed: onPressed,
+        child: child,
+      ),
+    );
+  }
+}
diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart
index 24e6636..ccbe7a8 100644
--- a/packages/flutter/lib/src/material/selectable_text.dart
+++ b/packages/flutter/lib/src/material/selectable_text.dart
@@ -9,6 +9,7 @@
 import 'package:flutter/gestures.dart';
 import 'package:flutter/rendering.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'desktop_text_selection.dart';
 import 'feedback.dart';
 import 'magnifier.dart';
@@ -190,7 +191,11 @@
     this.textScaleFactor,
     this.showCursor = false,
     this.autofocus = false,
-    ToolbarOptions? toolbarOptions,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
+    this.toolbarOptions,
     this.minLines,
     this.maxLines,
     this.cursorWidth = 2.0,
@@ -208,6 +213,7 @@
     this.textHeightBehavior,
     this.textWidthBasis,
     this.onSelectionChanged,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.magnifierConfiguration,
   }) :  assert(showCursor != null),
         assert(autofocus != null),
@@ -224,12 +230,7 @@
           data != null,
           'A non-null String must be provided to a SelectableText widget.',
         ),
-        textSpan = null,
-        toolbarOptions = toolbarOptions ??
-          const ToolbarOptions(
-            selectAll: true,
-            copy: true,
-          );
+        textSpan = null;
 
   /// Creates a selectable text widget with a [TextSpan].
   ///
@@ -248,7 +249,11 @@
     this.textScaleFactor,
     this.showCursor = false,
     this.autofocus = false,
-    ToolbarOptions? toolbarOptions,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
+    this.toolbarOptions,
     this.minLines,
     this.maxLines,
     this.cursorWidth = 2.0,
@@ -266,6 +271,7 @@
     this.textHeightBehavior,
     this.textWidthBasis,
     this.onSelectionChanged,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.magnifierConfiguration,
   }) :  assert(showCursor != null),
     assert(autofocus != null),
@@ -280,12 +286,7 @@
       textSpan != null,
       'A non-null TextSpan must be provided to a SelectableText.rich widget.',
     ),
-    data = null,
-    toolbarOptions = toolbarOptions ??
-      const ToolbarOptions(
-        selectAll: true,
-        copy: true,
-      );
+    data = null;
 
   /// The text to display.
   ///
@@ -397,7 +398,11 @@
   /// Paste and cut will be disabled regardless.
   ///
   /// If not set, select all and copy will be enabled by default.
-  final ToolbarOptions toolbarOptions;
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
+  final ToolbarOptions? toolbarOptions;
 
   /// {@macro flutter.widgets.editableText.selectionEnabled}
   bool get selectionEnabled => enableInteractiveSelection;
@@ -434,6 +439,15 @@
   /// {@macro flutter.widgets.editableText.onSelectionChanged}
   final SelectionChangedCallback? onSelectionChanged;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+  static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+    return AdaptiveTextSelectionToolbar.editableText(
+      editableTextState: editableTextState,
+    );
+  }
+
   /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
   ///
   /// {@macro flutter.widgets.magnifier.intro}
@@ -639,7 +653,7 @@
       case TargetPlatform.iOS:
         final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
         forcePressEnabled = true;
-        textSelectionControls ??= cupertinoTextSelectionControls;
+        textSelectionControls ??= cupertinoTextSelectionHandleControls;
         paintCursorAboveText = true;
         cursorOpacityAnimates = true;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -651,7 +665,7 @@
       case TargetPlatform.macOS:
         final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
         forcePressEnabled = false;
-        textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+        textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
         paintCursorAboveText = true;
         cursorOpacityAnimates = true;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -663,7 +677,7 @@
       case TargetPlatform.android:
       case TargetPlatform.fuchsia:
         forcePressEnabled = false;
-        textSelectionControls ??= materialTextSelectionControls;
+        textSelectionControls ??= materialTextSelectionHandleControls;
         paintCursorAboveText = false;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -673,7 +687,7 @@
       case TargetPlatform.linux:
       case TargetPlatform.windows:
         forcePressEnabled = false;
-        textSelectionControls ??= desktopTextSelectionControls;
+        textSelectionControls ??= desktopTextSelectionHandleControls;
         paintCursorAboveText = false;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -694,6 +708,7 @@
         key: editableTextKey,
         style: effectiveTextStyle,
         readOnly: true,
+        toolbarOptions: widget.toolbarOptions,
         textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
         textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
         showSelectionHandles: _showSelectionHandles,
@@ -706,7 +721,6 @@
         textScaleFactor: widget.textScaleFactor,
         autofocus: widget.autofocus,
         forceLine: false,
-        toolbarOptions: widget.toolbarOptions,
         minLines: widget.minLines,
         maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
         selectionColor: selectionColor,
@@ -729,6 +743,7 @@
         dragStartBehavior: widget.dragStartBehavior,
         scrollPhysics: widget.scrollPhysics,
         autofillHints: null,
+        contextMenuBuilder: widget.contextMenuBuilder,
       ),
     );
 
diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart
index a6312a6..609beef 100644
--- a/packages/flutter/lib/src/material/selection_area.dart
+++ b/packages/flutter/lib/src/material/selection_area.dart
@@ -5,6 +5,7 @@
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/rendering.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'debug.dart';
 import 'desktop_text_selection.dart';
 import 'magnifier.dart';
@@ -41,6 +42,7 @@
     super.key,
     this.focusNode,
     this.selectionControls,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.magnifierConfiguration,
     this.onSelectionChanged,
     required this.child,
@@ -65,6 +67,23 @@
   /// If it is null, the platform specific selection control is used.
   final TextSelectionControls? selectionControls;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  ///
+  /// If not provided, will build a default menu based on the ambient
+  /// [ThemeData.platform].
+  ///
+  /// {@tool dartpad}
+  /// This example shows how to build a custom context menu for any selected
+  /// content in a SelectionArea.
+  ///
+  /// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart **
+  /// {@end-tool}
+  ///
+  /// See also:
+  ///
+  ///  * [AdaptiveTextSelectionToolbar], which is built by default.
+  final SelectableRegionContextMenuBuilder? contextMenuBuilder;
+
   /// Called when the selected content changes.
   final ValueChanged<SelectedContent?>? onSelectionChanged;
 
@@ -73,6 +92,12 @@
   /// {@macro flutter.widgets.ProxyWidget.child}
   final Widget child;
 
+  static Widget _defaultContextMenuBuilder(BuildContext context, SelectableRegionState selectableRegionState) {
+    return AdaptiveTextSelectionToolbar.selectableRegion(
+      selectableRegionState: selectableRegionState,
+    );
+  }
+
   @override
   State<StatefulWidget> createState() => _SelectionAreaState();
 }
@@ -100,22 +125,24 @@
     switch (Theme.of(context).platform) {
       case TargetPlatform.android:
       case TargetPlatform.fuchsia:
-        controls ??= materialTextSelectionControls;
+        controls ??= materialTextSelectionHandleControls;
         break;
       case TargetPlatform.iOS:
-        controls ??= cupertinoTextSelectionControls;
+        controls ??= cupertinoTextSelectionHandleControls;
         break;
       case TargetPlatform.linux:
       case TargetPlatform.windows:
-        controls ??= desktopTextSelectionControls;
+        controls ??= desktopTextSelectionHandleControls;
         break;
       case TargetPlatform.macOS:
-        controls ??= cupertinoDesktopTextSelectionControls;
+        controls ??= cupertinoDesktopTextSelectionHandleControls;
         break;
     }
+
     return SelectableRegion(
-      focusNode: _effectiveFocusNode,
       selectionControls: controls,
+      focusNode: _effectiveFocusNode,
+      contextMenuBuilder: widget.contextMenuBuilder,
       magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
       onSelectionChanged: widget.onSelectionChanged,
       child: widget.child,
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index e0256d4..045b057 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -10,6 +10,7 @@
 import 'package:flutter/rendering.dart';
 import 'package:flutter/services.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'colors.dart';
 import 'debug.dart';
 import 'desktop_text_selection.dart';
@@ -263,7 +264,11 @@
     this.textAlignVertical,
     this.textDirection,
     this.readOnly = false,
-    ToolbarOptions? toolbarOptions,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
+    this.toolbarOptions,
     this.showCursor,
     this.autofocus = false,
     this.obscuringCharacter = '•',
@@ -305,6 +310,7 @@
     this.restorationId,
     this.scribbleEnabled = true,
     this.enableIMEPersonalizedLearning = true,
+    this.contextMenuBuilder = _defaultContextMenuBuilder,
     this.spellCheckConfiguration,
     this.magnifierConfiguration,
   }) : assert(textAlign != null),
@@ -343,31 +349,7 @@
        assert(clipBehavior != null),
        assert(enableIMEPersonalizedLearning != null),
        keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
-       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
-       toolbarOptions = toolbarOptions ??
-           (obscureText
-               ? (readOnly
-                   // No point in even offering "Select All" in a read-only obscured
-                   // field.
-                   ? const ToolbarOptions()
-                   // Writable, but obscured.
-                   : const ToolbarOptions(
-                       selectAll: true,
-                       paste: true,
-                     ))
-               : (readOnly
-                   // Read-only, not obscured.
-                   ? const ToolbarOptions(
-                       selectAll: true,
-                       copy: true,
-                     )
-                   // Writable, not obscured.
-                   : const ToolbarOptions(
-                       copy: true,
-                       cut: true,
-                       selectAll: true,
-                       paste: true,
-                     )));
+       enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
 
   /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
   ///
@@ -513,7 +495,11 @@
   /// If not set, select all and paste will default to be enabled. Copy and cut
   /// will be disabled if [obscureText] is true. If [readOnly] is true,
   /// paste and cut will be disabled regardless.
-  final ToolbarOptions toolbarOptions;
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
+  final ToolbarOptions? toolbarOptions;
 
   /// {@macro flutter.widgets.editableText.showCursor}
   final bool? showCursor;
@@ -779,6 +765,21 @@
   /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
   final bool enableIMEPersonalizedLearning;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  ///
+  /// If not provided, will build a default menu based on the platform.
+  ///
+  /// See also:
+  ///
+  ///  * [AdaptiveTextSelectionToolbar], which is built by default.
+  final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+  static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+    return AdaptiveTextSelectionToolbar.editableText(
+      editableTextState: editableTextState,
+    );
+  }
+
   /// {@macro flutter.widgets.EditableText.spellCheckConfiguration}
   ///
   /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this
@@ -1208,7 +1209,7 @@
       case TargetPlatform.iOS:
         final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
         forcePressEnabled = true;
-        textSelectionControls ??= cupertinoTextSelectionControls;
+        textSelectionControls ??= cupertinoTextSelectionHandleControls;
         paintCursorAboveText = true;
         cursorOpacityAnimates = true;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -1221,7 +1222,7 @@
       case TargetPlatform.macOS:
         final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
         forcePressEnabled = false;
-        textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+        textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
         paintCursorAboveText = true;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -1239,7 +1240,7 @@
       case TargetPlatform.android:
       case TargetPlatform.fuchsia:
         forcePressEnabled = false;
-        textSelectionControls ??= materialTextSelectionControls;
+        textSelectionControls ??= materialTextSelectionHandleControls;
         paintCursorAboveText = false;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1248,7 +1249,7 @@
 
       case TargetPlatform.linux:
         forcePressEnabled = false;
-        textSelectionControls ??= desktopTextSelectionControls;
+        textSelectionControls ??= desktopTextSelectionHandleControls;
         paintCursorAboveText = false;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1257,7 +1258,7 @@
 
       case TargetPlatform.windows:
         forcePressEnabled = false;
-        textSelectionControls ??= desktopTextSelectionControls;
+        textSelectionControls ??= desktopTextSelectionHandleControls;
         paintCursorAboveText = false;
         cursorOpacityAnimates = false;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1334,6 +1335,7 @@
           restorationId: 'editable',
           scribbleEnabled: widget.scribbleEnabled,
           enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+          contextMenuBuilder: widget.contextMenuBuilder,
           spellCheckConfiguration: spellCheckConfiguration,
           magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
         ),
diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart
index 1b1db94..a9addc2 100644
--- a/packages/flutter/lib/src/material/text_form_field.dart
+++ b/packages/flutter/lib/src/material/text_form_field.dart
@@ -5,6 +5,7 @@
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 
+import 'adaptive_text_selection_toolbar.dart';
 import 'input_decorator.dart';
 import 'text_field.dart';
 import 'theme.dart';
@@ -110,6 +111,10 @@
     TextAlignVertical? textAlignVertical,
     bool autofocus = false,
     bool readOnly = false,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
     ToolbarOptions? toolbarOptions,
     bool? showCursor,
     String obscuringCharacter = '•',
@@ -148,6 +153,7 @@
     super.restorationId,
     bool enableIMEPersonalizedLearning = true,
     MouseCursor? mouseCursor,
+    EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
   }) : assert(initialValue == null || controller == null),
        assert(textAlign != null),
        assert(autofocus != null),
@@ -236,6 +242,7 @@
                scrollController: scrollController,
                enableIMEPersonalizedLearning: enableIMEPersonalizedLearning,
                mouseCursor: mouseCursor,
+               contextMenuBuilder: contextMenuBuilder,
              ),
            );
          },
@@ -247,6 +254,12 @@
   /// initialize its [TextEditingController.text] with [initialValue].
   final TextEditingController? controller;
 
+  static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+    return AdaptiveTextSelectionToolbar.editableText(
+      editableTextState: editableTextState,
+    );
+  }
+
   @override
   FormFieldState<String> createState() => _TextFormFieldState();
 }
diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart
index 9b4aa1e..9c31349 100644
--- a/packages/flutter/lib/src/material/text_selection.dart
+++ b/packages/flutter/lib/src/material/text_selection.dart
@@ -19,6 +19,17 @@
 const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
 const double _kToolbarContentDistance = 8.0;
 
+/// Android Material styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+@Deprecated(
+  'Use `MaterialTextSelectionControls`. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+class MaterialTextSelectionHandleControls extends MaterialTextSelectionControls with TextSelectionHandleControls {
+}
+
 /// Android Material styled text selection controls.
 ///
 /// The [materialTextSelectionControls] global variable has a
@@ -29,6 +40,10 @@
   Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
 
   /// Builder for material-style copy/paste text selection toolbar.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   Widget buildToolbar(
     BuildContext context,
@@ -40,7 +55,7 @@
     ClipboardStatusNotifier? clipboardStatus,
     Offset? lastSecondaryTapDownPosition,
   ) {
-    return _TextSelectionControlsToolbar(
+   return _TextSelectionControlsToolbar(
       globalEditableRegion: globalEditableRegion,
       textLineHeight: textLineHeight,
       selectionMidpoint: selectionMidpoint,
@@ -107,6 +122,10 @@
     }
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   bool canSelectAll(TextSelectionDelegate delegate) {
     // Android allows SelectAll when selection is not collapsed, unless
@@ -183,8 +202,8 @@
 
   @override
   void dispose() {
-    super.dispose();
     widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+    super.dispose();
   }
 
   @override
@@ -289,5 +308,12 @@
   }
 }
 
+/// Text selection handle controls that follow the Material Design specification.
+@Deprecated(
+  'Use `materialTextSelectionControls` instead. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls materialTextSelectionHandleControls = MaterialTextSelectionHandleControls();
+
 /// Text selection controls that follow the Material Design specification.
 final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();
diff --git a/packages/flutter/lib/src/material/text_selection_toolbar.dart b/packages/flutter/lib/src/material/text_selection_toolbar.dart
index d014bbc..a024968 100644
--- a/packages/flutter/lib/src/material/text_selection_toolbar.dart
+++ b/packages/flutter/lib/src/material/text_selection_toolbar.dart
@@ -19,6 +19,12 @@
 const double _kToolbarScreenPadding = 8.0;
 const double _kToolbarHeight = 44.0;
 
+const double _kHandleSize = 22.0;
+
+// Padding between the toolbar and the anchor.
+const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
+const double _kToolbarContentDistance = 8.0;
+
 /// A fully-functional Material-style text selection toolbar.
 ///
 /// Tries to position itself above [anchorAbove], but if it doesn't fit, then
@@ -29,8 +35,8 @@
 ///
 /// See also:
 ///
-///  * [TextSelectionControls.buildToolbar], where this is used by default to
-///    build an Android-style toolbar.
+///  * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+///    platform.
 ///  * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS-
 ///    style toolbar.
 class TextSelectionToolbar extends StatelessWidget {
@@ -87,10 +93,17 @@
 
   @override
   Widget build(BuildContext context) {
+    // Incorporate the padding distance between the content and toolbar.
+    final Offset anchorAbovePadded =
+        anchorAbove - const Offset(0.0, _kToolbarContentDistance);
+    final Offset anchorBelowPadded =
+        anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow);
+
     final double paddingAbove = MediaQuery.of(context).padding.top
         + _kToolbarScreenPadding;
-    final double availableHeight = anchorAbove.dy - paddingAbove;
+    final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove;
     final bool fitsAbove = _kToolbarHeight <= availableHeight;
+    // Makes up for the Padding above the Stack.
     final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
 
     return Padding(
@@ -100,21 +113,17 @@
         _kToolbarScreenPadding,
         _kToolbarScreenPadding,
       ),
-      child: Stack(
-        children: <Widget>[
-          CustomSingleChildLayout(
-            delegate: TextSelectionToolbarLayoutDelegate(
-              anchorAbove: anchorAbove - localAdjustment,
-              anchorBelow: anchorBelow - localAdjustment,
-              fitsAbove: fitsAbove,
-            ),
-            child: _TextSelectionToolbarOverflowable(
-              isAbove: fitsAbove,
-              toolbarBuilder: toolbarBuilder,
-              children: children,
-            ),
-          ),
-        ],
+      child: CustomSingleChildLayout(
+        delegate: TextSelectionToolbarLayoutDelegate(
+          anchorAbove: anchorAbovePadded - localAdjustment,
+          anchorBelow: anchorBelowPadded - localAdjustment,
+          fitsAbove: fitsAbove,
+        ),
+        child: _TextSelectionToolbarOverflowable(
+          isAbove: fitsAbove,
+          toolbarBuilder: toolbarBuilder,
+          children: children,
+        ),
       ),
     );
   }
@@ -156,8 +165,8 @@
   // changed and saved values are no longer relevant. This should be called in
   // setState or another context where a rebuild is happening.
   void _reset() {
-    // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes in
-    // order to cause it to rebuild. This lets it recalculate its
+    // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes
+    // in order to cause it to rebuild. This lets it recalculate its
     // saved width for the new set of children, and it prevents AnimatedSize
     // from animating the size change.
     _containerKey = UniqueKey();
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index da314d6..5ed85d4 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1100,7 +1100,7 @@
   /// visually contiguous.
   ///
   /// Leading or trailing newline characters will be represented by zero-width
-  /// `Textbox`es.
+  /// `TextBox`es.
   ///
   /// The method only returns `TextBox`es of glyphs that are entirely enclosed by
   /// the given `selection`: a multi-code-unit glyph will be excluded if only
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 573ae8b..0a050f2 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -1981,8 +1981,10 @@
   Offset? _lastTapDownPosition;
   Offset? _lastSecondaryTapDownPosition;
 
+  /// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
   /// The position of the most recent secondary tap down event on this text
   /// input.
+  /// {@endtemplate}
   Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
 
   /// Tracks the position of a secondary tap event.
diff --git a/packages/flutter/lib/src/widgets/context_menu_button_item.dart b/packages/flutter/lib/src/widgets/context_menu_button_item.dart
new file mode 100644
index 0000000..2c5e723
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/context_menu_button_item.dart
@@ -0,0 +1,89 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'framework.dart';
+
+/// The buttons that can appear in a context menu by default.
+///
+/// See also:
+///
+///  * [ContextMenuButtonItem], which uses this enum to describe a button in a
+///    context menu.
+enum ContextMenuButtonType {
+  /// A button that cuts the current text selection.
+  cut,
+
+  /// A button that copies the current text selection.
+  copy,
+
+  /// A button that pastes the clipboard contents into the focused text field.
+  paste,
+
+  /// A button that selects all the contents of the focused text field.
+  selectAll,
+
+  /// Anything other than the default button types.
+  custom,
+}
+
+/// The type and callback for a context menu button.
+///
+/// See also:
+///
+///  * [AdaptiveTextSelectionToolbar], which can take a list of
+///    ContextMenuButtonItems and create a platform-specific context menu with
+///    the indicated buttons.
+@immutable
+class ContextMenuButtonItem {
+  /// Creates a const instance of [ContextMenuButtonItem].
+  const ContextMenuButtonItem({
+    required this.onPressed,
+    this.type = ContextMenuButtonType.custom,
+    this.label,
+  });
+
+  /// The callback to be called when the button is pressed.
+  final VoidCallback onPressed;
+
+  /// The type of button this represents.
+  final ContextMenuButtonType type;
+
+  /// The label to display on the button.
+  ///
+  /// If a [type] other than [ContextMenuButtonType.custom] is given
+  /// and a label is not provided, then the default label for that type for the
+  /// platform will be looked up.
+  final String? label;
+
+  /// Creates a new [ContextMenuButtonItem] with the provided parameters
+  /// overridden.
+  ContextMenuButtonItem copyWith({
+    VoidCallback? onPressed,
+    ContextMenuButtonType? type,
+    String? label,
+  }) {
+    return ContextMenuButtonItem(
+      onPressed: onPressed ?? this.onPressed,
+      type: type ?? this.type,
+      label: label ?? this.label,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other.runtimeType != runtimeType) {
+      return false;
+    }
+    return other is ContextMenuButtonItem
+        && other.label == label
+        && other.onPressed == onPressed
+        && other.type == type;
+  }
+
+  @override
+  int get hashCode => Object.hash(label, onPressed, type);
+
+  @override
+  String toString() => 'ContextMenuButtonItem $type, $label';
+}
diff --git a/packages/flutter/lib/src/widgets/context_menu_controller.dart b/packages/flutter/lib/src/widgets/context_menu_controller.dart
new file mode 100644
index 0000000..bfc6f96
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/context_menu_controller.dart
@@ -0,0 +1,123 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'framework.dart';
+import 'inherited_theme.dart';
+import 'navigator.dart';
+import 'overlay.dart';
+
+/// Builds and manages a context menu at a given location.
+///
+/// There can only ever be one context menu shown at a given time in the entire
+/// app.
+///
+/// {@tool dartpad}
+/// This example shows how to use a GestureDetector to show a context menu
+/// anywhere in a widget subtree that receives a right click or long press.
+///
+/// ** See code in examples/api/lib/material/context_menu/context_menu_controller.0.dart **
+/// {@end-tool}
+class ContextMenuController {
+  /// Creates a context menu that can be shown with [show].
+  ContextMenuController({
+    this.onRemove,
+  });
+
+  /// Called when this menu is removed.
+  final VoidCallback? onRemove;
+
+  /// The currently shown instance, if any.
+  static ContextMenuController? _shownInstance;
+
+  // The OverlayEntry is static because only one context menu can be displayed
+  // at one time.
+  static OverlayEntry? _menuOverlayEntry;
+
+  /// Shows the given context menu.
+  ///
+  /// Since there can only be one shown context menu at a time, calling this
+  /// will also remove any other context menu that is visible.
+  void show({
+    required BuildContext context,
+    required WidgetBuilder contextMenuBuilder,
+    Widget? debugRequiredFor,
+  }) {
+    removeAny();
+    final OverlayState overlayState = Overlay.of(
+      context,
+      rootOverlay: true,
+      debugRequiredFor: debugRequiredFor,
+    );
+    final CapturedThemes capturedThemes = InheritedTheme.capture(
+      from: context,
+      to: Navigator.maybeOf(context)?.context,
+    );
+
+    _menuOverlayEntry = OverlayEntry(
+      builder: (BuildContext context) {
+        return capturedThemes.wrap(contextMenuBuilder(context));
+      },
+    );
+    overlayState.insert(_menuOverlayEntry!);
+    _shownInstance = this;
+  }
+
+  /// Remove the currently shown context menu from the UI.
+  ///
+  /// Does nothing if no context menu is currently shown.
+  ///
+  /// If a menu is removed, and that menu provided an [onRemove] callback when
+  /// it was created, then that callback will be called.
+  ///
+  /// See also:
+  ///
+  ///  * [remove], which removes only the current instance.
+  static void removeAny() {
+    _menuOverlayEntry?.remove();
+    _menuOverlayEntry = null;
+    if (_shownInstance != null) {
+      _shownInstance!.onRemove?.call();
+      _shownInstance = null;
+    }
+  }
+
+  /// True if and only if this menu is currently being shown.
+  bool get isShown => _shownInstance == this;
+
+  /// Cause the underlying [OverlayEntry] to rebuild during the next pipeline
+  /// flush.
+  ///
+  /// It's necessary to call this function if the output of [contextMenuBuilder]
+  /// has changed.
+  ///
+  /// Errors if the context menu is not currently shown.
+  ///
+  /// See also:
+  ///
+  ///  * [OverlayEntry.markNeedsBuild]
+  void markNeedsBuild() {
+    assert(isShown);
+    _menuOverlayEntry?.markNeedsBuild();
+  }
+
+  /// Remove this menu from the UI.
+  ///
+  /// Does nothing if this instance is not currently shown. In other words, if
+  /// another context menu is currently shown, that menu will not be removed.
+  ///
+  /// This method should only be called once. The instance cannot be shown again
+  /// after removing. Create a new instance.
+  ///
+  /// If an [onRemove] method was given to this instance, it will be called.
+  ///
+  /// See also:
+  ///
+  ///  * [removeAny], which removes any shown instance of the context menu.
+  void remove() {
+    if (!isShown) {
+      return;
+    }
+    removeAny();
+  }
+}
diff --git a/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart b/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
index 471b011..706c28f 100644
--- a/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
+++ b/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
@@ -4,7 +4,6 @@
 
 import 'package:flutter/rendering.dart';
 
-
 /// Positions the toolbar at [anchor] if it fits, otherwise moves it so that it
 /// just fits fully on-screen.
 ///
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 8307aa7..a8a5b3b 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -19,6 +19,7 @@
 import 'basic.dart';
 import 'binding.dart';
 import 'constants.dart';
+import 'context_menu_button_item.dart';
 import 'debug.dart';
 import 'default_selection_style.dart';
 import 'default_text_editing_shortcuts.dart';
@@ -39,6 +40,7 @@
 import 'text.dart';
 import 'text_editing_intents.dart';
 import 'text_selection.dart';
+import 'text_selection_toolbar_anchors.dart';
 import 'ticker_provider.dart';
 import 'widget_span.dart';
 
@@ -54,6 +56,18 @@
 /// Signature for the callback that reports the app private command results.
 typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
 
+/// Signature for a widget builder that builds a context menu for the given
+/// [EditableTextState].
+///
+/// See also:
+///
+///  * [SelectableRegionContextMenuBuilder], which performs the same role for
+///    [SelectableRegion].
+typedef EditableTextContextMenuBuilder = Widget Function(
+  BuildContext context,
+  EditableTextState editableTextState,
+);
+
 // The time it takes for the cursor to fade from fully opaque to fully
 // transparent and vice versa. A full cursor blink, from transparent to opaque
 // to transparent, is twice this duration.
@@ -264,10 +278,18 @@
 /// [EditableText] and its derived widgets have their own default [ToolbarOptions].
 /// Create a custom [ToolbarOptions] if you want explicit control over the toolbar
 /// option.
+@Deprecated(
+  'Use `contextMenuBuilder` instead. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
 class ToolbarOptions {
   /// Create a toolbar configuration with given options.
   ///
   /// All options default to false if they are not explicitly set.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   const ToolbarOptions({
     this.copy = false,
     this.cut = false,
@@ -278,6 +300,9 @@
        assert(paste != null),
        assert(selectAll != null);
 
+  /// An instance of [ToolbarOptions] with no options enabled.
+  static const ToolbarOptions empty = ToolbarOptions();
+
   /// Whether to show copy option in toolbar.
   ///
   /// Defaults to false. Must not be null.
@@ -637,6 +662,10 @@
     this.scrollController,
     this.scrollPhysics,
     this.autocorrectionTextRectColor,
+    @Deprecated(
+      'Use `contextMenuBuilder` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
     ToolbarOptions? toolbarOptions,
     this.autofillHints = const <String>[],
     this.autofillClient,
@@ -645,6 +674,7 @@
     this.scrollBehavior,
     this.scribbleEnabled = true,
     this.enableIMEPersonalizedLearning = true,
+    this.contextMenuBuilder,
     this.spellCheckConfiguration,
     this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
   }) : assert(controller != null),
@@ -683,12 +713,12 @@
        assert(scrollPadding != null),
        assert(dragStartBehavior != null),
        enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
-       toolbarOptions = toolbarOptions ??
+       toolbarOptions = selectionControls is TextSelectionHandleControls && toolbarOptions == null ? ToolbarOptions.empty : toolbarOptions ??
            (obscureText
                ? (readOnly
                    // No point in even offering "Select All" in a read-only obscured
                    // field.
-                   ? const ToolbarOptions()
+                   ? ToolbarOptions.empty
                    // Writable, but obscured.
                    : const ToolbarOptions(
                        selectAll: true,
@@ -1564,6 +1594,41 @@
   /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
   final bool enableIMEPersonalizedLearning;
 
+  /// {@template flutter.widgets.EditableText.contextMenuBuilder}
+  /// Builds the text selection toolbar when requested by the user.
+  ///
+  /// `primaryAnchor` is the desired anchor position for the context menu, while
+  /// `secondaryAnchor` is the fallback location if the menu doesn't fit.
+  ///
+  /// `buttonItems` represents the buttons that would be built by default for
+  /// this widget.
+  ///
+  /// {@tool dartpad}
+  /// This example shows how to customize the menu, in this case by keeping the
+  /// default buttons for the platform but modifying their appearance.
+  ///
+  /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart **
+  /// {@end-tool}
+  ///
+  /// {@tool dartpad}
+  /// This example shows how to show a custom button only when an email address
+  /// is currently selected.
+  ///
+  /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart **
+  /// {@end-tool}
+  ///
+  /// See also:
+  ///   * [AdaptiveTextSelectionToolbar], which builds the default text selection
+  ///     toolbar for the current platform, but allows customization of the
+  ///     buttons.
+  ///   * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+  ///     button Widgets for the current platform given
+  ///     [ContextMenuButtonItem]s.
+  /// {@endtemplate}
+  ///
+  /// If not provided, no context menu will be shown.
+  final EditableTextContextMenuBuilder? contextMenuBuilder;
+
   /// {@template flutter.widgets.EditableText.spellCheckConfiguration}
   /// Configuration that details how spell check should be performed.
   ///
@@ -1587,6 +1652,61 @@
 
   bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
 
+  /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+  /// platform's default selection menu for an editable field.
+  ///
+  /// For example, [EditableText] uses this to generate the default buttons for
+  /// its context menu.
+  ///
+  /// See also:
+  ///
+  /// * [EditableTextState.contextMenuButtonItems], which gives the
+  ///   [ContextMenuButtonItem]s for a specific EditableText.
+  /// * [SelectableRegion.getSelectableButtonItems], which performs a similar
+  ///   role but for content that is selectable but not editable.
+  /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+  ///   take a list of [ContextMenuButtonItem]s with
+  ///   [AdaptiveTextSelectionToolbar.buttonItems].
+  /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+  ///   Widgets for the current platform given [ContextMenuButtonItem]s.
+  static List<ContextMenuButtonItem> getEditableButtonItems({
+    required final ClipboardStatus? clipboardStatus,
+    required final VoidCallback? onCopy,
+    required final VoidCallback? onCut,
+    required final VoidCallback? onPaste,
+    required final VoidCallback? onSelectAll,
+  }) {
+    // If the paste button is enabled, don't render anything until the state
+    // of the clipboard is known, since it's used to determine if paste is
+    // shown.
+    if (onPaste != null && clipboardStatus == ClipboardStatus.unknown) {
+      return <ContextMenuButtonItem>[];
+    }
+
+    return <ContextMenuButtonItem>[
+      if (onCut != null)
+        ContextMenuButtonItem(
+          onPressed: onCut,
+          type: ContextMenuButtonType.cut,
+        ),
+      if (onCopy != null)
+        ContextMenuButtonItem(
+          onPressed: onCopy,
+          type: ContextMenuButtonType.copy,
+        ),
+      if (onPaste != null)
+        ContextMenuButtonItem(
+          onPressed: onPaste,
+          type: ContextMenuButtonType.paste,
+        ),
+      if (onSelectAll != null)
+        ContextMenuButtonItem(
+          onPressed: onSelectAll,
+          type: ContextMenuButtonType.selectAll,
+        ),
+    ];
+  }
+
   // Infer the keyboard type of an `EditableText` if it's not specified.
   static TextInputType _inferKeyboardType({
     required Iterable<String>? autofillHints,
@@ -1778,7 +1898,9 @@
 
   final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
   final GlobalKey _editableKey = GlobalKey();
-  final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
+
+  /// Detects whether the clipboard can paste.
+  final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
 
   TextInputConnection? _textInputConnection;
   bool get _hasInputConnection => _textInputConnection?.attached ?? false;
@@ -1854,16 +1976,61 @@
   Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
 
   @override
-  bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
+  bool get cutEnabled {
+    if (widget.selectionControls is! TextSelectionHandleControls) {
+      return widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
+    }
+    return !widget.readOnly
+        && !widget.obscureText
+        && !textEditingValue.selection.isCollapsed;
+  }
 
   @override
-  bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
+  bool get copyEnabled {
+    if (widget.selectionControls is! TextSelectionHandleControls) {
+      return widget.toolbarOptions.copy && !widget.obscureText;
+    }
+    return !widget.obscureText
+        && !textEditingValue.selection.isCollapsed;
+  }
 
   @override
-  bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
+  bool get pasteEnabled {
+    if (widget.selectionControls is! TextSelectionHandleControls) {
+      return widget.toolbarOptions.paste && !widget.readOnly;
+    }
+    return !widget.readOnly
+        && (clipboardStatus == null
+          || clipboardStatus!.value == ClipboardStatus.pasteable);
+  }
 
   @override
-  bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
+  bool get selectAllEnabled {
+    if (widget.selectionControls is! TextSelectionHandleControls) {
+      return widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
+    }
+
+    if (!widget.enableInteractiveSelection
+        || (widget.readOnly
+            && widget.obscureText)) {
+      return false;
+    }
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.macOS:
+        return false;
+      case TargetPlatform.iOS:
+        return textEditingValue.text.isNotEmpty
+            && textEditingValue.selection.isCollapsed;
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        return textEditingValue.text.isNotEmpty
+           && !(textEditingValue.selection.start == 0
+               && textEditingValue.selection.end == textEditingValue.text.length);
+    }
+  }
 
   void _onChangedClipboardStatus() {
     setState(() {
@@ -1912,7 +2079,7 @@
           break;
       }
     }
-    _clipboardStatus?.update();
+    clipboardStatus?.update();
   }
 
   /// Cut current selection to [Clipboard].
@@ -1938,7 +2105,7 @@
       });
       hideToolbar();
     }
-    _clipboardStatus?.update();
+    clipboardStatus?.update();
   }
 
   /// Paste text from [Clipboard].
@@ -1999,6 +2166,16 @@
     if (cause == SelectionChangedCause.toolbar) {
       switch (defaultTargetPlatform) {
         case TargetPlatform.android:
+        case TargetPlatform.iOS:
+        case TargetPlatform.fuchsia:
+          break;
+        case TargetPlatform.macOS:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          hideToolbar();
+      }
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
         case TargetPlatform.fuchsia:
         case TargetPlatform.linux:
         case TargetPlatform.windows:
@@ -2033,12 +2210,154 @@
     return configuration.copyWith(spellCheckService: spellCheckService);
   }
 
+  /// Returns the [ContextMenuButtonItem]s for the given [ToolbarOptions].
+  @Deprecated(
+    'Use `contextMenuBuilder` instead of `toolbarOptions`. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
+  List<ContextMenuButtonItem>? buttonItemsForToolbarOptions([TargetPlatform? targetPlatform]) {
+    final ToolbarOptions toolbarOptions = widget.toolbarOptions;
+    if (toolbarOptions == ToolbarOptions.empty) {
+      return null;
+    }
+    return <ContextMenuButtonItem>[
+      if (toolbarOptions.cut && cutEnabled)
+        ContextMenuButtonItem(
+          onPressed: () {
+            selectAll(SelectionChangedCause.toolbar);
+          },
+          type: ContextMenuButtonType.selectAll,
+        ),
+      if (toolbarOptions.copy && copyEnabled)
+        ContextMenuButtonItem(
+          onPressed: () {
+            copySelection(SelectionChangedCause.toolbar);
+          },
+          type: ContextMenuButtonType.copy,
+        ),
+      if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled)
+        ContextMenuButtonItem(
+          onPressed: () {
+            pasteText(SelectionChangedCause.toolbar);
+          },
+          type: ContextMenuButtonType.paste,
+        ),
+      if (toolbarOptions.selectAll && selectAllEnabled)
+        ContextMenuButtonItem(
+          onPressed: () {
+            selectAll(SelectionChangedCause.toolbar);
+          },
+          type: ContextMenuButtonType.selectAll,
+        ),
+    ];
+  }
+
+  /// Gets the line heights at the start and end of the selection for the given
+  /// [EditableTextState].
+  _GlyphHeights _getGlyphHeights() {
+    final TextSelection selection = textEditingValue.selection;
+
+    // Only calculate handle rects if the text in the previous frame
+    // is the same as the text in the current frame. This is done because
+    // widget.renderObject contains the renderEditable from the previous frame.
+    // If the text changed between the current and previous frames then
+    // widget.renderObject.getRectForComposingRange might fail. In cases where
+    // the current frame is different from the previous we fall back to
+    // renderObject.preferredLineHeight.
+    final InlineSpan span = renderEditable.text!;
+    final String prevText = span.toPlainText();
+    final String currText = textEditingValue.text;
+    if (prevText != currText || selection == null || !selection.isValid || selection.isCollapsed) {
+      return _GlyphHeights(
+        start: renderEditable.preferredLineHeight,
+        end: renderEditable.preferredLineHeight,
+      );
+    }
+
+    final String selectedGraphemes = selection.textInside(currText);
+    final int firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
+    final Rect? startCharacterRect = renderEditable.getRectForComposingRange(TextRange(
+      start: selection.start,
+      end: selection.start + firstSelectedGraphemeExtent,
+    ));
+    final int lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
+    final Rect? endCharacterRect = renderEditable.getRectForComposingRange(TextRange(
+      start: selection.end - lastSelectedGraphemeExtent,
+      end: selection.end,
+    ));
+    return _GlyphHeights(
+      start: startCharacterRect?.height ?? renderEditable.preferredLineHeight,
+      end: endCharacterRect?.height ?? renderEditable.preferredLineHeight,
+    );
+  }
+
+  /// {@template flutter.widgets.EditableText.getAnchors}
+  /// Returns the anchor points for the default context menu.
+  /// {@endtemplate}
+  ///
+  /// See also:
+  ///
+  ///  * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
+  ///    for the default context menu buttons.
+  TextSelectionToolbarAnchors get contextMenuAnchors {
+    if (renderEditable.lastSecondaryTapDownPosition != null) {
+      return TextSelectionToolbarAnchors(
+        primaryAnchor: renderEditable.lastSecondaryTapDownPosition!,
+      );
+    }
+
+    final _GlyphHeights glyphHeights = _getGlyphHeights();
+    final TextSelection selection = textEditingValue.selection;
+    final List<TextSelectionPoint> points =
+        renderEditable.getEndpointsForSelection(selection);
+    return TextSelectionToolbarAnchors.fromSelection(
+      renderBox: renderEditable,
+      startGlyphHeight: glyphHeights.start,
+      endGlyphHeight: glyphHeights.end,
+      selectionEndpoints: points,
+    );
+  }
+
+  /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+  /// platform's default selection menu for [EditableText].
+  ///
+  /// See also:
+  ///
+  /// * [EditableText.getEditableButtonItems], which performs a similar role,
+  ///   but for any editable field, not just specifically EditableText.
+  /// * [SelectableRegionState.contextMenuButtonItems], which peforms a similar
+  ///   role but for content that is selectable but not editable.
+  /// * [contextMenuAnchors], which provides the anchor points for the default
+  ///   context menu.
+  /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+  ///   take a list of [ContextMenuButtonItem]s with
+  ///   [AdaptiveTextSelectionToolbar.buttonItems].
+  /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+  ///   button Widgets for the current platform given [ContextMenuButtonItem]s.
+  List<ContextMenuButtonItem> get contextMenuButtonItems {
+    return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems(
+      clipboardStatus: clipboardStatus?.value,
+      onCopy: copyEnabled
+          ? () => copySelection(SelectionChangedCause.toolbar)
+          : null,
+      onCut: cutEnabled
+          ? () => cutSelection(SelectionChangedCause.toolbar)
+          : null,
+      onPaste: pasteEnabled
+          ? () => pasteText(SelectionChangedCause.toolbar)
+          : null,
+      onSelectAll: selectAllEnabled
+          ? () => selectAll(SelectionChangedCause.toolbar)
+          : null,
+    );
+  }
+
   // State lifecycle:
 
   @override
   void initState() {
     super.initState();
-    _clipboardStatus?.addListener(_onChangedClipboardStatus);
+    clipboardStatus?.addListener(_onChangedClipboardStatus);
     widget.controller.addListener(_didChangeTextEditingValue);
     widget.focusNode.addListener(_handleFocusChanged);
     _scrollController.addListener(_onEditableScroll);
@@ -2159,8 +2478,11 @@
         );
       }
     }
-    if (widget.selectionEnabled && pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) {
-      _clipboardStatus?.update();
+    final bool canPaste = widget.selectionControls is TextSelectionHandleControls
+        ? pasteEnabled
+        : widget.selectionControls?.canPaste(this) ?? false;
+    if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) {
+      clipboardStatus!.update();
     }
   }
 
@@ -2181,8 +2503,8 @@
     _selectionOverlay = null;
     widget.focusNode.removeListener(_handleFocusChanged);
     WidgetsBinding.instance.removeObserver(this);
-    _clipboardStatus?.removeListener(_onChangedClipboardStatus);
-    _clipboardStatus?.dispose();
+    clipboardStatus?.removeListener(_onChangedClipboardStatus);
+    clipboardStatus?.dispose();
     _cursorVisibilityNotifier.dispose();
     super.dispose();
     assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
@@ -2733,7 +3055,7 @@
 
   TextSelectionOverlay _createSelectionOverlay() {
     final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
-      clipboardStatus: _clipboardStatus,
+      clipboardStatus: clipboardStatus,
       context: context,
       value: _value,
       debugRequiredFor: widget,
@@ -2745,6 +3067,14 @@
       selectionDelegate: this,
       dragStartBehavior: widget.dragStartBehavior,
       onSelectionHandleTapped: widget.onSelectionHandleTapped,
+      contextMenuBuilder: widget.contextMenuBuilder == null
+        ? null
+        : (BuildContext context) {
+          return widget.contextMenuBuilder!(
+            context,
+            this,
+          );
+        },
       magnifierConfiguration: widget.magnifierConfiguration,
     );
 
@@ -2784,7 +3114,7 @@
         }
         break;
     }
-    if (widget.selectionControls == null) {
+    if (widget.selectionControls == null && widget.contextMenuBuilder == null) {
       _selectionOverlay?.dispose();
       _selectionOverlay = null;
     } else {
@@ -3305,10 +3635,10 @@
       return false;
     }
 
-    if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) {
+    if (_selectionOverlay == null) {
       return false;
     }
-    _clipboardStatus?.update();
+    clipboardStatus?.update();
     _selectionOverlay!.showToolbar();
     return true;
   }
@@ -3356,13 +3686,13 @@
   }
 
   /// Hides the magnifier if it is visible.
-  void hideMagnifier({required bool shouldShowToolbar}) {
+  void hideMagnifier() {
     if (_selectionOverlay == null) {
       return;
     }
 
     if (_selectionOverlay!.magnifierIsVisible) {
-      _selectionOverlay!.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
+      _selectionOverlay!.hideMagnifier();
     }
   }
 
@@ -3427,29 +3757,41 @@
 
   VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) {
     return widget.selectionEnabled
-        && copyEnabled
         && _hasFocus
-        && (controls?.canCopy(this) ?? false)
-      ? () => controls!.handleCopy(this)
+        && (widget.selectionControls is TextSelectionHandleControls
+            ? copyEnabled
+            : copyEnabled && (widget.selectionControls?.canCopy(this) ?? false))
+      ? () {
+        controls?.handleCopy(this);
+        copySelection(SelectionChangedCause.toolbar);
+      }
       : null;
   }
 
   VoidCallback? _semanticsOnCut(TextSelectionControls? controls) {
     return widget.selectionEnabled
-        && cutEnabled
         && _hasFocus
-        && (controls?.canCut(this) ?? false)
-      ? () => controls!.handleCut(this)
+        && (widget.selectionControls is TextSelectionHandleControls
+            ? cutEnabled
+            : cutEnabled && (widget.selectionControls?.canCut(this) ?? false))
+      ? () {
+        controls?.handleCut(this);
+        cutSelection(SelectionChangedCause.toolbar);
+      }
       : null;
   }
 
   VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) {
     return widget.selectionEnabled
-        && pasteEnabled
         && _hasFocus
-        && (controls?.canPaste(this) ?? false)
-        && (_clipboardStatus == null || _clipboardStatus!.value == ClipboardStatus.pasteable)
-      ? () => controls!.handlePaste(this)
+        && (widget.selectionControls is TextSelectionHandleControls
+            ? pasteEnabled
+            : pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false))
+        && (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable)
+      ? () {
+        controls?.handlePaste(this);
+        pasteText(SelectionChangedCause.toolbar);
+      }
       : null;
   }
 
@@ -4983,3 +5325,18 @@
     return timer!;
   };
 }
+
+/// The start and end glyph heights of some range of text.
+@immutable
+class _GlyphHeights {
+  const _GlyphHeights({
+    required this.start,
+    required this.end,
+  });
+
+  /// The glyph height of the first line.
+  final double start;
+
+  /// The glyph height of the last line.
+  final double end;
+}
diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart
index 2b2f049..e3a19da 100644
--- a/packages/flutter/lib/src/widgets/selectable_region.dart
+++ b/packages/flutter/lib/src/widgets/selectable_region.dart
@@ -13,6 +13,7 @@
 
 import 'actions.dart';
 import 'basic.dart';
+import 'context_menu_button_item.dart';
 import 'debug.dart';
 import 'focus_manager.dart';
 import 'focus_scope.dart';
@@ -25,6 +26,7 @@
 import 'selection_container.dart';
 import 'text_editing_intents.dart';
 import 'text_selection.dart';
+import 'text_selection_toolbar_anchors.dart';
 
 // Examples can assume:
 // FocusNode _focusNode = FocusNode();
@@ -200,6 +202,7 @@
   /// toolbar for mobile devices.
   const SelectableRegion({
     super.key,
+    this.contextMenuBuilder,
     required this.focusNode,
     required this.selectionControls,
     required this.child,
@@ -224,6 +227,9 @@
   /// {@macro flutter.widgets.ProxyWidget.child}
   final Widget child;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  final SelectableRegionContextMenuBuilder? contextMenuBuilder;
+
   /// The delegate to build the selection handles and toolbar for mobile
   /// devices.
   ///
@@ -234,11 +240,54 @@
   /// Called when the selected content changes.
   final ValueChanged<SelectedContent?>? onSelectionChanged;
 
+  /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+  /// platform's default selection menu.
+  ///
+  /// For example, [SelectableRegion] uses this to generate the default buttons
+  /// for its context menu.
+  ///
+  /// See also:
+  ///
+  /// * [SelectableRegionState.contextMenuButtonItems], which gives the
+  ///   [ContextMenuButtonItem]s for a specific SelectableRegion.
+  /// * [EditableText.getEditableButtonItems], which performs a similar role but
+  ///   for content that is both selectable and editable.
+  /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+  ///   take a list of [ContextMenuButtonItem]s with
+  ///   [AdaptiveTextSelectionToolbar.buttonItems].
+  /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+  ///   Widgets for the current platform given [ContextMenuButtonItem]s.
+  static List<ContextMenuButtonItem> getSelectableButtonItems({
+    required final SelectionGeometry selectionGeometry,
+    required final VoidCallback onCopy,
+    required final VoidCallback onSelectAll,
+  }) {
+    final bool canCopy = selectionGeometry.hasSelection;
+    final bool canSelectAll = selectionGeometry.hasContent;
+
+    // Determine which buttons will appear so that the order and total number is
+    // known. A button's position in the menu can slightly affect its
+    // appearance.
+    return <ContextMenuButtonItem>[
+      if (canCopy)
+        ContextMenuButtonItem(
+          onPressed: onCopy,
+          type: ContextMenuButtonType.copy,
+        ),
+      if (canSelectAll)
+        ContextMenuButtonItem(
+          onPressed: onSelectAll,
+          type: ContextMenuButtonType.selectAll,
+        ),
+    ];
+  }
+
   @override
-  State<StatefulWidget> createState() => _SelectableRegionState();
+  State<StatefulWidget> createState() => SelectableRegionState();
 }
 
-class _SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
+/// State for a [SelectableRegion].
+class SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
   late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
     SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
     CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
@@ -258,6 +307,9 @@
   Orientation? _lastOrientation;
   SelectedContent? _lastSelectedContent;
 
+  /// {@macro flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
+  Offset? lastSecondaryTapDownPosition;
+
   @override
   void initState() {
     super.initState();
@@ -424,6 +476,7 @@
   }
 
   void _handleRightClickDown(TapDownDetails details) {
+    lastSecondaryTapDownPosition = details.globalPosition;
     widget.focusNode.requestFocus();
     _selectWordAt(offset: details.globalPosition);
     _showHandles();
@@ -465,7 +518,17 @@
  }
 
  void _onAnyDragEnd(DragEndDetails details) {
-  _selectionOverlay!.hideMagnifier(shouldShowToolbar: true);
+   if (widget.selectionControls is! TextSelectionHandleControls) {
+    _selectionOverlay!.hideMagnifier();
+    _selectionOverlay!.showToolbar();
+   } else {
+     _selectionOverlay!.hideMagnifier();
+     _selectionOverlay!.showToolbar(
+       contextMenuBuilder: (BuildContext context) {
+         return widget.contextMenuBuilder!(context, this);
+       },
+     );
+   }
   _stopSelectionEndEdgeUpdate();
   _updateSelectedContentIfNeeded();
  }
@@ -516,8 +579,6 @@
   late Offset _selectionStartHandleDragPosition;
   late Offset _selectionEndHandleDragPosition;
 
-  late List<TextSelectionPoint> points;
-
   void _handleSelectionStartHandleDragStart(DragStartDetails details) {
     assert(_selectionDelegate.value.startSelectionPoint != null);
 
@@ -595,19 +656,6 @@
     }
     final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
     final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
-    final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
-    final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
-    if (startLocalPosition.dy > endLocalPosition.dy) {
-      points = <TextSelectionPoint>[
-        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
-        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
-      ];
-    } else {
-      points = <TextSelectionPoint>[
-        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
-        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
-      ];
-    }
     _selectionOverlay = SelectionOverlay(
       context: context,
       debugRequiredFor: widget,
@@ -621,7 +669,7 @@
       onEndHandleDragStart: _handleSelectionEndHandleDragStart,
       onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
       onEndHandleDragEnd: _onAnyDragEnd,
-      selectionEndpoints: points,
+      selectionEndpoints: selectionEndpoints,
       selectionControls: widget.selectionControls,
       selectionDelegate: this,
       clipboardStatus: null,
@@ -639,26 +687,12 @@
     assert(_hasSelectionOverlayGeometry);
     final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
     final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
-    late List<TextSelectionPoint> points;
-    final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
-    final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
-    if (startLocalPosition.dy > endLocalPosition.dy) {
-      points = <TextSelectionPoint>[
-        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
-        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
-      ];
-    } else {
-      points = <TextSelectionPoint>[
-        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
-        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
-      ];
-    }
     _selectionOverlay!
       ..startHandleType = start?.handleType ?? TextSelectionHandleType.left
       ..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight
       ..endHandleType = end?.handleType ?? TextSelectionHandleType.right
       ..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight
-      ..selectionEndpoints = points;
+      ..selectionEndpoints = selectionEndpoints;
   }
 
   /// Shows the selection handles.
@@ -706,7 +740,19 @@
     }
 
     _selectionOverlay!.toolbarLocation = location;
-    _selectionOverlay!.showToolbar();
+    if (widget.selectionControls is! TextSelectionHandleControls) {
+      _selectionOverlay!.showToolbar();
+      return true;
+    }
+
+    _selectionOverlay!.hideToolbar();
+
+    _selectionOverlay!.showToolbar(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return widget.contextMenuBuilder!(context, this);
+      },
+    );
     return true;
   }
 
@@ -830,11 +876,104 @@
     await Clipboard.setData(ClipboardData(text: data.plainText));
   }
 
-  // [TextSelectionDelegate] overrides.
+  /// {@macro flutter.widgets.EditableText.getAnchors}
+  ///
+  /// See also:
+  ///
+  ///  * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
+  ///    for the default context menu buttons.
+  TextSelectionToolbarAnchors get contextMenuAnchors {
+    if (lastSecondaryTapDownPosition != null) {
+      return TextSelectionToolbarAnchors(
+        primaryAnchor: lastSecondaryTapDownPosition!,
+      );
+    }
+    final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+    return TextSelectionToolbarAnchors.fromSelection(
+      renderBox: renderBox,
+      startGlyphHeight: startGlyphHeight,
+      endGlyphHeight: endGlyphHeight,
+      selectionEndpoints: selectionEndpoints,
+    );
+  }
 
+  /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+  /// platform's default selection menu.
+  ///
+  /// See also:
+  ///
+  /// * [SelectableRegion.getSelectableButtonItems], which performs a similar role,
+  ///   but for any selectable text, not just specifically SelectableRegion.
+  /// * [EditableTextState.contextMenuButtonItems], which peforms a similar role
+  ///   but for content that is not just selectable but also editable.
+  /// * [contextMenuAnchors], which provides the anchor points for the default
+  ///   context menu.
+  /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+  ///   take a list of [ContextMenuButtonItem]s with
+  ///   [AdaptiveTextSelectionToolbar.buttonItems].
+  /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+  ///   button Widgets for the current platform given [ContextMenuButtonItem]s.
+  List<ContextMenuButtonItem> get contextMenuButtonItems {
+    return SelectableRegion.getSelectableButtonItems(
+      selectionGeometry: _selectionDelegate.value,
+      onCopy: () {
+        _copy();
+        hideToolbar();
+      },
+      onSelectAll: () {
+        selectAll();
+        hideToolbar();
+      },
+    );
+  }
+
+  /// The line height at the start of the current selection.
+  double get startGlyphHeight {
+    return _selectionDelegate.value.startSelectionPoint!.lineHeight;
+  }
+
+  /// The line height at the end of the current selection.
+  double get endGlyphHeight {
+    return _selectionDelegate.value.endSelectionPoint!.lineHeight;
+  }
+
+  /// Returns the local coordinates of the endpoints of the current selection.
+  List<TextSelectionPoint> get selectionEndpoints {
+    final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
+    final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
+    late List<TextSelectionPoint> points;
+    final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
+    final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
+    if (startLocalPosition.dy > endLocalPosition.dy) {
+      points = <TextSelectionPoint>[
+        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
+        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
+      ];
+    } else {
+      points = <TextSelectionPoint>[
+        TextSelectionPoint(startLocalPosition, TextDirection.ltr),
+        TextSelectionPoint(endLocalPosition, TextDirection.ltr),
+      ];
+    }
+    return points;
+  }
+
+  // [TextSelectionDelegate] overrides.
+  // TODO(justinmc): After deprecations have been removed, remove
+  // TextSelectionDelegate from this class.
+  // https://github.com/flutter/flutter/issues/111213
+
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   bool get cutEnabled => false;
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   bool get pasteEnabled => false;
 
@@ -857,28 +996,50 @@
     _updateSelectedContentIfNeeded();
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void copySelection(SelectionChangedCause cause) {
     _copy();
     _clearSelection();
   }
 
-  // TODO(chunhtai): remove this workaround after decoupling text selection
-  // from text editing in TextSelectionDelegate.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   TextEditingValue textEditingValue = const TextEditingValue(text: '_');
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */}
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void cutSelection(SelectionChangedCause cause) {
     assert(false);
   }
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */}
 
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   @override
   Future<void> pasteText(SelectionChangedCause cause) async {
     assert(false);
@@ -909,7 +1070,7 @@
     _selectionDelegate.dispose();
     // In case dispose was triggered before gesture end, remove the magnifier
     // so it doesn't remain stuck in the overlay forever.
-    _selectionOverlay?.hideMagnifier(shouldShowToolbar: false);
+    _selectionOverlay?.hideMagnifier();
     _selectionOverlay?.dispose();
     _selectionOverlay = null;
     super.dispose();
@@ -967,7 +1128,7 @@
 class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
   _SelectAllAction(this.state);
 
-  final _SelectableRegionState state;
+  final SelectableRegionState state;
 
   @override
   void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) {
@@ -978,7 +1139,7 @@
 class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
   _CopySelectionAction(this.state);
 
-  final _SelectableRegionState state;
+  final SelectableRegionState state;
 
   @override
   void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) {
@@ -1795,3 +1956,15 @@
     return finalResult!;
   }
 }
+
+/// Signature for a widget builder that builds a context menu for the given
+/// [SelectableRegionState].
+///
+/// See also:
+///
+///  * [EditableTextContextMenuBuilder], which performs the same role for
+///    [EditableText].
+typedef SelectableRegionContextMenuBuilder = Widget Function(
+  BuildContext context,
+  SelectableRegionState selectableRegionState,
+);
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index a9c81f3..959d6b8 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -16,6 +16,7 @@
 import 'binding.dart';
 import 'constants.dart';
 import 'container.dart';
+import 'context_menu_controller.dart';
 import 'debug.dart';
 import 'editable_text.dart';
 import 'framework.dart';
@@ -113,6 +114,10 @@
   /// The [selectionMidpoint] parameter is a general calculation midpoint
   /// parameter of the toolbar. More detailed position information
   /// is computable from the [endpoints] parameter.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   Widget buildToolbar(
     BuildContext context,
     Rect globalEditableRegion,
@@ -137,6 +142,10 @@
   ///
   /// Subclasses can use this to decide if they should expose the cut
   /// functionality to the user.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   bool canCut(TextSelectionDelegate delegate) {
     return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed;
   }
@@ -148,6 +157,10 @@
   ///
   /// Subclasses can use this to decide if they should expose the copy
   /// functionality to the user.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   bool canCopy(TextSelectionDelegate delegate) {
     return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed;
   }
@@ -161,6 +174,10 @@
   /// This does not consider the contents of the clipboard. Subclasses may want
   /// to, for example, disallow pasting when the clipboard contains an empty
   /// string.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   bool canPaste(TextSelectionDelegate delegate) {
     return delegate.pasteEnabled;
   }
@@ -171,6 +188,10 @@
   ///
   /// Subclasses can use this to decide if they should expose the select all
   /// functionality to the user.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   bool canSelectAll(TextSelectionDelegate delegate) {
     return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
   }
@@ -181,6 +202,10 @@
   /// the user.
   // TODO(chunhtai): remove optional parameter once migration is done.
   // https://github.com/flutter/flutter/issues/99360
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
     delegate.cutSelection(SelectionChangedCause.toolbar);
   }
@@ -191,6 +216,10 @@
   /// the user.
   // TODO(chunhtai): remove optional parameter once migration is done.
   // https://github.com/flutter/flutter/issues/99360
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
     delegate.copySelection(SelectionChangedCause.toolbar);
   }
@@ -204,6 +233,10 @@
   /// asynchronous. Race conditions may exist with this API as currently
   /// implemented.
   // TODO(ianh): https://github.com/flutter/flutter/issues/11427
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   Future<void> handlePaste(TextSelectionDelegate delegate) async {
     delegate.pasteText(SelectionChangedCause.toolbar);
   }
@@ -215,6 +248,10 @@
   ///
   /// This is called by subclasses when their select-all affordance is activated
   /// by the user.
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   void handleSelectAll(TextSelectionDelegate delegate) {
     delegate.selectAll(SelectionChangedCause.toolbar);
   }
@@ -293,6 +330,7 @@
     DragStartBehavior dragStartBehavior = DragStartBehavior.start,
     VoidCallback? onSelectionHandleTapped,
     ClipboardStatusNotifier? clipboardStatus,
+    this.contextMenuBuilder,
     required TextMagnifierConfiguration magnifierConfiguration,
   }) : assert(value != null),
        assert(context != null),
@@ -333,6 +371,14 @@
     );
   }
 
+  /// {@template flutter.widgets.SelectionOverlay.context}
+  /// The context in which the selection UI should appear.
+  ///
+  /// This context must have an [Overlay] as an ancestor because this object
+  /// will display the text selection handles in that [Overlay].
+  /// {@endtemplate}
+  final BuildContext context;
+
   /// Controls the fade-in and fade-out animations for the toolbar and handles.
   @Deprecated(
     'Use `SelectionOverlay.fadeDuration` instead. '
@@ -353,6 +399,11 @@
 
   late final SelectionOverlay _selectionOverlay;
 
+  /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+  ///
+  /// If not provided, no context menu will be built.
+  final WidgetBuilder? contextMenuBuilder;
+
   /// Retrieve current value.
   @visibleForTesting
   TextEditingValue get value => _value;
@@ -365,12 +416,6 @@
   final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
   final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
 
-  /// The context in which the selection handles should appear.
-  ///
-  /// This context must have an [Overlay] as an ancestor because this object
-  /// will display the text selection handles in that [Overlay].
-  final BuildContext context;
-
   void _updateTextSelectionOverlayVisibilities() {
     _effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
     _effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
@@ -406,7 +451,22 @@
   /// {@macro flutter.widgets.SelectionOverlay.showToolbar}
   void showToolbar() {
     _updateSelectionOverlay();
-    _selectionOverlay.showToolbar();
+
+    if (selectionControls is! TextSelectionHandleControls) {
+      _selectionOverlay.showToolbar();
+      return;
+    }
+
+    if (contextMenuBuilder == null) {
+      return;
+    }
+
+    assert(context.mounted);
+    _selectionOverlay.showToolbar(
+      context: context,
+      contextMenuBuilder: contextMenuBuilder,
+    );
+    return;
   }
 
   /// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
@@ -436,8 +496,8 @@
   }
 
   /// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
-  void hideMagnifier({required bool shouldShowToolbar}) {
-    _selectionOverlay.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
+  void hideMagnifier() {
+    _selectionOverlay.hideMagnifier();
   }
 
   /// Updates the overlay after the selection has changed.
@@ -487,7 +547,11 @@
   bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
 
   /// Whether the toolbar is currently visible.
-  bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
+  bool get toolbarIsVisible {
+    return selectionControls is TextSelectionHandleControls
+        ? _selectionOverlay._contextMenuControllerIsShown
+        : _selectionOverlay._toolbar != null;
+  }
 
   /// Whether the magnifier is currently visible.
   bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
@@ -506,6 +570,7 @@
     _effectiveToolbarVisibility.dispose();
     _effectiveStartHandleVisibility.dispose();
     _effectiveEndHandleVisibility.dispose();
+    hideToolbar();
   }
 
   double _getStartGlyphHeight() {
@@ -728,7 +793,25 @@
     _handleSelectionHandleChanged(newSelection, isEnd: false);
   }
 
-  void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
+  void _handleAnyDragEnd(DragEndDetails details) {
+    if (!context.mounted) {
+      return;
+    }
+    if (selectionControls is! TextSelectionHandleControls) {
+      _selectionOverlay.hideMagnifier();
+      if (!_selection.isCollapsed) {
+        _selectionOverlay.showToolbar();
+      }
+      return;
+    }
+    _selectionOverlay.hideMagnifier();
+    if (!_selection.isCollapsed) {
+      _selectionOverlay.showToolbar(
+        context: context,
+        contextMenuBuilder: contextMenuBuilder,
+      );
+    }
+  }
 
   // Returns the offset that locates a drag on a handle to the correct line of text.
   Offset _getOffsetToTextPositionPoint(TextSelectionHandleType type) {
@@ -810,6 +893,10 @@
     this.toolbarVisible,
     required List<TextSelectionPoint> selectionEndpoints,
     required this.selectionControls,
+    @Deprecated(
+      'Use `contextMenuBuilder` in `showToolbar` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
     required this.selectionDelegate,
     required this.clipboardStatus,
     required this.startHandleLayerLink,
@@ -817,6 +904,10 @@
     required this.toolbarLayerLink,
     this.dragStartBehavior = DragStartBehavior.start,
     this.onSelectionHandleTapped,
+    @Deprecated(
+      'Use `contextMenuBuilder` in `showToolbar` instead. '
+      'This feature was deprecated after v3.3.0-0.5.pre.',
+    )
     Offset? toolbarLocation,
     this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
   }) : _startHandleType = startHandleType,
@@ -827,13 +918,9 @@
        _toolbarLocation = toolbarLocation,
        assert(debugCheckHasOverlay(context));
 
-  /// The context in which the selection handles should appear.
-  ///
-  /// This context must have an [Overlay] as an ancestor because this object
-  /// will display the text selection handles in that [Overlay].
+  /// {@macro flutter.widgets.SelectionOverlay.context}
   final BuildContext context;
 
-
   final ValueNotifier<MagnifierInfo> _magnifierInfo =
       ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
 
@@ -863,7 +950,7 @@
   /// [MagnifierController.shown].
   /// {@endtemplate}
   void showMagnifier(MagnifierInfo initalMagnifierInfo) {
-    if (_toolbar != null) {
+    if (_toolbar != null || _contextMenuControllerIsShown) {
       hideToolbar();
     }
 
@@ -892,12 +979,11 @@
   }
 
   /// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
-  /// Hide the current magnifier, optionally immediately showing
-  /// the toolbar.
+  /// Hide the current magnifier.
   ///
   /// This does nothing if there is no magnifier.
   /// {@endtemplate}
-  void hideMagnifier({required bool shouldShowToolbar}) {
+  void hideMagnifier() {
     // This cannot be a check on `MagnifierController.shown`, since
     // it's possible that the magnifier is still in the overlay, but
     // not shown in cases where the magnifier hides itself.
@@ -906,10 +992,6 @@
     }
 
     _magnifierController.hide();
-
-    if (shouldShowToolbar) {
-      showToolbar();
-    }
   }
 
   /// The type of start selection handle.
@@ -1046,7 +1128,11 @@
   /// The delegate for manipulating the current selection in the owning
   /// text field.
   /// {@endtemplate}
-  final TextSelectionDelegate selectionDelegate;
+  @Deprecated(
+    'Use `contextMenuBuilder` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
+  final TextSelectionDelegate? selectionDelegate;
 
   /// Determines the way that drag start behavior is handled.
   ///
@@ -1097,6 +1183,10 @@
   ///
   /// This is useful for displaying toolbars at the mouse right-click locations
   /// in desktop devices.
+  @Deprecated(
+    'Use the `contextMenuBuilder` parameter in `showToolbar` instead. '
+    'This feature was deprecated after v3.3.0-0.5.pre.',
+  )
   Offset? get toolbarLocation => _toolbarLocation;
   Offset? _toolbarLocation;
   set toolbarLocation(Offset? value) {
@@ -1117,6 +1207,11 @@
   /// A copy/paste toolbar.
   OverlayEntry? _toolbar;
 
+  // Manages the context menu. Not necessarily visible when non-null.
+  final ContextMenuController _contextMenuController = ContextMenuController();
+
+  bool get _contextMenuControllerIsShown => _contextMenuController.isShown;
+
   /// {@template flutter.widgets.SelectionOverlay.showHandles}
   /// Builds the handles by inserting them into the [context]'s overlay.
   /// {@endtemplate}
@@ -1147,12 +1242,34 @@
   /// {@template flutter.widgets.SelectionOverlay.showToolbar}
   /// Shows the toolbar by inserting it into the [context]'s overlay.
   /// {@endtemplate}
-  void showToolbar() {
-    if (_toolbar != null) {
+  void showToolbar({
+    BuildContext? context,
+    WidgetBuilder? contextMenuBuilder,
+  }) {
+    if (contextMenuBuilder == null) {
+      if (_toolbar != null) {
+        return;
+      }
+      _toolbar = OverlayEntry(builder: _buildToolbar);
+      Overlay.of(this.context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
       return;
     }
-    _toolbar = OverlayEntry(builder: _buildToolbar);
-    Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
+
+    if (context == null) {
+      return;
+    }
+
+    final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+    _contextMenuController.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return _SelectionToolbarWrapper(
+          layerLink: toolbarLayerLink,
+          offset: -renderBox.localToGlobal(Offset.zero),
+          child: contextMenuBuilder(context),
+        );
+      },
+    );
   }
 
   bool _buildScheduled = false;
@@ -1174,6 +1291,9 @@
           _handles![1].markNeedsBuild();
         }
         _toolbar?.markNeedsBuild();
+        if (_contextMenuController.isShown) {
+          _contextMenuController.markNeedsBuild();
+        }
       });
     } else {
       if (_handles != null) {
@@ -1181,6 +1301,9 @@
         _handles![1].markNeedsBuild();
       }
       _toolbar?.markNeedsBuild();
+      if (_contextMenuController.isShown) {
+        _contextMenuController.markNeedsBuild();
+      }
     }
   }
 
@@ -1194,7 +1317,7 @@
       _handles![1].remove();
       _handles = null;
     }
-    if (_toolbar != null) {
+    if (_toolbar != null || _contextMenuControllerIsShown) {
       hideToolbar();
     }
   }
@@ -1205,6 +1328,7 @@
   /// To hide the whole overlay, see [hide].
   /// {@endtemplate}
   void hideToolbar() {
+    _contextMenuController.remove();
     if (_toolbar == null) {
       return;
     }
@@ -1272,10 +1396,12 @@
     );
   }
 
+  // Build the toolbar via TextSelectionControls.
   Widget _buildToolbar(BuildContext context) {
     if (selectionControls == null) {
       return const SizedBox.shrink();
     }
+    assert(selectionDelegate != null, 'If not using contextMenuBuilder, must pass selectionDelegate.');
 
     final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
 
@@ -1299,21 +1425,23 @@
       selectionEndpoints.first.point.dy - lineHeightAtStart,
     );
 
-    return TextFieldTapRegion(
-      child: Directionality(
-        textDirection: Directionality.of(this.context),
-        child: _SelectionToolbarOverlay(
-          preferredLineHeight: lineHeightAtStart,
-          toolbarLocation: toolbarLocation,
-          layerLink: toolbarLayerLink,
-          editingRegion: editingRegion,
-          selectionControls: selectionControls,
-          midpoint: midpoint,
-          selectionEndpoints: selectionEndpoints,
-          visibility: toolbarVisible,
-          selectionDelegate: selectionDelegate,
-          clipboardStatus: clipboardStatus,
-        ),
+    return _SelectionToolbarWrapper(
+      visibility: toolbarVisible,
+      layerLink: toolbarLayerLink,
+      offset: -editingRegion.topLeft,
+      child: Builder(
+        builder: (BuildContext context) {
+          return selectionControls!.buildToolbar(
+            context,
+            editingRegion,
+            lineHeightAtStart,
+            midpoint,
+            selectionEndpoints,
+            selectionDelegate!,
+            clipboardStatus,
+            toolbarLocation,
+          );
+        },
       ),
     );
   }
@@ -1337,38 +1465,32 @@
   }
 }
 
-/// This widget represents a selection toolbar.
-class _SelectionToolbarOverlay extends StatefulWidget {
-  /// Creates a toolbar overlay.
-  const _SelectionToolbarOverlay({
-    required this.preferredLineHeight,
-    required this.toolbarLocation,
-    required this.layerLink,
-    required this.editingRegion,
-    required this.selectionControls,
+// TODO(justinmc): Currently this fades in but not out on all platforms. It
+// should follow the correct fading behavior for the current platform, then be
+// made public and de-duplicated with widgets/selectable_region.dart.
+// https://github.com/flutter/flutter/issues/107732
+// Wrap the given child in the widgets common to both contextMenuBuilder and
+// TextSelectionControls.buildToolbar.
+class _SelectionToolbarWrapper extends StatefulWidget {
+  const _SelectionToolbarWrapper({
     this.visibility,
-    required this.midpoint,
-    required this.selectionEndpoints,
-    required this.selectionDelegate,
-    required this.clipboardStatus,
-  });
+    required this.layerLink,
+    required this.offset,
+    required this.child,
+  }) : assert(layerLink != null),
+       assert(offset != null),
+       assert(child != null);
 
-  final double preferredLineHeight;
-  final Offset? toolbarLocation;
+  final Widget child;
+  final Offset offset;
   final LayerLink layerLink;
-  final Rect editingRegion;
-  final TextSelectionControls? selectionControls;
   final ValueListenable<bool>? visibility;
-  final Offset midpoint;
-  final List<TextSelectionPoint> selectionEndpoints;
-  final TextSelectionDelegate? selectionDelegate;
-  final ClipboardStatusNotifier? clipboardStatus;
 
   @override
-  _SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
+  State<_SelectionToolbarWrapper> createState() => _SelectionToolbarWrapperState();
 }
 
-class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
+class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper> with SingleTickerProviderStateMixin {
   late AnimationController _controller;
   Animation<double> get _opacity => _controller.view;
 
@@ -1383,7 +1505,7 @@
   }
 
   @override
-  void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
+  void didUpdateWidget(_SelectionToolbarWrapper oldWidget) {
     super.didUpdateWidget(oldWidget);
     if (oldWidget.visibility == widget.visibility) {
       return;
@@ -1410,25 +1532,17 @@
 
   @override
   Widget build(BuildContext context) {
-    return FadeTransition(
-      opacity: _opacity,
-      child: CompositedTransformFollower(
-        link: widget.layerLink,
-        showWhenUnlinked: false,
-        offset: -widget.editingRegion.topLeft,
-        child: Builder(
-          builder: (BuildContext context) {
-            return widget.selectionControls!.buildToolbar(
-              context,
-              widget.editingRegion,
-              widget.preferredLineHeight,
-              widget.midpoint,
-              widget.selectionEndpoints,
-              widget.selectionDelegate!,
-              widget.clipboardStatus,
-              widget.toolbarLocation,
-            );
-          },
+    return TextFieldTapRegion(
+      child: Directionality(
+        textDirection: Directionality.of(this.context),
+        child: FadeTransition(
+          opacity: _opacity,
+          child: CompositedTransformFollower(
+            link: widget.layerLink,
+            showWhenUnlinked: false,
+            offset: widget.offset,
+            child: widget.child,
+          ),
         ),
       ),
     );
@@ -1464,7 +1578,6 @@
 
   @override
   State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState();
-
 }
 
 class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin {
@@ -1813,6 +1926,9 @@
     // trigger the selection overlay.
     // For backwards-compatibility, we treat a null kind the same as touch.
     final PointerDeviceKind? kind = details.kind;
+    // TODO(justinmc): Should a desktop platform show its selection toolbar when
+    // receiving a tap event?  Say a Windows device with a touchscreen.
+    // https://github.com/flutter/flutter/issues/106586
     _shouldShowSelectionToolbar = kind == null
       || kind == PointerDeviceKind.touch
       || kind == PointerDeviceKind.stylus;
@@ -2122,7 +2238,7 @@
     switch (defaultTargetPlatform) {
       case TargetPlatform.android:
       case TargetPlatform.iOS:
-        editableText.hideMagnifier(shouldShowToolbar: false);
+        editableText.hideMagnifier();
         break;
       case TargetPlatform.fuchsia:
       case TargetPlatform.linux:
@@ -2781,3 +2897,47 @@
   /// The content on the clipboard is not pastable, such as when it is empty.
   notPasteable,
 }
+
+/// [TextSelectionControls] that specifically do not manage the toolbar in order
+/// to leave that to [EditableText.contextMenuBuilder].
+@Deprecated(
+  'Use `TextSelectionControls`. '
+  'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+mixin TextSelectionHandleControls on TextSelectionControls {
+  @override
+  Widget buildToolbar(
+    BuildContext context,
+    Rect globalEditableRegion,
+    double textLineHeight,
+    Offset selectionMidpoint,
+    List<TextSelectionPoint> endpoints,
+    TextSelectionDelegate delegate,
+    ValueNotifier<ClipboardStatus>? clipboardStatus,
+    Offset? lastSecondaryTapDownPosition,
+  ) => const SizedBox.shrink();
+
+  @override
+  bool canCut(TextSelectionDelegate delegate) => false;
+
+  @override
+  bool canCopy(TextSelectionDelegate delegate) => false;
+
+  @override
+  bool canPaste(TextSelectionDelegate delegate) => false;
+
+  @override
+  bool canSelectAll(TextSelectionDelegate delegate) => false;
+
+  @override
+  void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
+
+  @override
+  void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
+
+  @override
+  Future<void> handlePaste(TextSelectionDelegate delegate) async {}
+
+  @override
+  void handleSelectAll(TextSelectionDelegate delegate) {}
+}
diff --git a/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
new file mode 100644
index 0000000..9f51264
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
@@ -0,0 +1,71 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+
+/// The position information for a text selection toolbar.
+///
+/// Typically, a menu will attempt to position itself at [primaryAnchor], and
+/// if that's not possible, then it will use [secondaryAnchor] instead, if it
+/// exists.
+///
+/// See also:
+///
+///  * [AdaptiveTextSelectionToolbar.anchors], which is of this type.
+@immutable
+class TextSelectionToolbarAnchors {
+  /// Creates an instance of [TextSelectionToolbarAnchors] directly from the
+  /// anchor points.
+  const TextSelectionToolbarAnchors({
+    required this.primaryAnchor,
+    this.secondaryAnchor,
+  });
+
+  /// Creates an instance of [TextSelectionToolbarAnchors] for some selection.
+  factory TextSelectionToolbarAnchors.fromSelection({
+    required RenderBox renderBox,
+    required double startGlyphHeight,
+    required double endGlyphHeight,
+    required List<TextSelectionPoint> selectionEndpoints,
+  }) {
+    final Rect editingRegion = Rect.fromPoints(
+      renderBox.localToGlobal(Offset.zero),
+      renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
+    );
+    final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
+        endGlyphHeight / 2;
+
+    final Rect selectionRect = Rect.fromLTRB(
+      isMultiline
+          ? editingRegion.left
+          : editingRegion.left + selectionEndpoints.first.point.dx,
+      editingRegion.top + selectionEndpoints.first.point.dy - startGlyphHeight,
+      isMultiline
+          ? editingRegion.right
+          : editingRegion.left + selectionEndpoints.last.point.dx,
+      editingRegion.top + selectionEndpoints.last.point.dy,
+    );
+
+    return TextSelectionToolbarAnchors(
+      primaryAnchor: Offset(
+        selectionRect.left + selectionRect.width / 2,
+        clampDouble(selectionRect.top, editingRegion.top, editingRegion.bottom),
+      ),
+      secondaryAnchor: Offset(
+        selectionRect.left + selectionRect.width / 2,
+        clampDouble(selectionRect.bottom, editingRegion.top, editingRegion.bottom),
+      ),
+    );
+  }
+
+  /// The location that the toolbar should attempt to position itself at.
+  ///
+  /// If the toolbar doesn't fit at this location, use [secondaryAnchor] if it
+  /// exists.
+  final Offset primaryAnchor;
+
+  /// The fallback position that should be used if [primaryAnchor] doesn't work.
+  final Offset? secondaryAnchor;
+}
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart
index cbf8e9b..cf29bdd 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -35,6 +35,8 @@
 export 'src/widgets/bottom_navigation_bar_item.dart';
 export 'src/widgets/color_filter.dart';
 export 'src/widgets/container.dart';
+export 'src/widgets/context_menu_button_item.dart';
+export 'src/widgets/context_menu_controller.dart';
 export 'src/widgets/debug.dart';
 export 'src/widgets/default_selection_style.dart';
 export 'src/widgets/default_text_editing_shortcuts.dart';
@@ -137,6 +139,7 @@
 export 'src/widgets/text.dart';
 export 'src/widgets/text_editing_intents.dart';
 export 'src/widgets/text_selection.dart';
+export 'src/widgets/text_selection_toolbar_anchors.dart';
 export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
 export 'src/widgets/texture.dart';
 export 'src/widgets/ticker_provider.dart';
diff --git a/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..6c150d1
--- /dev/null
+++ b/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart
@@ -0,0 +1,245 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../widgets/clipboard_utils.dart';
+
+void main() {
+  final MockClipboard mockClipboard = MockClipboard();
+
+  setUp(() async {
+    TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+      SystemChannels.platform,
+      mockClipboard.handleMethodCall,
+    );
+    // Fill the clipboard so that the Paste option is available in the text
+    // selection menu.
+    await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+  });
+
+  tearDown(() {
+    TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+      SystemChannels.platform,
+      null,
+    );
+  });
+
+  testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
+    const String buttonText = 'Click me';
+
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: CupertinoAdaptiveTextSelectionToolbar.buttonItems(
+            anchors: const TextSelectionToolbarAnchors(
+              primaryAnchor: Offset.zero,
+            ),
+            buttonItems: <ContextMenuButtonItem>[
+              ContextMenuButtonItem(
+                label: buttonText,
+                onPressed: () {
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text(buttonText), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
+        break;
+    }
+  },
+    variant: TargetPlatformVariant.all(),
+    skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+  );
+
+  testWidgets('Can build children directly as well', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: CupertinoAdaptiveTextSelectionToolbar(
+            anchors: const TextSelectionToolbarAnchors(
+              primaryAnchor: Offset.zero,
+            ),
+            children: <Widget>[
+              Container(key: key),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(key), findsOneWidget);
+  },
+    skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+  );
+
+  testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    await tester.pumpWidget(CupertinoApp(
+      home: Align(
+        alignment: Alignment.topLeft,
+        child: SizedBox(
+          width: 400,
+          child: EditableText(
+            controller: TextEditingController(),
+            backgroundCursorColor: const Color(0xff00ffff),
+            focusNode: FocusNode(),
+            style: const TextStyle(),
+            cursorColor: const Color(0xff00ffff),
+            selectionControls: cupertinoTextSelectionHandleControls,
+            contextMenuBuilder: (
+              BuildContext context,
+              EditableTextState editableTextState,
+            ) {
+              return CupertinoAdaptiveTextSelectionToolbar.editableText(
+                key: key,
+                editableTextState: editableTextState,
+              );
+            },
+          ),
+        ),
+      ),
+    ));
+
+    await tester.pump(); // Wait for autofocus to take effect.
+
+    expect(find.byKey(key), findsNothing);
+
+    // Long-press to bring up the context menu.
+    final Finder textFinder = find.byType(EditableText);
+    await tester.longPress(textFinder);
+    tester.state<EditableTextState>(textFinder).showToolbar();
+    await tester.pumpAndSettle();
+
+    expect(find.byKey(key), findsOneWidget);
+    expect(find.text('Copy'), findsNothing);
+    expect(find.text('Cut'), findsNothing);
+    expect(find.text('Select all'), findsNothing);
+    expect(find.text('Paste'), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+        break;
+    }
+  },
+    skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    variant: TargetPlatformVariant.all(),
+  );
+
+  testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    await tester.pumpWidget(CupertinoApp(
+      home: Center(
+        child: CupertinoAdaptiveTextSelectionToolbar.editable(
+          key: key,
+          anchors: const TextSelectionToolbarAnchors(
+            primaryAnchor: Offset.zero,
+          ),
+          clipboardStatus: ClipboardStatus.pasteable,
+          onCopy: () {},
+          onCut: () {},
+          onPaste: () {},
+          onSelectAll: () {},
+        ),
+      ),
+    ));
+
+    expect(find.byKey(key), findsOneWidget);
+    expect(find.text('Copy'), findsOneWidget);
+    expect(find.text('Cut'), findsOneWidget);
+    expect(find.text('Select All'), findsOneWidget);
+    expect(find.text('Paste'), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
+        break;
+    }
+  },
+    skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    variant: TargetPlatformVariant.all(),
+  );
+
+  testWidgets('Builds the correct button per-platform', (WidgetTester tester) async {
+    const String buttonText = 'Click me';
+
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: Builder(
+            builder: (BuildContext context) {
+              return Column(
+                children: CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons(
+                  context,
+                  <ContextMenuButtonItem>[
+                    ContextMenuButtonItem(
+                      label: buttonText,
+                      onPressed: () {
+                      },
+                    ),
+                  ],
+                ).toList(),
+              );
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text(buttonText), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+        break;
+    }
+  },
+    variant: TargetPlatformVariant.all(),
+  );
+}
diff --git a/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart
new file mode 100644
index 0000000..3a3af2e
--- /dev/null
+++ b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart
@@ -0,0 +1,74 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('can press', (WidgetTester tester) async {
+    bool pressed = false;
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: CupertinoDesktopTextSelectionToolbarButton(
+            child: const Text('Tap me'),
+            onPressed: () {
+              pressed = true;
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(pressed, false);
+
+    await tester.tap(find.byType(CupertinoDesktopTextSelectionToolbarButton));
+    expect(pressed, true);
+  });
+
+  testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: CupertinoDesktopTextSelectionToolbarButton(
+            child: const Text('Tap me'),
+            onPressed: () { },
+          ),
+        ),
+      ),
+    );
+
+    // Original at full opacity.
+    FadeTransition opacity = tester.widget(find.descendant(
+      of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+      matching: find.byType(FadeTransition),
+    ));
+    expect(opacity.opacity.value, 1.0);
+
+    // Make a "down" gesture on the button.
+    final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
+    final TestGesture gesture = await tester.startGesture(center);
+    await tester.pumpAndSettle();
+
+    // Opacity reduces during the down gesture.
+    opacity = tester.widget(find.descendant(
+      of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+      matching: find.byType(FadeTransition),
+    ));
+    expect(opacity.opacity.value, 0.7);
+
+    // Release the down gesture.
+    await gesture.up();
+    await tester.pumpAndSettle();
+
+    // Opacity is back to normal.
+    opacity = tester.widget(find.descendant(
+      of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+      matching: find.byType(FadeTransition),
+    ));
+    expect(opacity.opacity.value, 1.0);
+  });
+}
diff --git a/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..bde5250
--- /dev/null
+++ b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart
@@ -0,0 +1,37 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('positions itself at the anchor', (WidgetTester tester) async {
+    // An arbitrary point on the screen to position at.
+    const Offset anchor = Offset(30.0, 40.0);
+
+    await tester.pumpWidget(
+      CupertinoApp(
+        home: Center(
+          child: CupertinoDesktopTextSelectionToolbar(
+            anchor: anchor,
+            children: <Widget>[
+              CupertinoDesktopTextSelectionToolbarButton(
+                child: const Text('Tap me'),
+                onPressed: () {},
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    expect(
+      tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
+      // Greater than due to padding internal to the toolbar.
+      greaterThan(anchor),
+    );
+  });
+}
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index f646dee..2aedb7b 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -1578,30 +1578,30 @@
       ),
     );
 
-      // Long press to put the cursor after the "w".
-      const int index = 3;
-      await tester.longPressAt(textOffsetToPosition(tester, index));
-      await tester.pump();
-      expect(
-        controller.selection,
-        const TextSelection.collapsed(offset: index),
-      );
+    // Long press to put the cursor after the "w".
+    const int index = 3;
+    await tester.longPressAt(textOffsetToPosition(tester, index));
+    await tester.pump();
+    expect(
+      controller.selection,
+      const TextSelection.collapsed(offset: index),
+    );
 
-      // Double tap on the same location to select the word around the cursor.
-      await tester.tapAt(textOffsetToPosition(tester, index));
-      await tester.pump(const Duration(milliseconds: 50));
-      await tester.tapAt(textOffsetToPosition(tester, index));
-      await tester.pump();
-      expect(
-        controller.selection,
-        const TextSelection(baseOffset: 0, extentOffset: 7),
-      );
+    // Double tap on the same location to select the word around the cursor.
+    await tester.tapAt(textOffsetToPosition(tester, index));
+    await tester.pump(const Duration(milliseconds: 50));
+    await tester.tapAt(textOffsetToPosition(tester, index));
+    await tester.pump();
+    expect(
+      controller.selection,
+      const TextSelection(baseOffset: 0, extentOffset: 7),
+    );
 
-      // Selected text shows 'Copy'.
-      expect(find.text('Paste'), findsNothing);
-      expect(find.text('Copy'), findsOneWidget);
-      expect(find.text('Cut'), findsNothing);
-      expect(find.text('Select All'), findsNothing);
+    // Selected text shows 'Copy'.
+    expect(find.text('Paste'), findsNothing);
+    expect(find.text('Copy'), findsOneWidget);
+    expect(find.text('Cut'), findsNothing);
+    expect(find.text('Select All'), findsNothing);
   },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
     skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
@@ -1633,6 +1633,15 @@
       const TextSelection(baseOffset: 0, extentOffset: 7),
     );
 
+    // Tap elsewhere to hide the context menu so that subsequent taps don't
+    // collide with it.
+    await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
+    await tester.pump();
+    expect(
+      controller.selection,
+      const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
+    );
+
     // Double tap on the same location to select the word around the cursor.
     await tester.tapAt(textOffsetToPosition(tester, 10));
     await tester.pump(const Duration(milliseconds: 50));
@@ -1966,7 +1975,7 @@
   );
 
   testWidgets(
-    'double tap selects word and first tap of double tap moves cursor for non-Apple platforms',
+    'double tap selects word for non-Apple platforms',
     (WidgetTester tester) async {
       final TextEditingController controller = TextEditingController(
         text: 'Atwater Peel Sherbrooke Bonaventure',
@@ -1990,6 +1999,15 @@
         const TextSelection(baseOffset: 0, extentOffset: 7),
       );
 
+      // Tap elsewhere to hide the context menu so that subsequent taps don't
+      // collide with it.
+      await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
+      await tester.pump();
+      expect(
+        controller.selection,
+        const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
+      );
+
       // Double tap in the middle of 'Peel' to select the word.
       await tester.tapAt(textOffsetToPosition(tester, 10));
       await tester.pump(const Duration(milliseconds: 50));
@@ -2001,7 +2019,7 @@
       );
 
       // Selected text shows 3 toolbar buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
 
       // Tap somewhere else to move the cursor.
       await tester.tapAt(textOffsetToPosition(tester, index));
@@ -2012,7 +2030,7 @@
   );
 
   testWidgets(
-    'double tap selects word and first tap of double tap moves cursor for Apple platforms',
+    'double tap selects word for Apple platforms',
     (WidgetTester tester) async {
       final TextEditingController controller = TextEditingController(
         text: 'Atwater Peel Sherbrooke Bonaventure',
@@ -2036,8 +2054,10 @@
         const TextSelection.collapsed(offset: index),
       );
 
-      // Double tap on the same location to select the word around the cursor.
-      await tester.tapAt(textOffsetToPosition(tester, index));
+      // Double tap to select the word around the cursor. Move slightly left of
+      // the previous tap in order to avoid hitting the text selection toolbar
+      // on Mac.
+      await tester.tapAt(textOffsetToPosition(tester, index) - const Offset(1.0, 0.0));
       await tester.pump(const Duration(milliseconds: 50));
       await tester.tapAt(textOffsetToPosition(tester, index));
       await tester.pumpAndSettle();
@@ -2046,8 +2066,22 @@
         const TextSelection(baseOffset: 0, extentOffset: 7),
       );
 
-      // Selected text shows 3 toolbar buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      if (isContextMenuProvidedByPlatform) {
+        expect(find.byType(CupertinoButton), findsNothing);
+      } else {
+        switch (defaultTargetPlatform) {
+          case TargetPlatform.macOS:
+          case TargetPlatform.iOS:
+            expect(find.byType(CupertinoButton), findsNWidgets(3));
+            break;
+          case TargetPlatform.android:
+          case TargetPlatform.fuchsia:
+          case TargetPlatform.linux:
+          case TargetPlatform.windows:
+            expect(find.byType(CupertinoButton), findsNWidgets(4));
+            break;
+        }
+      }
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
   );
@@ -2184,8 +2218,24 @@
         const TextSelection(baseOffset: 8, extentOffset: 12),
       );
 
-      // Selected text shows 3 toolbar buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      final Matcher matchToolbarButtons;
+      if (isContextMenuProvidedByPlatform) {
+        matchToolbarButtons = findsNothing;
+      } else {
+        switch (defaultTargetPlatform) {
+          case TargetPlatform.macOS:
+          case TargetPlatform.iOS:
+            matchToolbarButtons = findsNWidgets(3);
+            break;
+          case TargetPlatform.android:
+          case TargetPlatform.fuchsia:
+          case TargetPlatform.linux:
+          case TargetPlatform.windows:
+            matchToolbarButtons = findsNWidgets(4);
+            break;
+        }
+      }
+      expect(find.byType(CupertinoButton), matchToolbarButtons);
 
       await gesture.up();
       await tester.pump();
@@ -2195,7 +2245,7 @@
         controller.selection,
         const TextSelection(baseOffset: 8, extentOffset: 12),
       );
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      expect(find.byType(CupertinoButton), matchToolbarButtons);
     },
   );
 
@@ -2348,8 +2398,10 @@
     expect(controller.value.selection.baseOffset, 5);
     expect(controller.value.selection.extentOffset, 6);
 
-    // Put the cursor at the end of the field.
-    await tester.tapAt(textOffsetToPosition(tester, 10));
+    // Tap at the end of the text to move the selection to the end. On some
+    // platforms, the context menu "Cut" button blocks this tap, so move it out
+    // of the way by an Offset.
+    await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
     expect(controller.value.selection, isNotNull);
     expect(controller.value.selection.baseOffset, 10);
     expect(controller.value.selection.extentOffset, 10);
@@ -2646,8 +2698,8 @@
         const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream),
       );
 
-      // Non-Collapsed toolbar shows 3 buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      // Non-Collapsed toolbar shows 4 buttons.
+      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
     },
     variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
   );
@@ -2680,7 +2732,14 @@
       );
 
       // Collapsed toolbar shows 2 buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      expect(
+        find.byType(CupertinoButton),
+        isContextMenuProvidedByPlatform
+            ? findsNothing
+            : defaultTargetPlatform == TargetPlatform.iOS
+                ? findsNWidgets(2)
+                : findsNWidgets(1),
+      );
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
   );
@@ -2706,7 +2765,21 @@
       await tester.longPressAt(ePos);
       await tester.pump(const Duration(milliseconds: 50));
 
-      await tester.tapAt(ePos);
+      final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
+      if (kIsWeb) {
+        expect(find.byType(CupertinoButton), findsNothing);
+      } else {
+        expect(find.byType(CupertinoButton), findsNWidgets(isTargetPlatformMobile ? 2 : 1));
+      }
+      expect(controller.selection.isCollapsed, isTrue);
+      expect(controller.selection.baseOffset, 6);
+
+      // Tap in a slightly different position to avoid hitting the context menu
+      // on desktop.
+      final Offset secondTapPos = isTargetPlatformMobile
+          ? ePos
+          : ePos + const Offset(-1.0, 0.0);
+      await tester.tapAt(secondTapPos);
       await tester.pump();
 
       // The cursor does not move and the toolbar is toggled.
@@ -2776,7 +2849,7 @@
         const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream),
       );
       // The toolbar now shows up.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
     },
     variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
   );
@@ -2840,7 +2913,14 @@
         const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream),
       );
       // The toolbar now shows up.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      expect(
+        find.byType(CupertinoButton),
+        isContextMenuProvidedByPlatform
+            ? findsNothing
+            : defaultTargetPlatform == TargetPlatform.iOS
+                ? findsNWidgets(2)
+                : findsNWidgets(1),
+      );
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
   );
@@ -3001,7 +3081,16 @@
       const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
     );
     // The toolbar now shows up.
-    expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+    final int toolbarButtons;
+    if (isContextMenuProvidedByPlatform) {
+      toolbarButtons = 0;
+    } else if (defaultTargetPlatform == TargetPlatform.iOS) {
+      toolbarButtons = 2;
+    } else {
+      // MacOS has no 'Select all' button.
+      toolbarButtons = 1;
+    }
+    expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
 
     lastCharEndpoint = renderEditable.getEndpointsForSelection(
       const TextSelection.collapsed(offset: 66), // Last character's position.
@@ -3059,7 +3148,16 @@
       );
 
       // Long press toolbar.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      final int toolbarButtons;
+      if (isContextMenuProvidedByPlatform) {
+        toolbarButtons = 0;
+      } else if (defaultTargetPlatform == TargetPlatform.iOS) {
+        toolbarButtons = 2;
+      } else {
+        // MacOS has no 'Select all' button.
+        toolbarButtons = 1;
+      }
+      expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
   }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
 
   testWidgets(
@@ -3081,17 +3179,30 @@
         ),
       );
 
-      final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
+      // Use a position higher than wPos to avoid tapping the context menu on
+      // desktop.
+      final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
       final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
 
       await tester.longPressAt(wPos);
       await tester.pump(const Duration(milliseconds: 50));
 
+      expect(controller.selection.isCollapsed, isTrue);
+      expect(controller.selection.baseOffset, 3);
+      if (isContextMenuProvidedByPlatform) {
+        expect(find.byType(CupertinoButton), findsNothing);
+      } else {
+        expect(find.byType(CupertinoButton), isTargetPlatformMobile ? findsNWidgets(2) : findsNWidgets(1));
+      }
+
       await tester.tapAt(pPos);
       await tester.pump(const Duration(milliseconds: 50));
+
       // First tap moved the cursor.
+      expect(find.byType(CupertinoButton), findsNothing);
       expect(controller.selection.isCollapsed, isTrue);
       expect(controller.selection.baseOffset, isTargetPlatformMobile ? 8 : 9);
+
       await tester.tapAt(pPos);
       await tester.pumpAndSettle();
 
@@ -3208,7 +3319,24 @@
     await gesture.up();
     await tester.pumpAndSettle();
     // Shows toolbar.
-    expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+    final Matcher matchToolbarButtons;
+    if (isContextMenuProvidedByPlatform) {
+      matchToolbarButtons = findsNothing;
+    } else {
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.macOS:
+        case TargetPlatform.iOS:
+          matchToolbarButtons = findsNWidgets(3);
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          matchToolbarButtons = findsNWidgets(4);
+          break;
+      }
+    }
+    expect(find.byType(CupertinoButton), matchToolbarButtons);
   });
 
   testWidgets('force press on unsupported devices falls back to tap', (WidgetTester tester) async {
@@ -3295,6 +3423,11 @@
     );
     expect(endpoints.length, 2);
 
+    // On Mac, the toolbar blocks the drag on the right handle, so hide it.
+    final EditableTextState editableTextState = tester.state(find.byType(EditableText));
+    editableTextState.hideToolbar(false);
+    await tester.pumpAndSettle();
+
     // Drag the right handle until there's only 1 char selected.
     // We use a small offset because the endpoint is on the very corner
     // of the handle.
@@ -6269,6 +6402,9 @@
                 key: key1,
                 focusNode: focusNode1,
               ),
+              // This spacer prevents the context menu in one field from
+              // overlapping with the other field.
+              const SizedBox(height: 100.0),
               CupertinoTextField(
                 key: key2,
                 focusNode: focusNode2,
@@ -6406,6 +6542,75 @@
     }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
   });
 
+  group('context menu', () {
+    testWidgets('builds CupertinoAdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
+      final TextEditingController controller = TextEditingController(text: '');
+      await tester.pumpWidget(
+        CupertinoApp(
+          home: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: <Widget>[
+              CupertinoTextField(
+                controller: controller,
+              ),
+            ],
+          ),
+        ),
+      );
+
+      await tester.pump(); // Wait for autofocus to take effect.
+
+      expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
+
+      // Long-press to bring up the context menu.
+      final Finder textFinder = find.byType(EditableText);
+      await tester.longPress(textFinder);
+      tester.state<EditableTextState>(textFinder).showToolbar();
+      await tester.pumpAndSettle();
+
+      expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget);
+    },
+      skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    );
+
+    testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+      final GlobalKey key = GlobalKey();
+      final TextEditingController controller = TextEditingController(text: '');
+      await tester.pumpWidget(
+        CupertinoApp(
+          home: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: <Widget>[
+              CupertinoTextField(
+                controller: controller,
+                contextMenuBuilder: (
+                  BuildContext context,
+                  EditableTextState editableTextState,
+                ) {
+                  return Placeholder(key: key);
+                },
+              ),
+            ],
+          ),
+        ),
+      );
+
+      await tester.pump(); // Wait for autofocus to take effect.
+
+      expect(find.byKey(key), findsNothing);
+
+      // Long-press to bring up the context menu.
+      final Finder textFinder = find.byType(EditableText);
+      await tester.longPress(textFinder);
+      tester.state<EditableTextState>(textFinder).showToolbar();
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(key), findsOneWidget);
+    },
+      skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    );
+  });
+
   group('magnifier', () {
     late ValueNotifier<MagnifierInfo> magnifierInfo;
     final Widget fakeMagnifier = Container(key: UniqueKey());
@@ -6500,7 +6705,6 @@
         ),
       );
 
-
       const String testValue = 'abc def ghi';
       await tester.enterText(find.byType(CupertinoTextField), testValue);
 
@@ -6662,6 +6866,7 @@
       await tester.pumpAndSettle();
       expect(find.byKey(fakeMagnifier.key!), findsNothing);
     }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
+  });
 
   group('TapRegion integration', () {
     testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@@ -6776,34 +6981,33 @@
       skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser.
     );
 
-      testWidgets("Tapping on border doesn't lose focus",
-          (WidgetTester tester) async {
-        final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
-        await tester.pumpWidget(
-          CupertinoApp(
-            home: Center(
-              child: SizedBox(
-                width: 100,
-                height: 100,
-                child: CupertinoTextField(
-                  autofocus: true,
-                  focusNode: focusNode,
-                ),
+    testWidgets("Tapping on border doesn't lose focus",
+        (WidgetTester tester) async {
+      final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
+      await tester.pumpWidget(
+        CupertinoApp(
+          home: Center(
+            child: SizedBox(
+              width: 100,
+              height: 100,
+              child: CupertinoTextField(
+                autofocus: true,
+                focusNode: focusNode,
               ),
             ),
           ),
-        );
-        await tester.pump();
-        expect(focusNode.hasPrimaryFocus, isTrue);
+        ),
+      );
+      await tester.pump();
+      expect(focusNode.hasPrimaryFocus, isTrue);
 
-        final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
-        // Tap just inside the border, but not inside the EditableText.
-        await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
-        await tester.pump();
+      final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
+      // Tap just inside the border, but not inside the EditableText.
+      await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
+      await tester.pump();
 
-        expect(focusNode.hasPrimaryFocus, isTrue);
-      }, variant: TargetPlatformVariant.all());
-    });
+      expect(focusNode.hasPrimaryFocus, isTrue);
+    }, variant: TargetPlatformVariant.all());
   });
 
   testWidgets('Can drag handles to change selection correctly in multiline', (WidgetTester tester) async {
diff --git a/packages/flutter/test/cupertino/text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
index 9a46e76..5052d1e 100644
--- a/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
+++ b/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
@@ -23,7 +23,7 @@
     Offset selectionMidpoint,
     List<TextSelectionPoint> endpoints,
     TextSelectionDelegate delegate,
-    ClipboardStatusNotifier? clipboardStatus,
+    ValueNotifier<ClipboardStatus>? clipboardStatus,
     Offset? lastSecondaryTapDownPosition,
   ) {
     final MediaQueryData mediaQuery = MediaQuery.of(context);
diff --git a/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart b/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..067de6e
--- /dev/null
+++ b/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart
@@ -0,0 +1,393 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../widgets/clipboard_utils.dart';
+import '../widgets/editable_text_utils.dart';
+
+void main() {
+  final MockClipboard mockClipboard = MockClipboard();
+
+  setUp(() async {
+    TestWidgetsFlutterBinding.ensureInitialized();
+    TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+      SystemChannels.platform,
+      mockClipboard.handleMethodCall,
+    );
+    // Fill the clipboard so that the Paste option is available in the text
+    // selection menu.
+    await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+  });
+
+  testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
+    const String buttonText = 'Click me';
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: AdaptiveTextSelectionToolbar.buttonItems(
+              anchors: const TextSelectionToolbarAnchors(
+                primaryAnchor: Offset.zero,
+              ),
+              buttonItems: <ContextMenuButtonItem>[
+                ContextMenuButtonItem(
+                  label: buttonText,
+                  onPressed: () {
+                  },
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text(buttonText), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+        expect(find.byType(TextSelectionToolbar), findsOneWidget);
+        expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+        expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+        break;
+      case TargetPlatform.iOS:
+        expect(find.byType(TextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
+        expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+        break;
+      case TargetPlatform.macOS:
+        expect(find.byType(TextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+        expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
+        break;
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(TextSelectionToolbar), findsNothing);
+        expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+        expect(find.byType(DesktopTextSelectionToolbar), findsOneWidget);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+        break;
+    }
+  },
+    variant: TargetPlatformVariant.all(),
+    skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+  );
+
+  testWidgets('Can build children directly as well', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: AdaptiveTextSelectionToolbar(
+              anchors: const TextSelectionToolbarAnchors(
+                primaryAnchor: Offset.zero,
+              ),
+              children: <Widget>[
+                Container(key: key),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(key), findsOneWidget);
+  });
+
+  testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: SizedBox(
+              width: 400,
+              child: EditableText(
+                controller: TextEditingController(),
+                backgroundCursorColor: const Color(0xff00ffff),
+                focusNode: FocusNode(),
+                style: const TextStyle(),
+                cursorColor: const Color(0xff00ffff),
+                selectionControls: materialTextSelectionHandleControls,
+                contextMenuBuilder: (
+                  BuildContext context,
+                  EditableTextState editableTextState,
+                ) {
+                  return AdaptiveTextSelectionToolbar.editableText(
+                    key: key,
+                    editableTextState: editableTextState,
+                  );
+                },
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    await tester.pump(); // Wait for autofocus to take effect.
+
+    expect(find.byKey(key), findsNothing);
+
+    // Long-press to bring up the context menu.
+    final Finder textFinder = find.byType(EditableText);
+    await tester.longPress(textFinder);
+    tester.state<EditableTextState>(textFinder).showToolbar();
+    await tester.pumpAndSettle();
+
+    expect(find.byKey(key), findsOneWidget);
+    expect(find.text('Copy'), findsNothing);
+    expect(find.text('Cut'), findsNothing);
+    expect(find.text('Select all'), findsNothing);
+    expect(find.text('Paste'), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+        expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
+        break;
+      case TargetPlatform.iOS:
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+        break;
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
+        break;
+      case TargetPlatform.macOS:
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+        break;
+    }
+  },
+    skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    variant: TargetPlatformVariant.all(),
+  );
+
+  testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Center(
+            child: AdaptiveTextSelectionToolbar.editable(
+              key: key,
+              anchors: const TextSelectionToolbarAnchors(
+                primaryAnchor: Offset.zero,
+              ),
+              clipboardStatus: ClipboardStatus.pasteable,
+              onCopy: () {},
+              onCut: () {},
+              onPaste: () {},
+              onSelectAll: () {},
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(key), findsOneWidget);
+    expect(find.text('Copy'), findsOneWidget);
+    expect(find.text('Cut'), findsOneWidget);
+    expect(find.text('Paste'), findsOneWidget);
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+        expect(find.text('Select all'), findsOneWidget);
+        expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(4));
+        break;
+      case TargetPlatform.iOS:
+        expect(find.text('Select All'), findsOneWidget);
+        expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
+        break;
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        expect(find.text('Select all'), findsOneWidget);
+        expect(find.byType(DesktopTextSelectionToolbarButton), findsNWidgets(4));
+        break;
+      case TargetPlatform.macOS:
+        expect(find.text('Select All'), findsOneWidget);
+        expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
+        break;
+    }
+  },
+    skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    variant: TargetPlatformVariant.all(),
+  );
+
+  group('buttonItems', () {
+    testWidgets('getEditableTextButtonItems builds the correct button items per-platform', (WidgetTester tester) async {
+      // Fill the clipboard so that the Paste option is available in the text
+      // selection menu.
+      await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+
+      Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
+      final TextEditingController controller = TextEditingController();
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: Center(
+              child: EditableText(
+                controller: controller,
+                backgroundCursorColor: Colors.grey,
+                focusNode: FocusNode(),
+                style: const TextStyle(),
+                cursorColor: Colors.red,
+                selectionControls: materialTextSelectionHandleControls,
+                contextMenuBuilder: (
+                  BuildContext context,
+                  EditableTextState editableTextState,
+                ) {
+                  buttonTypes = editableTextState.contextMenuButtonItems
+                    .map((ContextMenuButtonItem buttonItem) => buttonItem.type)
+                    .toSet();
+                  return const SizedBox.shrink();
+                },
+              ),
+            ),
+          ),
+        ),
+      );
+
+      final EditableTextState state =
+          tester.state<EditableTextState>(find.byType(EditableText));
+
+      // With no text in the field.
+      await tester.tapAt(textOffsetToPosition(tester, 0));
+      await tester.pump();
+      expect(state.showToolbar(), true);
+      await tester.pump();
+
+      expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
+      expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
+      expect(buttonTypes, contains(ContextMenuButtonType.paste));
+      expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+
+      // With text but no selection.
+      controller.text = 'lorem ipsum';
+      await tester.pump();
+
+      expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
+      expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
+      expect(buttonTypes, contains(ContextMenuButtonType.paste));
+
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
+        case TargetPlatform.iOS:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+          break;
+        case TargetPlatform.macOS:
+          expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+          break;
+      }
+
+      // With text and selection.
+      controller.value = controller.value.copyWith(
+        selection: const TextSelection(
+          baseOffset: 0,
+          extentOffset: 'lorem'.length,
+        ),
+      );
+      await tester.pump();
+
+      expect(buttonTypes, contains(ContextMenuButtonType.cut));
+      expect(buttonTypes, contains(ContextMenuButtonType.copy));
+      expect(buttonTypes, contains(ContextMenuButtonType.paste));
+
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+          break;
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+          break;
+      }
+    },
+      variant: TargetPlatformVariant.all(),
+      skip: kIsWeb, // [intended]
+    );
+
+    testWidgets('getAdaptiveButtons builds the correct button widgets per-platform', (WidgetTester tester) async {
+      const String buttonText = 'Click me';
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: Center(
+              child: Builder(
+                builder: (BuildContext context) {
+                  final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[
+                    ContextMenuButtonItem(
+                      label: buttonText,
+                      onPressed: () {
+                      },
+                    ),
+                  ];
+                  return ListView(
+                    children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
+                      context,
+                      buttonItems,
+                    ).toList(),
+                  );
+                },
+              ),
+            ),
+          ),
+        ),
+      );
+
+      expect(find.text(buttonText), findsOneWidget);
+
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.android:
+          expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
+          expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+          break;
+        case TargetPlatform.iOS:
+          expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+          expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+          expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+          break;
+        case TargetPlatform.macOS:
+          expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+          expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+          break;
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+          expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+          expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
+          expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+          break;
+      }
+    },
+      variant: TargetPlatformVariant.all(),
+    );
+  });
+}
diff --git a/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart b/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart
new file mode 100644
index 0000000..41350ab
--- /dev/null
+++ b/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart
@@ -0,0 +1,31 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('can press', (WidgetTester tester) async {
+    bool pressed = false;
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Center(
+          child: DesktopTextSelectionToolbarButton(
+            child: const Text('Tap me'),
+            onPressed: () {
+              pressed = true;
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(pressed, false);
+
+    await tester.tap(find.byType(DesktopTextSelectionToolbarButton));
+    expect(pressed, true);
+  });
+}
diff --git a/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart b/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..3052356
--- /dev/null
+++ b/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart
@@ -0,0 +1,36 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('positions itself at the anchor', (WidgetTester tester) async {
+    // An arbitrary point on the screen to position at.
+    const Offset anchor = Offset(30.0, 40.0);
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Center(
+          child: DesktopTextSelectionToolbar(
+            anchor: anchor,
+            children: <Widget>[
+              DesktopTextSelectionToolbarButton(
+                child: const Text('Tap me'),
+                onPressed: () {},
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+
+    expect(
+      tester.getTopLeft(find.byType(DesktopTextSelectionToolbarButton)),
+      anchor,
+    );
+  });
+}
diff --git a/packages/flutter/test/material/selection_area_test.dart b/packages/flutter/test/material/selection_area_test.dart
index 4c697af..0310c16 100644
--- a/packages/flutter/test/material/selection_area_test.dart
+++ b/packages/flutter/test/material/selection_area_test.dart
@@ -27,21 +27,82 @@
     switch (defaultTargetPlatform) {
       case TargetPlatform.android:
       case TargetPlatform.fuchsia:
-        expect(region.selectionControls, materialTextSelectionControls);
+        expect(region.selectionControls, materialTextSelectionHandleControls);
         break;
       case TargetPlatform.iOS:
-        expect(region.selectionControls, cupertinoTextSelectionControls);
+        expect(region.selectionControls, cupertinoTextSelectionHandleControls);
         break;
       case TargetPlatform.linux:
       case TargetPlatform.windows:
-        expect(region.selectionControls, desktopTextSelectionControls);
+        expect(region.selectionControls, desktopTextSelectionHandleControls);
         break;
       case TargetPlatform.macOS:
-        expect(region.selectionControls, cupertinoDesktopTextSelectionControls);
+        expect(region.selectionControls, cupertinoDesktopTextSelectionHandleControls);
         break;
     }
   }, variant: TargetPlatformVariant.all());
 
+  testWidgets('builds the default context menu by default', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        home: SelectionArea(
+          focusNode: FocusNode(),
+          child: const Text('How are you?'),
+        ),
+      ),
+    );
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    // Show the toolbar by longpressing.
+    final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+    final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+    addTearDown(gesture.removePointer);
+    await tester.pump(const Duration(milliseconds: 500));
+    // `are` is selected.
+    expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+  },
+    skip: kIsWeb, // [intended]
+  );
+
+  testWidgets('builds a custom context menu if provided', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    await tester.pumpWidget(
+      MaterialApp(
+        home: SelectionArea(
+          focusNode: FocusNode(),
+          contextMenuBuilder: (
+            BuildContext context,
+            SelectableRegionState selectableRegionState,
+          ) {
+            return Placeholder(key: key);
+          },
+          child: const Text('How are you?'),
+        ),
+      ),
+    );
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+    expect(find.byKey(key), findsNothing);
+
+    // Show the toolbar by longpressing.
+    final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+    final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+    addTearDown(gesture.removePointer);
+    await tester.pump(const Duration(milliseconds: 500));
+    // `are` is selected.
+    expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+    await tester.pumpAndSettle();
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+    expect(find.byKey(key), findsOneWidget);
+  },
+    skip: kIsWeb, // [intended]
+  );
+
   testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
     SelectedContent? content;
 
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 0b0fd27..cf07039 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -24,15 +24,12 @@
 import 'package:flutter_test/flutter_test.dart';
 
 import '../widgets/clipboard_utils.dart';
-import '../widgets/editable_text_utils.dart' show OverflowWidgetTextEditingController, findRenderEditable, globalize, textOffsetToPosition;
+import '../widgets/editable_text_utils.dart';
 import '../widgets/semantics_tester.dart';
 import 'feedback_tester.dart';
 
 typedef FormatEditUpdateCallback = void Function(TextEditingValue, TextEditingValue);
 
-// On web, the context menu (aka toolbar) is provided by the browser.
-const bool isContextMenuProvidedByPlatform = isBrowser;
-
 // On web, key events in text fields are handled by the browser.
 const bool areKeyEventsHandledByPlatform = isBrowser;
 
@@ -1160,8 +1157,9 @@
     expect(controller.selection.baseOffset, 0);
     expect(controller.selection.extentOffset, 7);
 
-    // Use toolbar to select all text.
-    if (isContextMenuProvidedByPlatform) {
+    // Select all text.  Use the toolbar if possible. iOS only shows the toolbar
+    // when the selection is collapsed.
+    if (isContextMenuProvidedByPlatform || defaultTargetPlatform == TargetPlatform.iOS) {
       controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
       expect(controller.selection.extentOffset, controller.text.length);
     } else {
@@ -2914,6 +2912,7 @@
     await tester.pump();
 
     // Toolbar should fade in. Starting at 0% opacity.
+    expect(find.text('Select all'), findsOneWidget);
     final Element target = tester.element(find.text('Select all'));
     final FadeTransition opacity = target.findAncestorWidgetOfExactType<FadeTransition>()!;
     expect(opacity.opacity.value, equals(0.0));
@@ -8474,7 +8473,11 @@
       );
 
       // Collapsed toolbar shows 2 buttons.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+      expect(
+        find.byType(CupertinoButton),
+        isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+      );
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
   );
@@ -8535,7 +8538,14 @@
 
       await tester.longPressAt(ePos);
       await tester.pump(const Duration(milliseconds: 50));
-      await tester.tapAt(ePos);
+
+      // Tap slightly behind the previous tap to avoid tapping the context menu
+      // on desktop.
+      final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
+      final Offset secondTapPos = isTargetPlatformMobile
+          ? ePos
+          : ePos + const Offset(-1.0, 0.0);
+      await tester.tapAt(secondTapPos);
       await tester.pump();
 
       // The cursor does not move and the toolbar is toggled.
@@ -8689,7 +8699,11 @@
         const TextSelection.collapsed(offset: 9),
       );
       // The toolbar now shows up.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+      expect(
+        find.byType(CupertinoButton),
+        isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+      );
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
   );
@@ -8852,7 +8866,11 @@
       const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
     );
     // The toolbar now shows up.
-    expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+    final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+    expect(
+      find.byType(CupertinoButton),
+      isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+    );
 
     lastCharEndpoint = renderEditable.getEndpointsForSelection(
       const TextSelection.collapsed(offset: 66), // Last character's position.
@@ -9038,7 +9056,6 @@
     // Start long pressing on the first line.
     final TestGesture gesture =
         await tester.startGesture(textOffsetToPosition(tester, 19));
-    // TODO(justinmc): Make sure you've got all things torn down.
     await tester.pump(const Duration(milliseconds: 500));
     expect(
       controller.selection,
@@ -9281,7 +9298,11 @@
       );
 
       // Long press toolbar.
-      expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+      final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+      expect(
+        find.byType(CupertinoButton),
+        isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+      );
     },
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }),
   );
@@ -9307,7 +9328,9 @@
         ),
       );
 
-      final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
+      // The second tap is slightly higher to avoid tapping the context menu on
+      // desktop.
+      final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
       final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
 
       await tester.longPressAt(wPos);
@@ -9662,8 +9685,10 @@
     expect(controller.value.selection.baseOffset, 5);
     expect(controller.value.selection.extentOffset, 6);
 
-    // Put the cursor at the end of the field.
-    await tester.tapAt(textOffsetToPosition(tester, 10));
+    // Tap at the end of the text to move the selection to the end. On some
+    // platforms, the context menu "Cut" button blocks this tap, so move it out
+    // of the way by an Offset.
+    await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
     expect(controller.value.selection, isNotNull);
     expect(controller.value.selection.baseOffset, 10);
     expect(controller.value.selection.extentOffset, 10);
@@ -10679,10 +10704,12 @@
     // Tap the handle to show the toolbar.
     final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0);
     await tester.tapAt(handlePos, pointer: 7);
+    await tester.pump();
     expect(editableText.selectionOverlay!.toolbarIsVisible, isContextMenuProvidedByPlatform ? isFalse : isTrue);
 
     // Tap the handle again to hide the toolbar.
     await tester.tapAt(handlePos, pointer: 7);
+    await tester.pump();
     expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse);
   });
 
@@ -11405,7 +11432,8 @@
     expect(find.text(selectAll), findsNothing);
     expect(find.text('Copy'), findsNothing);
   },
-    variant: TargetPlatformVariant.desktop(),
+    // All desktop platforms except MacOS, which has no select all button.
+    variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.windows }),
     skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
   );
 
@@ -12455,6 +12483,7 @@
                   key: key1,
                   focusNode: focusNode1,
                 ),
+                const SizedBox(height: 100.0),
                 TextField(
                   key: key2,
                   focusNode: focusNode2,
@@ -12594,6 +12623,77 @@
     }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
   });
 
+  group('context menu', () {
+    testWidgets('builds AdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
+      final TextEditingController controller = TextEditingController(text: '');
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: Column(
+              children: <Widget>[
+                TextField(
+                  controller: controller,
+                ),
+              ],
+            ),
+          ),
+        ),
+      );
+
+      await tester.pump(); // Wait for autofocus to take effect.
+
+      expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+      // Long-press to bring up the context menu.
+      final Finder textFinder = find.byType(EditableText);
+      await tester.longPress(textFinder);
+      tester.state<EditableTextState>(textFinder).showToolbar();
+      await tester.pumpAndSettle();
+
+      expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+    },
+      skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    );
+
+    testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+      final GlobalKey key = GlobalKey();
+      final TextEditingController controller = TextEditingController(text: '');
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: Column(
+              children: <Widget>[
+                TextField(
+                  controller: controller,
+                  contextMenuBuilder: (
+                    BuildContext context,
+                    EditableTextState editableTextState,
+                  ) {
+                    return Placeholder(key: key);
+                  },
+                ),
+              ],
+            ),
+          ),
+        ),
+      );
+
+      await tester.pump(); // Wait for autofocus to take effect.
+
+      expect(find.byKey(key), findsNothing);
+
+      // Long-press to bring up the context menu.
+      final Finder textFinder = find.byType(EditableText);
+      await tester.longPress(textFinder);
+      tester.state<EditableTextState>(textFinder).showToolbar();
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(key), findsOneWidget);
+    },
+      skip: kIsWeb, // [intended] on web the browser handles the context menu.
+    );
+  });
+
   group('magnifier builder', () {
     testWidgets('should build custom magnifier if given',
         (WidgetTester tester) async {
@@ -12816,6 +12916,7 @@
       await tester.pumpAndSettle();
       expect(find.byKey(fakeMagnifier.key!), findsNothing);
     }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }));
+  });
 
   group('TapRegion integration', () {
     testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@@ -12966,6 +13067,7 @@
           ),
         ),
       );
+
       await tester.pump();
       expect(focusNode.hasPrimaryFocus, isTrue);
 
@@ -13033,10 +13135,9 @@
           case PointerDeviceKind.unknown:
             expect(focusNode.hasPrimaryFocus, isFalse);
             break;
-          }
-        }, variant: TargetPlatformVariant.all());
-      }
-    });
+        }
+      }, variant: TargetPlatformVariant.all());
+    }
   });
 }
 
diff --git a/packages/flutter/test/material/text_selection_test.dart b/packages/flutter/test/material/text_selection_test.dart
index faf88de..230ca07 100644
--- a/packages/flutter/test/material/text_selection_test.dart
+++ b/packages/flutter/test/material/text_selection_test.dart
@@ -611,7 +611,6 @@
         expect(find.text('Select all'), findsNothing);
         expect(find.byType(IconButton), findsNothing);
 
-
         // The menu appears at the top of the visible selection.
         final Offset selectionOffset = tester
             .getTopLeft(find.byType(TextSelectionToolbarTextButton).first);
diff --git a/packages/flutter/test/material/text_selection_toolbar_test.dart b/packages/flutter/test/material/text_selection_toolbar_test.dart
index 8ab72e6..0274d1c 100644
--- a/packages/flutter/test/material/text_selection_toolbar_test.dart
+++ b/packages/flutter/test/material/text_selection_toolbar_test.dart
@@ -8,11 +8,12 @@
 
 import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
 
+const double _kHandleSize = 22.0;
+const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
+const double _kToolbarContentDistance = 8.0;
+
 // A custom text selection menu that just displays a single custom button.
 class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
-  static const double _kToolbarContentDistanceBelow = 20.0;
-  static const double _kToolbarContentDistance = 8.0;
-
   @override
   Widget buildToolbar(
     BuildContext context,
@@ -154,23 +155,23 @@
     // When the toolbar doesn't fit above aboveAnchor, it positions itself below
     // belowAnchor.
     double toolbarY = tester.getTopLeft(findToolbar()).dy;
-    expect(toolbarY, equals(anchorBelowY));
+    expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
 
     // Even when it barely doesn't fit.
     setState(() {
-      anchorAboveY = 50.0;
-    });
-    await tester.pump();
-    toolbarY = tester.getTopLeft(findToolbar()).dy;
-    expect(toolbarY, equals(anchorBelowY));
-
-    // When it does fit above aboveAnchor, it positions itself there.
-    setState(() {
       anchorAboveY = 60.0;
     });
     await tester.pump();
     toolbarY = tester.getTopLeft(findToolbar()).dy;
-    expect(toolbarY, equals(anchorAboveY - height));
+    expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
+
+    // When it does fit above aboveAnchor, it positions itself there.
+    setState(() {
+      anchorAboveY = 70.0;
+    });
+    await tester.pump();
+    toolbarY = tester.getTopLeft(findToolbar()).dy;
+    expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance));
   });
 
   testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
diff --git a/packages/flutter/test/widgets/context_menu_controller_test.dart b/packages/flutter/test/widgets/context_menu_controller_test.dart
new file mode 100644
index 0000000..1a61558
--- /dev/null
+++ b/packages/flutter/test/widgets/context_menu_controller_test.dart
@@ -0,0 +1,263 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'clipboard_utils.dart';
+import 'editable_text_utils.dart';
+
+void main() {
+  final MockClipboard mockClipboard = MockClipboard();
+  TestWidgetsFlutterBinding.ensureInitialized()
+    .defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
+
+  setUp(() async {
+    // Fill the clipboard so that the Paste option is available in the text
+    // selection menu.
+    await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+  });
+
+  testWidgets('Hides and shows only a single menu', (WidgetTester tester) async {
+    final GlobalKey key1 = GlobalKey();
+    final GlobalKey key2 = GlobalKey();
+    late final BuildContext context;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Builder(
+            builder: (BuildContext localContext) {
+              context = localContext;
+              return const SizedBox.shrink();
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(key1), findsNothing);
+    expect(find.byKey(key2), findsNothing);
+
+    final ContextMenuController controller1 = ContextMenuController();
+    await tester.pump();
+    expect(find.byKey(key1), findsNothing);
+    expect(find.byKey(key2), findsNothing);
+
+    controller1.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: key1);
+      },
+    );
+    await tester.pump();
+
+    expect(find.byKey(key1), findsOneWidget);
+    expect(find.byKey(key2), findsNothing);
+
+    // Showing the same thing again does nothing and is not an error.
+    controller1.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: key1);
+      },
+    );
+    await tester.pump();
+
+    expect(tester.takeException(), null);
+    expect(find.byKey(key1), findsOneWidget);
+    expect(find.byKey(key2), findsNothing);
+
+    // Showing a new menu hides the first.
+    final ContextMenuController controller2 = ContextMenuController();
+    controller2.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: key2);
+      },
+    );
+    await tester.pump();
+
+    expect(find.byKey(key1), findsNothing);
+    expect(find.byKey(key2), findsOneWidget);
+
+    controller2.remove();
+    await tester.pump();
+
+    expect(find.byKey(key1), findsNothing);
+    expect(find.byKey(key2), findsNothing);
+  });
+
+  testWidgets('A menu can be hidden and then reshown', (WidgetTester tester) async {
+    final GlobalKey key1 = GlobalKey();
+    late final BuildContext context;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Builder(
+            builder: (BuildContext localContext) {
+              context = localContext;
+              return const SizedBox.shrink();
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(key1), findsNothing);
+
+    final ContextMenuController controller = ContextMenuController();
+
+    // Instantiating the controller does not shown it.
+    await tester.pump();
+    expect(find.byKey(key1), findsNothing);
+
+    controller.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: key1);
+      },
+    );
+    await tester.pump();
+
+    expect(find.byKey(key1), findsOneWidget);
+
+    controller.remove();
+    await tester.pump();
+
+    expect(find.byKey(key1), findsNothing);
+
+    controller.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: key1);
+      },
+    );
+    await tester.pump();
+
+    expect(find.byKey(key1), findsOneWidget);
+  });
+
+  testWidgets('markNeedsBuild causes the builder to update', (WidgetTester tester) async {
+    int buildCount = 0;
+    late final BuildContext context;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Builder(
+            builder: (BuildContext localContext) {
+              context = localContext;
+              return const SizedBox.shrink();
+            },
+          ),
+        ),
+      ),
+    );
+
+    final ContextMenuController controller = ContextMenuController();
+    controller.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        buildCount++;
+        return const Placeholder();
+      },
+    );
+    expect(buildCount, 0);
+    await tester.pump();
+    expect(buildCount, 1);
+
+    controller.markNeedsBuild();
+    expect(buildCount, 1);
+    await tester.pump();
+    expect(buildCount, 2);
+
+    controller.remove();
+  });
+
+  testWidgets('Calling show when a built-in widget is already showing its context menu hides the built-in menu', (WidgetTester tester) async {
+    final GlobalKey builtInKey = GlobalKey();
+    final GlobalKey directKey = GlobalKey();
+    late final BuildContext context;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Builder(
+            builder: (BuildContext localContext) {
+              context = localContext;
+              return EditableText(
+                controller: TextEditingController(),
+                backgroundCursorColor: Colors.grey,
+                focusNode: FocusNode(),
+                style: const TextStyle(),
+                cursorColor: Colors.red,
+                selectionControls: materialTextSelectionHandleControls,
+                contextMenuBuilder: (
+                  BuildContext context,
+                  EditableTextState editableTextState,
+                ) {
+                  return Placeholder(key: builtInKey);
+                },
+              );
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(builtInKey), findsNothing);
+    expect(find.byKey(directKey), findsNothing);
+
+    final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
+
+    await tester.tapAt(textOffsetToPosition(tester, 0));
+    await tester.pump();
+    expect(state.showToolbar(), true);
+    await tester.pump();
+
+    expect(find.byKey(builtInKey), findsOneWidget);
+    expect(find.byKey(directKey), findsNothing);
+
+    final ContextMenuController controller = ContextMenuController();
+    controller.show(
+      context: context,
+      contextMenuBuilder: (BuildContext context) {
+        return Placeholder(key: directKey);
+      },
+    );
+    await tester.pump();
+
+    expect(find.byKey(builtInKey), findsNothing);
+    expect(find.byKey(directKey), findsOneWidget);
+    expect(controller.isShown, isTrue);
+
+    // And showing the built-in menu hides the directly shown menu.
+    expect(state.showToolbar(), isTrue);
+    await tester.pump();
+
+    expect(find.byKey(builtInKey), findsOneWidget);
+    expect(find.byKey(directKey), findsNothing);
+    expect(controller.isShown, isFalse);
+
+    // Calling remove on the hidden ContextMenuController does not hide the
+    // built-in menu.
+    controller.remove();
+    await tester.pump();
+
+    expect(find.byKey(builtInKey), findsOneWidget);
+    expect(find.byKey(directKey), findsNothing);
+    expect(controller.isShown, isFalse);
+
+    state.hideToolbar();
+    await tester.pump();
+    expect(find.byKey(builtInKey), findsNothing);
+    expect(find.byKey(directKey), findsNothing);
+    expect(controller.isShown, isFalse);
+  },
+    skip: isContextMenuProvidedByPlatform, // [intended] no Flutter-drawn text selection toolbar on web.
+  );
+}
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 632e4a3..51ebfa8 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -1834,7 +1834,7 @@
           backgroundCursorColor: Colors.grey,
           controller: TextEditingController(text: 'blah blah'),
           focusNode: focusNode,
-          toolbarOptions: const ToolbarOptions(),
+          toolbarOptions: ToolbarOptions.empty,
           style: textStyle,
           cursorColor: cursorColor,
           selectionControls: cupertinoTextSelectionControls,
@@ -3293,7 +3293,7 @@
     );
 
     controller.selection =
-        TextSelection.collapsed(offset:controller.text.length);
+        TextSelection.collapsed(offset: controller.text.length);
     await tester.pumpAndSettle();
 
     // At end, can only go backwards.
@@ -5306,7 +5306,7 @@
 
     // Find the toolbar fade transition while the toolbar is still visible.
     final List<FadeTransition> transitionsBefore = find.descendant(
-      of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
+      of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
       matching: find.byType(FadeTransition),
     ).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
 
@@ -5322,7 +5322,7 @@
 
     // Find the toolbar fade transition after the toolbar has been hidden.
     final List<FadeTransition> transitionsAfter = find.descendant(
-      of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
+      of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
       matching: find.byType(FadeTransition),
     ).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
 
@@ -13575,6 +13575,56 @@
     });
   });
 
+  testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+    final GlobalKey key = GlobalKey();
+    final TextEditingController controller = TextEditingController(text: '');
+    await tester.pumpWidget(MaterialApp(
+      home: Align(
+        alignment: Alignment.topLeft,
+        child: SizedBox(
+          width: 400,
+          child: EditableText(
+            maxLines: 10,
+            controller: controller,
+            showSelectionHandles: true,
+            autofocus: true,
+            focusNode: FocusNode(),
+            style: Typography.material2018().black.subtitle1!,
+            cursorColor: Colors.blue,
+            backgroundCursorColor: Colors.grey,
+            keyboardType: TextInputType.text,
+            textAlign: TextAlign.right,
+            selectionControls: materialTextSelectionHandleControls,
+            contextMenuBuilder: (
+              BuildContext context,
+              EditableTextState editableTextState,
+            ) {
+              return SizedBox(
+                key: key,
+                width: 10.0,
+                height: 10.0,
+              );
+            },
+          ),
+        ),
+      ),
+    ));
+
+    await tester.pump(); // Wait for autofocus to take effect.
+
+    expect(find.byKey(key), findsNothing);
+
+    // Long-press to bring up the context menu.
+    final Finder textFinder = find.byType(EditableText);
+    await tester.longPress(textFinder);
+    tester.state<EditableTextState>(textFinder).showToolbar();
+    await tester.pumpAndSettle();
+
+    expect(find.byKey(key), findsOneWidget);
+  },
+    skip: kIsWeb, // [intended] on web the browser handles the context menu.
+  );
+
   group('Spell check', () {
     testWidgets(
       'Spell check configured properly when spell check disabled by default',
diff --git a/packages/flutter/test/widgets/editable_text_utils.dart b/packages/flutter/test/widgets/editable_text_utils.dart
index 505e104..325e13b 100644
--- a/packages/flutter/test/widgets/editable_text_utils.dart
+++ b/packages/flutter/test/widgets/editable_text_utils.dart
@@ -7,6 +7,9 @@
 import 'package:flutter/rendering.dart';
 import 'package:flutter_test/flutter_test.dart';
 
+/// On web, the context menu (aka toolbar) is provided by the browser.
+const bool isContextMenuProvidedByPlatform = isBrowser;
+
 // Returns the first RenderEditable.
 RenderEditable findRenderEditable(WidgetTester tester) {
   final RenderObject root = tester.renderObject(find.byType(EditableText));
diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart
index 42f749c..8e3c6f74 100644
--- a/packages/flutter/test/widgets/selectable_region_test.dart
+++ b/packages/flutter/test/widgets/selectable_region_test.dart
@@ -1209,6 +1209,44 @@
     variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
   );
 
+  testWidgets('builds the correct button items', (WidgetTester tester) async {
+    Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
+    await tester.pumpWidget(
+      MaterialApp(
+        home: SelectableRegion(
+          focusNode: FocusNode(),
+          selectionControls: materialTextSelectionHandleControls,
+          contextMenuBuilder: (
+            BuildContext context,
+            SelectableRegionState selectableRegionState,
+          ) {
+            buttonTypes = selectableRegionState.contextMenuButtonItems
+              .map((ContextMenuButtonItem buttonItem) => buttonItem.type)
+              .toSet();
+            return const SizedBox.shrink();
+          },
+          child: const Text('How are you?'),
+        ),
+      ),
+    );
+
+    expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+    final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+    final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+    addTearDown(gesture.removePointer);
+    await tester.pump(const Duration(milliseconds: 500));
+    // `are` is selected.
+    expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+    await tester.pumpAndSettle();
+
+    expect(buttonTypes, contains(ContextMenuButtonType.copy));
+    expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+  },
+    variant: TargetPlatformVariant.all(),
+    skip: kIsWeb, // [intended]
+  );
+
   testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
     SelectedContent? content;
 
diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart
index 9b6ff4c..e1e5ce0 100644
--- a/packages/flutter/test/widgets/selectable_text_test.dart
+++ b/packages/flutter/test/widgets/selectable_text_test.dart
@@ -3353,6 +3353,12 @@
       await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
       await tester.pump(const Duration(milliseconds: 50));
 
+      // Hide the toolbar so it doesn't interfere with taps on the text.
+      final EditableTextState editableTextState =
+          tester.state<EditableTextState>(find.byType(EditableText));
+      editableTextState.hideToolbar();
+      await tester.pumpAndSettle();
+
       await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
       await tester.pump();
 
@@ -3829,6 +3835,12 @@
       await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
       await tester.pump(const Duration(milliseconds: 50));
 
+      // Hide the toolbar so it doesn't interfere with taps on the text.
+      final EditableTextState editableTextState =
+          tester.state<EditableTextState>(find.byType(EditableText));
+      editableTextState.hideToolbar();
+      await tester.pumpAndSettle();
+
       await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
       await tester.pump(const Duration(milliseconds: 50));
 
@@ -3901,6 +3913,12 @@
       );
       expect(find.byType(CupertinoButton), findsNWidgets(1));
 
+      // Hide the toolbar so it doesn't interfere with taps on the text.
+      final EditableTextState editableTextState =
+          tester.state<EditableTextState>(find.byType(EditableText));
+      editableTextState.hideToolbar();
+      await tester.pumpAndSettle();
+
       await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
       await tester.pump(const Duration(milliseconds: 50));
       // First tap moved the cursor.
@@ -4411,7 +4429,7 @@
     );
 
     final EditableTextState state =
-    tester.state<EditableTextState>(find.byType(EditableText));
+        tester.state<EditableTextState>(find.byType(EditableText));
     final RenderEditable renderEditable = state.renderEditable;
 
     await tester.tapAt(const Offset(20, 10));
@@ -4971,7 +4989,11 @@
     expect(selection!.baseOffset, 5);
     expect(selection!.extentOffset, 6);
 
-    // Put the cursor at the end of the field.
+    // Tap at the beginning of the text to hide the toolbar, then at the end to
+    // move the cursor to the end. On some platforms, the context menu would
+    // otherwise block a tap on the end of the field.
+    await tester.tapAt(textOffsetToPosition(tester, 0));
+    await tester.pumpAndSettle();
     await tester.tapAt(textOffsetToPosition(tester, 10));
     expect(selection, isNotNull);
     expect(selection!.baseOffset, 10);