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

Ability to disable browser context menu #38682

Merged
merged 19 commits into from
Jan 24, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/web_ui/lib/src/engine/embedder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,20 @@ class FlutterViewEmbedder {
assert(element.parentNode == _resourcesHost);
element.remove();
}

/// 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();

/// 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();
}

/// The embedder singleton.
Expand Down
16 changes: 15 additions & 1 deletion lib/web_ui/lib/src/engine/platform_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -532,6 +531,21 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
textEditing.channel.handleTextInput(data, callback);
return;

case 'flutter/contextmenu':
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've omitted any web or browser namespacing (not "flutter/webcontextmenu") because other methods don't seem to include platform names like that. And maybe in the future this could be used on other platforms.

In the framework PR I'll make the API specific (BrowserContextMenu.disable or something like that).

const MethodCodec codec = StandardMethodCodec();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(() {
Expand Down Expand Up @@ -56,3 +57,71 @@ abstract class EmbeddingStrategy {
_hotRestartCache?.registerElement(element);
}
}

mixin _ContextMenu {
/// False when the context menu has been disabled, otherwise true.
@visibleForTesting
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(DomElement 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(DomElement element) {
if (contextMenuEnabled) {
return;
}

element.removeEventListener('contextmenu', _disablingContextMenuListener);
contextMenuEnabled = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ class FullPageEmbeddingStrategy extends EmbeddingStrategy {
registerElementForCleanup(resourceHost);
}

@override
void disableContextMenu() => disableContextMenuOn(domDocument.body!);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually set these event listeners on window not document.body. In order to do that, you want to change the signature of disableContextMenuOn/enableContextMenuOn to take a DomEventTarget instead of a DomElement. Then do:

Suggested change
void disableContextMenu() => disableContextMenuOn(domDocument.body!);
void disableContextMenu() => disableContextMenuOn(domWindow);


@override
void enableContextMenu() => enableContextMenuOn(domDocument.body!);

void _setHostAttribute(String name, String value) {
domDocument.body!.setAttribute(name, value);
}
Expand Down
38 changes: 38 additions & 0 deletions lib/web_ui/test/engine/platform_dispatcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ void testMain() {
domWindow.navigator, 'clipboard', originalClipboard);
});

test('responds to flutter/contextmenu enable', () async {
const MethodCodec codec = JSONMethodCodec();
final Completer<ByteData?> completer = Completer<ByteData?>();
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<ByteData?> completer = Completer<ByteData?>();
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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,33 @@ 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', () {
expect(strategy.contextMenuEnabled, isTrue);

strategy.disableContextMenu();
expect(strategy.contextMenuEnabled, isFalse);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything else I could expect here, like could I somehow verify that the event listener was added to the dom element?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could add your own contextmenu Event handler to the root DOM element of the strategy in the test, and check if isDefaultPrevented has been set to true. (IIRC, once you prevent the default in a handler, the event will be modified to set defaultPrevented to true.)

In order to programmatically trigger the event, you should be able to dispatchEvent a contextmenu event using the root DOM element of the strategy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some examples in our code base. E.g.

expect(event.defaultPrevented, isFalse);

You don't have to create an event listener on the root element. Just check defaultPrevented on the event you dispatched.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That worked, thank you all for the guidance here! I think the tests are much more robust this way, and I don't have to use @visibleForTest.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just check defaultPrevented on the event you dispatched.

Damn this approach is good! Thanks for the tests @justinmc!


strategy.disableContextMenu();
expect(strategy.contextMenuEnabled, isFalse);

strategy.enableContextMenu();
expect(strategy.contextMenuEnabled, isTrue);

strategy.enableContextMenu();
expect(strategy.contextMenuEnabled, isTrue);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,24 @@ void doTests() {
reason: 'Should be injected `nextTo` the passed element.');
});
});

group('context menu', () {
test('disableContextMenu and enableContextMenu can toggle the context menu', () {
final FullPageEmbeddingStrategy strategy = FullPageEmbeddingStrategy();

expect(strategy.contextMenuEnabled, isTrue);

strategy.disableContextMenu();
expect(strategy.contextMenuEnabled, isFalse);

strategy.disableContextMenu();
expect(strategy.contextMenuEnabled, isFalse);

strategy.enableContextMenu();
expect(strategy.contextMenuEnabled, isTrue);

strategy.enableContextMenu();
expect(strategy.contextMenuEnabled, isTrue);
});
});
}