Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 17eb2e8

Browse files
authored
Ability to disable the browser's context menu on web (#118194)
Enables custom context menus on web
1 parent 530c3f2 commit 17eb2e8

File tree

9 files changed

+272
-36
lines changed

9 files changed

+272
-36
lines changed

packages/flutter/lib/services.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export 'src/services/asset_bundle.dart';
1414
export 'src/services/autofill.dart';
1515
export 'src/services/binary_messenger.dart';
1616
export 'src/services/binding.dart';
17+
export 'src/services/browser_context_menu.dart';
1718
export 'src/services/clipboard.dart';
1819
export 'src/services/debug.dart';
1920
export 'src/services/deferred_component.dart';

packages/flutter/lib/src/material/text_selection.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
5555
ClipboardStatusNotifier? clipboardStatus,
5656
Offset? lastSecondaryTapDownPosition,
5757
) {
58-
return _TextSelectionControlsToolbar(
58+
return _TextSelectionControlsToolbar(
5959
globalEditableRegion: globalEditableRegion,
6060
textLineHeight: textLineHeight,
6161
selectionMidpoint: selectionMidpoint,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
7+
import 'system_channels.dart';
8+
9+
/// Controls the browser's context menu on the web platform.
10+
///
11+
/// The context menu is the menu that appears on right clicking or selecting
12+
/// text in the browser, for example.
13+
///
14+
/// On web, by default, the browser's context menu is enabled and Flutter's
15+
/// context menus are hidden.
16+
///
17+
/// On all non-web platforms, this does nothing.
18+
class BrowserContextMenu {
19+
BrowserContextMenu._();
20+
21+
static final BrowserContextMenu _instance = BrowserContextMenu._();
22+
23+
/// Whether showing the browser's context menu is enabled.
24+
///
25+
/// When true, any event that the browser typically uses to trigger its
26+
/// context menu (e.g. right click) will do so. When false, the browser's
27+
/// context menu will not show.
28+
///
29+
/// It's possible for this to be true but for the browser's context menu to
30+
/// not show due to direct manipulation of the DOM. For example, handlers for
31+
/// the browser's `contextmenu` event could be added/removed in the browser's
32+
/// JavaScript console, and this boolean wouldn't know about it. This boolean
33+
/// only indicates the results of calling [disableContextMenu] and
34+
/// [enableContextMenu] here.
35+
///
36+
/// Defaults to true.
37+
static bool get enabled => _instance._enabled;
38+
39+
bool _enabled = true;
40+
41+
final MethodChannel _channel = SystemChannels.contextMenu;
42+
43+
/// Disable the browser's context menu.
44+
///
45+
/// By default, when the app starts, the browser's context menu is already
46+
/// enabled.
47+
///
48+
/// This is an asynchronous action. The context menu can be considered to be
49+
/// disabled at the time that the Future resolves. [enabled] won't reflect the
50+
/// change until that time.
51+
///
52+
/// See also:
53+
/// * [enableContextMenu], which performs the opposite operation.
54+
static Future<void> disableContextMenu() {
55+
assert(kIsWeb, 'This has no effect on platforms other than web.');
56+
return _instance._channel.invokeMethod<void>(
57+
'disableContextMenu',
58+
).then((_) {
59+
_instance._enabled = false;
60+
});
61+
}
62+
63+
/// Enable the browser's context menu.
64+
///
65+
/// By default, when the app starts, the browser's context menu is already
66+
/// enabled. Typically this method would be called after first calling
67+
/// [disableContextMenu].
68+
///
69+
/// This is an asynchronous action. The context menu can be considered to be
70+
/// enabled at the time that the Future resolves. [enabled] won't reflect the
71+
/// change until that time.
72+
///
73+
/// See also:
74+
/// * [disableContextMenu], which performs the opposite operation.
75+
static Future<void> enableContextMenu() {
76+
assert(kIsWeb, 'This has no effect on platforms other than web.');
77+
return _instance._channel.invokeMethod<void>(
78+
'enableContextMenu',
79+
).then((_) {
80+
_instance._enabled = true;
81+
});
82+
}
83+
}

packages/flutter/lib/src/services/system_channels.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,17 @@ class SystemChannels {
465465
///
466466
/// * [DefaultPlatformMenuDelegate], which uses this channel.
467467
static const MethodChannel menu = OptionalMethodChannel('flutter/menu');
468+
469+
/// A [MethodChannel] for configuring the browser's context menu on web.
470+
///
471+
/// The following outgoing methods are defined for this channel (invoked using
472+
/// [OptionalMethodChannel.invokeMethod]):
473+
///
474+
/// * `enableContextMenu`: enables the browser's context menu. When a Flutter
475+
/// app starts, the browser's context menu is already enabled.
476+
/// * `disableContextMenu`: disables the browser's context menu.
477+
static const MethodChannel contextMenu = OptionalMethodChannel(
478+
'flutter/contextmenu',
479+
JSONMethodCodec(),
480+
);
468481
}

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,7 +1893,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18931893
final GlobalKey _editableKey = GlobalKey();
18941894

18951895
/// Detects whether the clipboard can paste.
1896-
final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
1896+
final ClipboardStatusNotifier clipboardStatus = ClipboardStatusNotifier();
18971897

18981898
TextInputConnection? _textInputConnection;
18991899
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
@@ -1996,8 +1996,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
19961996
return widget.toolbarOptions.paste && !widget.readOnly;
19971997
}
19981998
return !widget.readOnly
1999-
&& (clipboardStatus == null
2000-
|| clipboardStatus!.value == ClipboardStatus.pasteable);
1999+
&& (clipboardStatus.value == ClipboardStatus.pasteable);
20012000
}
20022001

20032002
@override
@@ -2074,7 +2073,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20742073
break;
20752074
}
20762075
}
2077-
clipboardStatus?.update();
2076+
clipboardStatus.update();
20782077
}
20792078

