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

/// 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.
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 @@ -531,6 +530,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 = 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);
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);
}
}

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

@override
void enableContextMenu() => enableContextMenuOn(domWindow);

void _setHostAttribute(String name, String value) {
domDocument.body!.setAttribute(name, value);
}
Expand Down
40 changes: 40 additions & 0 deletions lib/web_ui/test/engine/platform_dispatcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ void main() {
}

void testMain() {
ensureFlutterViewEmbedderInitialized();

group('PlatformDispatcher', () {
test('high contrast in accessibilityFeatures has the correct value', () {
final MockHighContrastSupport mockHighContrast =
Expand Down Expand Up @@ -72,6 +74,44 @@ void testMain() {
);
});

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,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);
Comment on lines +144 to +149
Copy link
Contributor

Choose a reason for hiding this comment

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

Another important test is to dispatch an event outside of target (either create a sibling element or just dispatch on document.body) and make sure that the event wasn't prevented.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea, thanks!


// 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);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}