diff --git a/lib/web_ui/lib/src/engine/embedder.dart b/lib/web_ui/lib/src/engine/embedder.dart index db28eac7672c9..c2d507de65044 100644 --- a/lib/web_ui/lib/src/engine/embedder.dart +++ b/lib/web_ui/lib/src/engine/embedder.dart @@ -356,6 +356,20 @@ class FlutterViewEmbedder { assert(element.parentNode == _resourcesHost); element.remove(); } + + /// Disables the browser's context menu for this part of the DOM. + /// + /// By default, when a Flutter web app starts, the context menu is enabled. + /// + /// Can be re-enabled by calling [enableContextMenu]. + void disableContextMenu() => _embeddingStrategy.disableContextMenu(); + + /// Enables the browser's context menu for this part of the DOM. + /// + /// By default, when a Flutter web app starts, the context menu is already + /// enabled. Typically, this method would be used after calling + /// [disableContextMenu] to first disable it. + void enableContextMenu() => _embeddingStrategy.enableContextMenu(); } /// The embedder singleton. diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index b34226fd4ee3e..06859c6684d2c 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -33,7 +33,6 @@ ui.VoidCallback? scheduleFrameCallback; typedef HighContrastListener = void Function(bool enabled); typedef _KeyDataResponseCallback = void Function(bool handled); - /// Determines if high contrast is enabled using media query 'forced-colors: active' for Windows class HighContrastSupport { static HighContrastSupport instance = HighContrastSupport(); @@ -531,6 +530,21 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { textEditing.channel.handleTextInput(data, callback); return; + case 'flutter/contextmenu': + const MethodCodec codec = JSONMethodCodec(); + final MethodCall decoded = codec.decodeMethodCall(data); + switch (decoded.method) { + case 'enableContextMenu': + flutterViewEmbedder.enableContextMenu(); + replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + return; + case 'disableContextMenu': + flutterViewEmbedder.disableContextMenu(); + replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + return; + } + return; + case 'flutter/mousecursor': const MethodCodec codec = StandardMethodCodec(); final MethodCall decoded = codec.decodeMethodCall(data); diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart index ecf92bb3d8956..572bdb8ce33b2 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart @@ -51,6 +51,12 @@ class CustomElementEmbeddingStrategy extends EmbeddingStrategy { registerElementForCleanup(resourceHost); } + @override + void disableContextMenu() => disableContextMenuOn(_hostElement); + + @override + void enableContextMenu() => enableContextMenuOn(_hostElement); + void _setHostAttribute(String name, String value) { _hostElement.setAttribute(name, value); } diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart index bb1c9361ae2a8..b811153a4ba97 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/safe_browser_api.dart'; import 'package:ui/src/engine/view_embedder/hot_restart_cache_handler.dart'; import 'custom_element_embedding_strategy.dart'; @@ -20,7 +21,7 @@ import 'full_page_embedding_strategy.dart'; /// * [CustomElementEmbeddingStrategy] - Flutter is rendered inside a custom host /// element, provided by the web app programmer through the engine /// initialization. -abstract class EmbeddingStrategy { +abstract class EmbeddingStrategy with _ContextMenu { EmbeddingStrategy() { // Initialize code to handle hot-restart (debug only). assert(() { @@ -56,3 +57,71 @@ abstract class EmbeddingStrategy { _hotRestartCache?.registerElement(element); } } + +/// Provides functionality to disable and enable the browser's context menu. +mixin _ContextMenu { + /// False when the context menu has been disabled, otherwise true. + bool _contextMenuEnabled = true; + + /// Listener for contextmenu events that prevents the browser's context menu + /// from being shown. + final DomEventListener _disablingContextMenuListener = allowInterop((DomEvent event) { + event.preventDefault(); + }); + + /// Disables the browser's context menu for this part of the DOM. + /// + /// By default, when a Flutter web app starts, the context menu is enabled. + /// + /// Can be re-enabled by calling [enableContextMenu]. + /// + /// See also: + /// + /// * [disableContextMenuOn], which is like this but takes the relevant + /// [DomElement] as a parameter. + void disableContextMenu(); + + /// Disables the browser's context menu for the given [DomElement]. + /// + /// See also: + /// + /// * [disableContextMenu], which is like this but is not passed a + /// [DomElement]. + @protected + void disableContextMenuOn(DomEventTarget element) { + if (!_contextMenuEnabled) { + return; + } + + element.addEventListener('contextmenu', _disablingContextMenuListener); + _contextMenuEnabled = false; + } + + /// Enables the browser's context menu for this part of the DOM. + /// + /// By default, when a Flutter web app starts, the context menu is already + /// enabled. Typically, this method would be used after calling + /// [disableContextMenu] to first disable it. + /// + /// See also: + /// + /// * [enableContextMenuOn], which is like this but takes the relevant + /// [DomElement] as a parameter. + void enableContextMenu(); + + /// Enables the browser's context menu for the given [DomElement]. + /// + /// See also: + /// + /// * [enableContextMenu], which is like this but is not passed a + /// [DomElement]. + @protected + void enableContextMenuOn(DomEventTarget element) { + if (_contextMenuEnabled) { + return; + } + + element.removeEventListener('contextmenu', _disablingContextMenuListener); + _contextMenuEnabled = true; + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart index 009b6aef4b8a0..27dd8052c5b46 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart @@ -48,6 +48,12 @@ class FullPageEmbeddingStrategy extends EmbeddingStrategy { registerElementForCleanup(resourceHost); } + @override + void disableContextMenu() => disableContextMenuOn(domWindow); + + @override + void enableContextMenu() => enableContextMenuOn(domWindow); + void _setHostAttribute(String name, String value) { domDocument.body!.setAttribute(name, value); } diff --git a/lib/web_ui/test/engine/platform_dispatcher_test.dart b/lib/web_ui/test/engine/platform_dispatcher_test.dart index e197db5c8ba4e..dd6b928a30953 100644 --- a/lib/web_ui/test/engine/platform_dispatcher_test.dart +++ b/lib/web_ui/test/engine/platform_dispatcher_test.dart @@ -17,6 +17,8 @@ void main() { } void testMain() { + ensureFlutterViewEmbedderInitialized(); + group('PlatformDispatcher', () { test('high contrast in accessibilityFeatures has the correct value', () { final MockHighContrastSupport mockHighContrast = @@ -72,6 +74,44 @@ void testMain() { ); }); + test('responds to flutter/contextmenu enable', () async { + const MethodCodec codec = JSONMethodCodec(); + final Completer completer = Completer(); + ui.PlatformDispatcher.instance.sendPlatformMessage( + 'flutter/contextmenu', + codec.encodeMethodCall(const MethodCall( + 'enableContextMenu', + )), + completer.complete, + ); + + final ByteData? response = await completer.future; + expect(response, isNotNull); + expect( + codec.decodeEnvelope(response!), + true, + ); + }); + + test('responds to flutter/contextmenu disable', () async { + const MethodCodec codec = JSONMethodCodec(); + final Completer completer = Completer(); + ui.PlatformDispatcher.instance.sendPlatformMessage( + 'flutter/contextmenu', + codec.encodeMethodCall(const MethodCall( + 'disableContextMenu', + )), + completer.complete, + ); + + final ByteData? response = await completer.future; + expect(response, isNotNull); + expect( + codec.decodeEnvelope(response!), + true, + ); + }); + test('can find text scale factor', () async { const double deltaTolerance = 1e-5; diff --git a/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart b/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart index 75af6a0359e70..62dc4de5b09c4 100644 --- a/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart +++ b/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart @@ -121,4 +121,60 @@ void doTests() { reason: 'Should be injected `nextTo` the passed element.'); }); }); + + group('context menu', () { + setUp(() { + target = createDomElement('this-is-the-target'); + domDocument.body!.append(target); + strategy = CustomElementEmbeddingStrategy(target); + strategy.initialize(); + }); + + tearDown(() { + target.remove(); + }); + + test('disableContextMenu and enableContextMenu can toggle the context menu', () { + // When the app starts, contextmenu events are not prevented. + DomEvent event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + + // Disabling the context menu causes contextmenu events to be prevented. + strategy.disableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isTrue); + + // Disabling again has no effect. + strategy.disableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isTrue); + + // Dispatching on a DOM element outside of target's subtree has no effect. + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + domDocument.body!.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + + // Enabling the context menu means that contextmenu events are back to not + // being prevented. + strategy.enableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + + // Enabling again has no effect. + strategy.enableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + }); + }); } diff --git a/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart b/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart index d05effb9b3f54..1c62be92165ef 100644 --- a/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart +++ b/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart @@ -129,4 +129,56 @@ void doTests() { reason: 'Should be injected `nextTo` the passed element.'); }); }); + + group('context menu', () { + setUp(() { + strategy = FullPageEmbeddingStrategy(); + strategy.initialize(); + }); + + test('disableContextMenu and enableContextMenu can toggle the context menu', () { + final FullPageEmbeddingStrategy strategy = FullPageEmbeddingStrategy(); + + // When the app starts, contextmenu events are not prevented. + DomEvent event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + + // Disabling the context menu causes contextmenu events to be prevented. + strategy.disableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isTrue); + + // Disabling again has no effect. + strategy.disableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isTrue); + + // Dispatching on the document body is still disabled. + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + domDocument.body!.dispatchEvent(event); + expect(event.defaultPrevented, isTrue); + + // Enabling the context menu means that contextmenu events are back to not + // being prevented. + strategy.enableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + + // Enabling again has no effect. + strategy.enableContextMenu(); + event = createDomEvent('Event', 'contextmenu'); + expect(event.defaultPrevented, isFalse); + target.dispatchEvent(event); + expect(event.defaultPrevented, isFalse); + }); + }); }