20802079
/// Cut current selection to [Clipboard].
@@ -2099,7 +2098,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20992098
});
21002099
hideToolbar();
21012100
}
2102-
clipboardStatus?.update();
2101+
clipboardStatus.update();
21032102
}
21042103

21052104
/// Paste text from [Clipboard].
@@ -2285,7 +2284,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
22852284
},
22862285
type: ContextMenuButtonType.copy,
22872286
),
2288-
if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled)
2287+
if (toolbarOptions.paste && pasteEnabled)
22892288
ContextMenuButtonItem(
22902289
onPressed: () {
22912290
pasteText(SelectionChangedCause.toolbar);
@@ -2386,7 +2385,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
23862385
/// button Widgets for the current platform given [ContextMenuButtonItem]s.
23872386
List<ContextMenuButtonItem> get contextMenuButtonItems {
23882387
return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems(
2389-
clipboardStatus: clipboardStatus?.value,
2388+
clipboardStatus: clipboardStatus.value,
23902389
onCopy: copyEnabled
23912390
? () => copySelection(SelectionChangedCause.toolbar)
23922391
: null,
@@ -2407,7 +2406,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
24072406
@override
24082407
void initState() {
24092408
super.initState();
2410-
clipboardStatus?.addListener(_onChangedClipboardStatus);
2409+
clipboardStatus.addListener(_onChangedClipboardStatus);
24112410
widget.controller.addListener(_didChangeTextEditingValue);
24122411
widget.focusNode.addListener(_handleFocusChanged);
24132412
_scrollController.addListener(_onEditableScroll);
@@ -2531,8 +2530,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25312530
final bool canPaste = widget.selectionControls is TextSelectionHandleControls
25322531
? pasteEnabled
25332532
: widget.selectionControls?.canPaste(this) ?? false;
2534-
if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) {
2535-
clipboardStatus!.update();
2533+
if (widget.selectionEnabled && pasteEnabled && canPaste) {
2534+
clipboardStatus.update();
25362535
}
25372536
}
25382537

@@ -2553,8 +2552,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25532552
_selectionOverlay = null;
25542553
widget.focusNode.removeListener(_handleFocusChanged);
25552554
WidgetsBinding.instance.removeObserver(this);
2556-
clipboardStatus?.removeListener(_onChangedClipboardStatus);
2557-
clipboardStatus?.dispose();
2555+
clipboardStatus.removeListener(_onChangedClipboardStatus);
2556+
clipboardStatus.dispose();
25582557
_cursorVisibilityNotifier.dispose();
25592558
super.dispose();
25602559
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
@@ -3688,17 +3687,18 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
36883687
@override
36893688
bool showToolbar() {
36903689
// Web is using native dom elements to enable clipboard functionality of the
3691-
// toolbar: copy, paste, select, cut. It might also provide additional
3692-
// functionality depending on the browser (such as translate). Due to this
3693-
// we should not show a Flutter toolbar for the editable text elements.
3694-
if (kIsWeb) {
3690+
// context menu: copy, paste, select, cut. It might also provide additional
3691+
// functionality depending on the browser (such as translate). Due to this,
3692+
// we should not show a Flutter toolbar for the editable text elements
3693+
// unless the browser's context menu is explicitly disabled.
3694+
if (kIsWeb && BrowserContextMenu.enabled) {
36953695
return false;
36963696
}
36973697

36983698
if (_selectionOverlay == null) {
36993699
return false;
37003700
}
3701-
clipboardStatus?.update();
3701+
clipboardStatus.update();
37023702
_selectionOverlay!.showToolbar();
37033703
return true;
37043704
}
@@ -3912,7 +3912,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
39123912
&& (widget.selectionControls is TextSelectionHandleControls
39133913
? pasteEnabled
39143914
: pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false))
3915-
&& (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable)
3915+
&& (clipboardStatus.value == ClipboardStatus.pasteable)
39163916
? () {
39173917
controls?.handlePaste(this);
39183918
pasteText(SelectionChangedCause.toolbar);

packages/flutter/test/material/text_field_test.dart

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11914,7 +11914,7 @@ void main() {
1191411914
},
1191511915
);
1191611916

11917-
testWidgets('Web does not check the clipboard status', (WidgetTester tester) async {
11917+
testWidgets('clipboard status is checked via hasStrings without getting the full clipboard contents', (WidgetTester tester) async {
1191811918
final TextEditingController controller = TextEditingController(
1191911919
text: 'Atwater Peel Sherbrooke Bonaventure',
1192011920
);
@@ -11958,14 +11958,8 @@ void main() {
1195811958
// getData is not called unless something is pasted. hasStrings is used to
1195911959
// check the status of the clipboard.
1196011960
expect(calledGetData, false);
11961-
if (kIsWeb) {
11962-
// hasStrings is not checked because web doesn't show a custom text
11963-
// selection menu.
11964-
expect(calledHasStrings, false);
11965-
} else {
11966-
// hasStrings is checked in order to decide if the content can be pasted.
11967-
expect(calledHasStrings, true);
11968-
}
11961+
// hasStrings is checked in order to decide if the content can be pasted.
11962+
expect(calledHasStrings, true);
1196911963
});
1197011964

1197111965
testWidgets('TextField changes mouse cursor when hovered', (WidgetTester tester) async {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/services.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
TestWidgetsFlutterBinding.ensureInitialized();
11+
12+
final List<MethodCall> log = <MethodCall>[];
13+
14+
Future<void> verify(AsyncCallback test, List<Object> expectations) async {
15+
log.clear();
16+
await test();
17+
expect(log, expectations);
18+
}
19+
20+
group('not on web', () {
21+
test('disableContextMenu asserts', () async {
22+
try {
23+
BrowserContextMenu.disableContextMenu();
24+
} catch (error) {
25+
expect(error, isAssertionError);
26+
}
27+
});
28+
29+
test('enableContextMenu asserts', () async {
30+
try {
31+
BrowserContextMenu.enableContextMenu();
32+
} catch (error) {
33+
expect(error, isAssertionError);
34+
}
35+
});
36+
},
37+
skip: kIsWeb, // [intended]
38+
);
39+
40+
group('on web', () {
41+
group('disableContextMenu', () {
42+
// Make sure the context menu is enabled (default) after the test.
43+
tearDown(() async {
44+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) {
45+
return null;
46+
});
47+
await BrowserContextMenu.enableContextMenu();
48+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null);
49+
});
50+
51+
test('disableContextMenu calls its platform channel method', () async {
52+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) async {
53+
log.add(methodCall);
54+
return null;
55+
});
56+
57+
await verify(BrowserContextMenu.disableContextMenu, <Object>[
58+
isMethodCall('disableContextMenu', arguments: null),
59+
]);
60+
61+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null);
62+
});
63+
});
64+
65+
group('enableContextMenu', () {
66+
test('enableContextMenu calls its platform channel method', () async {
67+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, (MethodCall methodCall) async {
68+
log.add(methodCall);
69+
return null;
70+
});
71+
72+
await verify(BrowserContextMenu.enableContextMenu, <Object>[
73+
isMethodCall('enableContextMenu', arguments: null),
74+
]);
75+
76+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.contextMenu, null);
77+
});
78+
});
79+
},
80+
skip: !kIsWeb, // [intended]
81+
);
82+
}

packages/flutter/test/services/text_input_test.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
65
import 'dart:convert' show jsonDecode;
76

87
import 'package:flutter/foundation.dart';

0 commit comments

Comments
 (0)