Unverified Commit 17eb2e8a authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Ability to disable the browser's context menu on web (#118194)

Enables custom context menus on web
parent 530c3f2d
...@@ -14,6 +14,7 @@ export 'src/services/asset_bundle.dart'; ...@@ -14,6 +14,7 @@ export 'src/services/asset_bundle.dart';
export 'src/services/autofill.dart'; export 'src/services/autofill.dart';
export 'src/services/binary_messenger.dart'; export 'src/services/binary_messenger.dart';
export 'src/services/binding.dart'; export 'src/services/binding.dart';
export 'src/services/browser_context_menu.dart';
export 'src/services/clipboard.dart'; export 'src/services/clipboard.dart';
export 'src/services/debug.dart'; export 'src/services/debug.dart';
export 'src/services/deferred_component.dart'; export 'src/services/deferred_component.dart';
......
...@@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls { ...@@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
ClipboardStatusNotifier? clipboardStatus, ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition, Offset? lastSecondaryTapDownPosition,
) { ) {
return _TextSelectionControlsToolbar( return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion, globalEditableRegion: globalEditableRegion,
textLineHeight: textLineHeight, textLineHeight: textLineHeight,
selectionMidpoint: selectionMidpoint, selectionMidpoint: selectionMidpoint,
......
// 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<void> disableContextMenu() {
assert(kIsWeb, 'This has no effect on platforms other than web.');
return _instance._channel.invokeMethod<void>(
'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<void> enableContextMenu() {
assert(kIsWeb, 'This has no effect on platforms other than web.');
return _instance._channel.invokeMethod<void>(
'enableContextMenu',
).then((_) {
_instance._enabled = true;
});
}
}
...@@ -465,4 +465,17 @@ class SystemChannels { ...@@ -465,4 +465,17 @@ class SystemChannels {
/// ///
/// * [DefaultPlatformMenuDelegate], which uses this channel. /// * [DefaultPlatformMenuDelegate], which uses this channel.
static const MethodChannel menu = OptionalMethodChannel('flutter/menu'); 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(),
);
} }
...@@ -1893,7 +1893,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1893,7 +1893,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final GlobalKey _editableKey = GlobalKey(); final GlobalKey _editableKey = GlobalKey();
/// Detects whether the clipboard can paste. /// Detects whether the clipboard can paste.
final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); final ClipboardStatusNotifier clipboardStatus = ClipboardStatusNotifier();
TextInputConnection? _textInputConnection; TextInputConnection? _textInputConnection;
bool get _hasInputConnection => _textInputConnection?.attached ?? false; bool get _hasInputConnection => _textInputConnection?.attached ?? false;
...@@ -1996,8 +1996,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1996,8 +1996,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return widget.toolbarOptions.paste && !widget.readOnly; return widget.toolbarOptions.paste && !widget.readOnly;
} }
return !widget.readOnly return !widget.readOnly
&& (clipboardStatus == null && (clipboardStatus.value == ClipboardStatus.pasteable);
|| clipboardStatus!.value == ClipboardStatus.pasteable);
} }
@override @override
...@@ -2074,7 +2073,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2074,7 +2073,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
break; break;
} }
} }
clipboardStatus?.update(); clipboardStatus.update();
} }
/// Cut current selection to [Clipboard]. /// Cut current selection to [Clipboard].
...@@ -2099,7 +2098,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2099,7 +2098,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}); });
hideToolbar(); hideToolbar();
} }
clipboardStatus?.update(); clipboardStatus.update();
} }
/// Paste text from [Clipboard]. /// Paste text from [Clipboard].
...@@ -2285,7 +2284,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2285,7 +2284,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}, },
type: ContextMenuButtonType.copy, type: ContextMenuButtonType.copy,
), ),
if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled) if (toolbarOptions.paste && pasteEnabled)
ContextMenuButtonItem( ContextMenuButtonItem(
onPressed: () { onPressed: () {
pasteText(SelectionChangedCause.toolbar); pasteText(SelectionChangedCause.toolbar);
...@@ -2386,7 +2385,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2386,7 +2385,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// button Widgets for the current platform given [ContextMenuButtonItem]s. /// button Widgets for the current platform given [ContextMenuButtonItem]s.
List<ContextMenuButtonItem> get contextMenuButtonItems { List<ContextMenuButtonItem> get contextMenuButtonItems {
return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems( return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems(
clipboardStatus: clipboardStatus?.value, clipboardStatus: clipboardStatus.value,
onCopy: copyEnabled onCopy: copyEnabled
? () => copySelection(SelectionChangedCause.toolbar) ? () => copySelection(SelectionChangedCause.toolbar)
: null, : null,
...@@ -2407,7 +2406,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2407,7 +2406,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
void initState() { void initState() {
super.initState(); super.initState();
clipboardStatus?.addListener(_onChangedClipboardStatus); clipboardStatus.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_onEditableScroll); _scrollController.addListener(_onEditableScroll);
...@@ -2531,8 +2530,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2531,8 +2530,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final bool canPaste = widget.selectionControls is TextSelectionHandleControls final bool canPaste = widget.selectionControls is TextSelectionHandleControls
? pasteEnabled ? pasteEnabled
: widget.selectionControls?.canPaste(this) ?? false; : widget.selectionControls?.canPaste(this) ?? false;
if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) { if (widget.selectionEnabled && pasteEnabled && canPaste) {
clipboardStatus!.update(); clipboardStatus.update();
} }
} }
...@@ -2553,8 +2552,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2553,8 +2552,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_selectionOverlay = null; _selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged); widget.focusNode.removeListener(_handleFocusChanged);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
clipboardStatus?.removeListener(_onChangedClipboardStatus); clipboardStatus.removeListener(_onChangedClipboardStatus);
clipboardStatus?.dispose(); clipboardStatus.dispose();
_cursorVisibilityNotifier.dispose(); _cursorVisibilityNotifier.dispose();
super.dispose(); super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth'); assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
...@@ -3688,17 +3687,18 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3688,17 +3687,18 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
bool showToolbar() { bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the // Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional // context menu: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this // functionality depending on the browser (such as translate). Due to this,
// we should not show a Flutter toolbar for the editable text elements. // we should not show a Flutter toolbar for the editable text elements
if (kIsWeb) { // unless the browser's context menu is explicitly disabled.
if (kIsWeb && BrowserContextMenu.enabled) {
return false; return false;
} }
if (_selectionOverlay == null) { if (_selectionOverlay == null) {
return false; return false;
} }
clipboardStatus?.update(); clipboardStatus.update();
_selectionOverlay!.showToolbar(); _selectionOverlay!.showToolbar();
return true; return true;
} }
...@@ -3912,7 +3912,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3912,7 +3912,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
&& (widget.selectionControls is TextSelectionHandleControls && (widget.selectionControls is TextSelectionHandleControls
? pasteEnabled ? pasteEnabled
: pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) : pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false))
&& (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable) && (clipboardStatus.value == ClipboardStatus.pasteable)
? () { ? () {
controls?.handlePaste(this); controls?.handlePaste(this);
pasteText(SelectionChangedCause.toolbar); pasteText(SelectionChangedCause.toolbar);
......
...@@ -11914,7 +11914,7 @@ void main() { ...@@ -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( final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure', text: 'Atwater Peel Sherbrooke Bonaventure',
); );
...@@ -11958,14 +11958,8 @@ void main() { ...@@ -11958,14 +11958,8 @@ void main() {
// getData is not called unless something is pasted. hasStrings is used to // getData is not called unless something is pasted. hasStrings is used to
// check the status of the clipboard. // check the status of the clipboard.
expect(calledGetData, false); expect(calledGetData, false);
if (kIsWeb) { // hasStrings is checked in order to decide if the content can be pasted.
// hasStrings is not checked because web doesn't show a custom text expect(calledHasStrings, true);
// selection menu.
expect(calledHasStrings, false);
} else {
// 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 { testWidgets('TextField changes mouse cursor when hovered', (WidgetTester tester) async {
......
// 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<MethodCall> log = <MethodCall>[];
Future<void> verify(AsyncCallback test, List<Object> 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, <Object>[
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, <Object>[
isMethodCall('enableContextMenu', arguments: null),
]);
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null);
});
});
},
skip: !kIsWeb, // [intended]
);
}
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:convert' show jsonDecode; import 'dart:convert' show jsonDecode;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
......
...@@ -1488,13 +1488,14 @@ void main() { ...@@ -1488,13 +1488,14 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
/// Toolbar is not used in Flutter Web. Skip this check. // 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, // Web is using native DOM elements (it is also used as platform input)
/// cut. It might also provide additional functionality depending on the // to enable clipboard functionality of the toolbar: copy, paste, select,
/// browser (such as translation). Due to this, in browsers, we should not // cut. It might also provide additional functionality depending on the
/// show a Flutter toolbar for the editable text elements. // 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 { testWidgets('can show toolbar when there is text and a selection', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -1542,6 +1543,69 @@ void main() { ...@@ -1542,6 +1543,69 @@ void main() {
expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget); 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<void>.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<EditableTextState>(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 { testWidgets('can hide toolbar with DismissIntent', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment