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';
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';
......
......@@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _TextSelectionControlsToolbar(
return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion,
textLineHeight: textLineHeight,
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 {
///
/// * [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(),
);
}
......@@ -1893,7 +1893,7 @@ class EditableTextState extends State<EditableText> 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<EditableText> 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<EditableText> with AutomaticKeepAliveClien
break;
}
}
clipboardStatus?.update();
clipboardStatus.update();
}
/// Cut current selection to [Clipboard].
......@@ -2099,7 +2098,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
});
hideToolbar();
}
clipboardStatus?.update();
clipboardStatus.update();
}
/// Paste text from [Clipboard].
......@@ -2285,7 +2284,7 @@ class EditableTextState extends State<EditableText> 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<EditableText> with AutomaticKeepAliveClien
/// button Widgets for the current platform given [ContextMenuButtonItem]s.
List<ContextMenuButtonItem> 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<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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);
......
......@@ -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 {
......
// 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 @@
// 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';
......
......@@ -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<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 {
await tester.pumpWidget(
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