Ability to disable the browser's context menu on web (#118194)
Enables custom context menus on web
This commit is contained in:
parent
530c3f2d13
commit
17eb2e8aeb
@ -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,
|
||||
|
83
packages/flutter/lib/src/services/browser_context_menu.dart
Normal file
83
packages/flutter/lib/src/services/browser_context_menu.dart
Normal file
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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]
|
||||
);
|
||||
}
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user