diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index a0ff42f677..fb8420f9ae 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -14,6 +14,7 @@ export 'src/services/asset_bundle.dart'; export 'src/services/autofill.dart'; export 'src/services/binary_messenger.dart'; export 'src/services/binding.dart'; +export 'src/services/browser_context_menu.dart'; export 'src/services/clipboard.dart'; export 'src/services/debug.dart'; export 'src/services/deferred_component.dart'; diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index 9c31349f06..46df56d6bf 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls { ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition, ) { - return _TextSelectionControlsToolbar( + return _TextSelectionControlsToolbar( globalEditableRegion: globalEditableRegion, textLineHeight: textLineHeight, selectionMidpoint: selectionMidpoint, diff --git a/packages/flutter/lib/src/services/browser_context_menu.dart b/packages/flutter/lib/src/services/browser_context_menu.dart new file mode 100644 index 0000000000..982fbc24c1 --- /dev/null +++ b/packages/flutter/lib/src/services/browser_context_menu.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/foundation.dart'; + +import 'system_channels.dart'; + +/// Controls the browser's context menu on the web platform. +/// +/// The context menu is the menu that appears on right clicking or selecting +/// text in the browser, for example. +/// +/// On web, by default, the browser's context menu is enabled and Flutter's +/// context menus are hidden. +/// +/// On all non-web platforms, this does nothing. +class BrowserContextMenu { + BrowserContextMenu._(); + + static final BrowserContextMenu _instance = BrowserContextMenu._(); + + /// Whether showing the browser's context menu is enabled. + /// + /// When true, any event that the browser typically uses to trigger its + /// context menu (e.g. right click) will do so. When false, the browser's + /// context menu will not show. + /// + /// It's possible for this to be true but for the browser's context menu to + /// not show due to direct manipulation of the DOM. For example, handlers for + /// the browser's `contextmenu` event could be added/removed in the browser's + /// JavaScript console, and this boolean wouldn't know about it. This boolean + /// only indicates the results of calling [disableContextMenu] and + /// [enableContextMenu] here. + /// + /// Defaults to true. + static bool get enabled => _instance._enabled; + + bool _enabled = true; + + final MethodChannel _channel = SystemChannels.contextMenu; + + /// Disable the browser's context menu. + /// + /// By default, when the app starts, the browser's context menu is already + /// enabled. + /// + /// This is an asynchronous action. The context menu can be considered to be + /// disabled at the time that the Future resolves. [enabled] won't reflect the + /// change until that time. + /// + /// See also: + /// * [enableContextMenu], which performs the opposite operation. + static Future disableContextMenu() { + assert(kIsWeb, 'This has no effect on platforms other than web.'); + return _instance._channel.invokeMethod( + 'disableContextMenu', + ).then((_) { + _instance._enabled = false; + }); + } + + /// Enable the browser's context menu. + /// + /// By default, when the app starts, the browser's context menu is already + /// enabled. Typically this method would be called after first calling + /// [disableContextMenu]. + /// + /// This is an asynchronous action. The context menu can be considered to be + /// enabled at the time that the Future resolves. [enabled] won't reflect the + /// change until that time. + /// + /// See also: + /// * [disableContextMenu], which performs the opposite operation. + static Future enableContextMenu() { + assert(kIsWeb, 'This has no effect on platforms other than web.'); + return _instance._channel.invokeMethod( + 'enableContextMenu', + ).then((_) { + _instance._enabled = true; + }); + } +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index c69c7e5e77..d2628a570e 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -465,4 +465,17 @@ class SystemChannels { /// /// * [DefaultPlatformMenuDelegate], which uses this channel. static const MethodChannel menu = OptionalMethodChannel('flutter/menu'); + + /// A [MethodChannel] for configuring the browser's context menu on web. + /// + /// The following outgoing methods are defined for this channel (invoked using + /// [OptionalMethodChannel.invokeMethod]): + /// + /// * `enableContextMenu`: enables the browser's context menu. When a Flutter + /// app starts, the browser's context menu is already enabled. + /// * `disableContextMenu`: disables the browser's context menu. + static const MethodChannel contextMenu = OptionalMethodChannel( + 'flutter/contextmenu', + JSONMethodCodec(), + ); } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 46d15b7939..01e5111d8a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1893,7 +1893,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien final GlobalKey _editableKey = GlobalKey(); /// Detects whether the clipboard can paste. - final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); + final ClipboardStatusNotifier clipboardStatus = ClipboardStatusNotifier(); TextInputConnection? _textInputConnection; bool get _hasInputConnection => _textInputConnection?.attached ?? false; @@ -1996,8 +1996,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien return widget.toolbarOptions.paste && !widget.readOnly; } return !widget.readOnly - && (clipboardStatus == null - || clipboardStatus!.value == ClipboardStatus.pasteable); + && (clipboardStatus.value == ClipboardStatus.pasteable); } @override @@ -2074,7 +2073,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien break; } } - clipboardStatus?.update(); + clipboardStatus.update(); } /// Cut current selection to [Clipboard]. @@ -2099,7 +2098,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien }); hideToolbar(); } - clipboardStatus?.update(); + clipboardStatus.update(); } /// Paste text from [Clipboard]. @@ -2285,7 +2284,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien }, type: ContextMenuButtonType.copy, ), - if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled) + if (toolbarOptions.paste && pasteEnabled) ContextMenuButtonItem( onPressed: () { pasteText(SelectionChangedCause.toolbar); @@ -2386,7 +2385,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// button Widgets for the current platform given [ContextMenuButtonItem]s. List get contextMenuButtonItems { return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems( - clipboardStatus: clipboardStatus?.value, + clipboardStatus: clipboardStatus.value, onCopy: copyEnabled ? () => copySelection(SelectionChangedCause.toolbar) : null, @@ -2407,7 +2406,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void initState() { super.initState(); - clipboardStatus?.addListener(_onChangedClipboardStatus); + clipboardStatus.addListener(_onChangedClipboardStatus); widget.controller.addListener(_didChangeTextEditingValue); widget.focusNode.addListener(_handleFocusChanged); _scrollController.addListener(_onEditableScroll); @@ -2531,8 +2530,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien final bool canPaste = widget.selectionControls is TextSelectionHandleControls ? pasteEnabled : widget.selectionControls?.canPaste(this) ?? false; - if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) { - clipboardStatus!.update(); + if (widget.selectionEnabled && pasteEnabled && canPaste) { + clipboardStatus.update(); } } @@ -2553,8 +2552,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien _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'); @@ -3688,17 +3687,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the - // toolbar: copy, paste, select, cut. It might also provide additional - // functionality depending on the browser (such as translate). Due to this - // we should not show a Flutter toolbar for the editable text elements. - if (kIsWeb) { + // context menu: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this, + // we should not show a Flutter toolbar for the editable text elements + // unless the browser's context menu is explicitly disabled. + if (kIsWeb && BrowserContextMenu.enabled) { return false; } if (_selectionOverlay == null) { return false; } - clipboardStatus?.update(); + clipboardStatus.update(); _selectionOverlay!.showToolbar(); return true; } @@ -3912,7 +3912,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien && (widget.selectionControls is TextSelectionHandleControls ? pasteEnabled : pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) - && (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable) + && (clipboardStatus.value == ClipboardStatus.pasteable) ? () { controls?.handlePaste(this); pasteText(SelectionChangedCause.toolbar); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 10f2adf5e8..07d876269f 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -11914,7 +11914,7 @@ void main() { }, ); - testWidgets('Web does not check the clipboard status', (WidgetTester tester) async { + testWidgets('clipboard status is checked via hasStrings without getting the full clipboard contents', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', ); @@ -11958,14 +11958,8 @@ void main() { // getData is not called unless something is pasted. hasStrings is used to // check the status of the clipboard. expect(calledGetData, false); - if (kIsWeb) { - // hasStrings is not checked because web doesn't show a custom text - // selection menu. - expect(calledHasStrings, false); - } else { - // hasStrings is checked in order to decide if the content can be pasted. - expect(calledHasStrings, true); - } + // hasStrings is checked in order to decide if the content can be pasted. + expect(calledHasStrings, true); }); testWidgets('TextField changes mouse cursor when hovered', (WidgetTester tester) async { diff --git a/packages/flutter/test/services/browser_context_menu_test.dart b/packages/flutter/test/services/browser_context_menu_test.dart new file mode 100644 index 0000000000..d8a6b9a5c0 --- /dev/null +++ b/packages/flutter/test/services/browser_context_menu_test.dart @@ -0,0 +1,82 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final List log = []; + + Future verify(AsyncCallback test, List expectations) async { + log.clear(); + await test(); + expect(log, expectations); + } + + group('not on web', () { + test('disableContextMenu asserts', () async { + try { + BrowserContextMenu.disableContextMenu(); + } catch (error) { + expect(error, isAssertionError); + } + }); + + test('enableContextMenu asserts', () async { + try { + BrowserContextMenu.enableContextMenu(); + } catch (error) { + expect(error, isAssertionError); + } + }); + }, + skip: kIsWeb, // [intended] + ); + + group('on web', () { + group('disableContextMenu', () { + // Make sure the context menu is enabled (default) after the test. + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) { + return null; + }); + await BrowserContextMenu.enableContextMenu(); + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null); + }); + + test('disableContextMenu calls its platform channel method', () async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + await verify(BrowserContextMenu.disableContextMenu, [ + isMethodCall('disableContextMenu', arguments: null), + ]); + + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null); + }); + }); + + group('enableContextMenu', () { + test('enableContextMenu calls its platform channel method', () async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + await verify(BrowserContextMenu.enableContextMenu, [ + isMethodCall('enableContextMenu', arguments: null), + ]); + + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null); + }); + }); + }, + skip: !kIsWeb, // [intended] + ); +} diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 254e7b7144..221491f1e6 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. - import 'dart:convert' show jsonDecode; import 'package:flutter/foundation.dart'; diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 2438876d25..794171655d 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1488,13 +1488,14 @@ void main() { expect(tester.takeException(), isNull); }); - /// Toolbar is not used in Flutter Web. Skip this check. - /// - /// Web is using native DOM elements (it is also used as platform input) - /// to enable clipboard functionality of the toolbar: copy, paste, select, - /// cut. It might also provide additional functionality depending on the - /// browser (such as translation). Due to this, in browsers, we should not - /// show a Flutter toolbar for the editable text elements. + // Toolbar is not used in Flutter Web unless the browser context menu is + // explicitly disabled. Skip this check. + // + // Web is using native DOM elements (it is also used as platform input) + // to enable clipboard functionality of the toolbar: copy, paste, select, + // cut. It might also provide additional functionality depending on the + // browser (such as translation). Due to this, in browsers, we should not + // show a Flutter toolbar for the editable text elements. testWidgets('can show toolbar when there is text and a selection', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -1542,6 +1543,69 @@ void main() { expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget); }); + group('BrowserContextMenu', () { + setUp(() async { + SystemChannels.contextMenu.setMockMethodCallHandler((MethodCall call) { + // Just complete successfully, so that BrowserContextMenu thinks that + // the engine successfully received its call. + return Future.value(); + }); + await BrowserContextMenu.disableContextMenu(); + }); + + tearDown(() async { + await BrowserContextMenu.enableContextMenu(); + SystemChannels.contextMenu.setMockMethodCallHandler(null); + }); + + testWidgets('web can show toolbar when the browser context menu is disabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Can't show the toolbar when there's no focus. + expect(state.showToolbar(), false); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsNothing); + + // Can show the toolbar when focused even though there's no text. + state.renderEditable.selectWordsInRange( + from: Offset.zero, + cause: SelectionChangedCause.tap, + ); + await tester.pump(); + expect(state.showToolbar(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + // Hide the menu again. + state.hideToolbar(); + await tester.pump(); + expect(find.text('Paste'), findsNothing); + + // Can show the menu with text and a selection. + controller.text = 'blah'; + await tester.pump(); + expect(state.showToolbar(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + }, + skip: !kIsWeb, // [intended] + ); + }); + testWidgets('can hide toolbar with DismissIntent', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp(