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

Commit

Permalink
Ability to disable the browser's context menu on web (#118194)
Browse files Browse the repository at this point in the history
Enables custom context menus on web
  • Loading branch information
justinmc authored Jan 30, 2023
1 parent 530c3f2 commit 17eb2e8
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 36 deletions.
1 change: 1 addition & 0 deletions packages/flutter/lib/services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/material/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _TextSelectionControlsToolbar(
return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion,
textLineHeight: textLineHeight,
selectionMidpoint: selectionMidpoint,
Expand Down
83 changes: 83 additions & 0 deletions packages/flutter/lib/src/services/browser_context_menu.dart
Original file line number Diff line number Diff line change
@@ -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<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;
});
}
}
13 changes: 13 additions & 0 deletions packages/flutter/lib/src/services/system_channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}
36 changes: 18 additions & 18 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2074,7 +2073,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
break;
}
}
clipboardStatus?.update();
clipboardStatus.update();
}

/// Cut current selection to [Clipboard].
Expand All @@ -2099,7 +2098,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
});
hideToolbar();
}
clipboardStatus?.update();
clipboardStatus.update();
}

/// Paste text from [Clipboard].
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -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');
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 3 additions & 9 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand Down Expand Up @@ -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 {
Expand Down
82 changes: 82 additions & 0 deletions packages/flutter/test/services/browser_context_menu_test.dart
Original file line number Diff line number Diff line change
@@ -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<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]
);
}
1 change: 0 additions & 1 deletion packages/flutter/test/services/text_input_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit 17eb2e8

Please sign in to comment.