Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to disable browser context menu #38682

Merged
merged 19 commits into from
Jan 24, 2023

Conversation

justinmc
Copy link
Contributor

@justinmc justinmc commented Jan 6, 2023

The goal of this PR is to create a way to disable the browser-rendered context menu on web.

The main reason that someone might want to do this is to use a custom Flutter-rendered context menu instead, which this PR will unblock.

After this PR:

Screen.Recording.2023-01-06.at.4.34.53.PM.mov

The current, basic approach

I'm just calling event.preventDefault() on contextmenu events at the root domWindow level. I've tied this to platform channel methods that could be exposed in the framework.

  static final DomEventListener _handleContextMenu = allowInterop((DomEvent event) {
    event.preventDefault();
  });

  static void disableContextMenu() {
    domWindow.addEventListener('contextmenu', _handleContextMenu);
  }

  static void enableContextMenu() {
    domWindow.removeEventListener('contextmenu', _handleContextMenu);
  }

Alternative, more fully featured approach

It might be nice if, instead of toggling the context menus for the whole app, users could toggle it for a widget subtree. Would it be at all possible to do this? I guess there would have to be a specific DOM element for every given widget that we could add/remove the listener on.

With either approach, please excuse my lack of web engine knowledge and let me know if I'm on the right track!

References

Part of: flutter/flutter#78671
Framework PR: flutter/flutter#118194

Example code
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool _contextMenuEnabled = true;

  void _toggleContextMenu() {
    if (_contextMenuEnabled) {
      ContextMenu.disableContextMenu();
    } else {
      ContextMenu.enableContextMenu();
    }
    setState(() {
      _contextMenuEnabled = !_contextMenuEnabled;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggleContextMenu,
        tooltip: _contextMenuEnabled ? 'Disable' : 'Enable',
        child: Icon(_contextMenuEnabled ? Icons.thumb_down: Icons.thumb_up),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const TextField(),

            // This container does not show a context menu.
            GestureDetector(
              onSecondaryTapDown: (TapDownDetails details) async {
                await ContextMenu.disableContextMenu();
                await Future.delayed(const Duration(seconds: 0));
                ContextMenu.enableContextMenu();
              },
              child: Container(
                width: 200,
                height: 200,
                color: Colors.tealAccent,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

@justinmc justinmc self-assigned this Jan 6, 2023
@flutter-dashboard flutter-dashboard bot added the platform-web Code specifically for the web engine label Jan 6, 2023
Copy link
Contributor

@mdebbar mdebbar left a comment

Choose a reason for hiding this comment

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

Both PRs (engine & framework) look good to me overall. They are still in draft mode, so I'll assume that tests will be added later.

@@ -0,0 +1,37 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
Copy link
Contributor

Choose a reason for hiding this comment

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

The path of this file needs to be added here:

ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/clipboard.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/color_filter.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/configuration.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/dom.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/embedder.dart + ../../../flutter/LICENSE

///
/// Can be re-enabled by calling [enableContextMenu].
static void disableContextMenu() {
domWindow.addEventListener('contextmenu', _handleContextMenu);
Copy link
Contributor

Choose a reason for hiding this comment

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

The flutter app could be embedded inside a host element (instead of taking over the entire window). We should add the event listener to the host element. cc @ditman for the right way to do this.

Copy link
Member

Choose a reason for hiding this comment

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

The contextmenu event bubbles (spec), so this should be enough?

If this needs to be aware of the embedding, the enable/disable methods could be added to the EmbeddingStrategy abstract class (and its implementations):

https://github.com/flutter/engine/blob/main/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart#L23

Copy link
Contributor

Choose a reason for hiding this comment

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

The contextmenu event bubbles (spec), so this should be enough?

Yes, but it's more than enough :) It disables the context menu for the entire page, which we don't want in the host element mode.

Copy link
Member

Choose a reason for hiding this comment

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

You're very right :P

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the advice here! I've moved the enable/disable methods to the embedding strategy and made them accessible via FlutterViewEmbedder.

@mdebbar
Copy link
Contributor

mdebbar commented Jan 9, 2023

It might be nice if, instead of toggling the context menus for the whole app, users could toggle it for a widget subtree. Would it be at all possible to do this?

This isn't possible because the entire app could be rendered on a canvas element.

To achieve this, you want the pointer event listener on the framework side to decide whether it wants to show a context menu or not (e.g. based on which widget was hit tested). And it should immediately tell the engine that a context menu shouldn't be shown for this event.

@justinmc
Copy link
Contributor Author

Thanks for the review! Understood about specific widget subtrees. I just played around with using the API in this PR to quickly disable and then reenable the context menu for a specific GestureDetector in the framework, and it worked, which is a good endorsement for this API.

I'm going to go ahead with cleaning these two PRs up and writing tests for them when I'm back next week, and I'll let you know when they're ready for a real review.

@justinmc justinmc force-pushed the disablable-context-menu branch 3 times, most recently from c9ca134 to 383427d Compare January 17, 2023 20:13
@justinmc justinmc force-pushed the disablable-context-menu branch from 383427d to 4fd56bd Compare January 17, 2023 21:06
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!

@justinmc justinmc marked this pull request as ready for review January 17, 2023 21:34
@justinmc justinmc force-pushed the disablable-context-menu branch from 0b12dee to a2c5a88 Compare January 17, 2023 23:41
@@ -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).

engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jan 24, 2023
auto-submit bot pushed a commit to flutter/flutter that referenced this pull request Jan 24, 2023
…119081)

* 22084db7a Remove unnecessary null checks in doc snippet (flutter/engine#39071)

* f91890636 Roll Skia from f6a5c806294d to 1ccf2093cfa9 (2 revisions) (flutter/engine#39028)

* f02a70492 Roll Fuchsia Mac SDK from rQtxqj3gePeR-nTLv... to HxpwvvbQdk54L6_8q... (flutter/engine#39075)

* 42eec6280 [Impeller] uniform offsets account for size (flutter/engine#39077)

* b6a348a82 Ability to disable browser context menu (flutter/engine#38682)

* 1b51696eb Roll Skia from 1ccf2093cfa9 to 0328e12ab195 (59 revisions) (flutter/engine#39078)

* d83a705ef [embedder] Fix getting vkGetInstanceProcAddr (flutter/engine#39051)

* 973b01c40 Fix doc analyzer breakage (flutter/engine#39082)

* 55e9eafab Roll Dart SDK from 107a1280a61f to d1a0e860679e (2 revisions) (flutter/engine#39084)

* fa07c546c Roll Skia from 0328e12ab195 to 50d78376d30c (3 revisions) (flutter/engine#39086)

* 17abf1a9e Roll Fuchsia Linux SDK from GLRbnjiO5SbZKX-Us... to dWbkAZchFHtZE9Wt_... (flutter/engine#39087)

* 5c46d75f7 Roll Skia from 50d78376d30c to 90fda2e72314 (4 revisions) (flutter/engine#39089)

* 5b4e331a6 Add mmap dependency to flutter_frontend_server (flutter/engine#39090)

* 7f38d0d4f Migrate `@FfiNative` to `@Native` (flutter/engine#39034)

* 71ee5f19b Remove superfluous words from comments (flutter/engine#39068)
ricardoamador pushed a commit to ricardoamador/engine that referenced this pull request Jan 25, 2023
Adds platform channel methods that disable/enable the context menu on web.
@justinmc justinmc mentioned this pull request Jun 30, 2023
auto-submit bot pushed a commit to flutter/flutter that referenced this pull request Aug 9, 2023
By default, Flutter web uses the browser's built-in context menu.

<img width="200" src="https://github.com/flutter/flutter/assets/389558/990f99cb-bc38-40f1-9e88-8839bc342da5" />

As of [recently](flutter/engine#38682), it's possible to use a Flutter-rendered context menu like the other platforms.

```dart
void main() {
  runApp(const MyApp());
  BrowserContextMenu.disableContextMenu();
}
```

But there is a bug (#129692) that the Paste button is missing and never shows up.

<img width="284" alt="Screenshot 2023-08-07 at 2 39 03 PM" src="https://github.com/flutter/flutter/assets/389558/f632be25-28b1-4e2e-98f7-3bb443f077df">

The reason why it's missing is that Flutter first checks if there is any pasteable text on the clipboard before deciding to show the Paste button using the `hasStrings` platform channel method, but that was never implemented for web ([original hasStrings PR](#87678)).

So let's just implement hasStrings for web?  No, because Chrome shows a permissions prompt when the clipboard is accessed, and there is [no browser clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API) to avoid it.  The prompt will show immediately when the EditableText is built, not just when the Paste button is pressed.

<img width="200" src="https://github.com/flutter/flutter/assets/389558/5abdb160-1b13-4f1a-87e1-4653ca19d73e" />

### This PR's solution

Instead, before implementing hasStrings for web, this PR disables the hasStrings check for web.  The result is that users will always see a paste button, even in the (unlikely) case that they have nothing pasteable on the clipboard.  However, they will not see a permissions dialog until they actually click the Paste button.  Subsequent pastes don't show the permission dialog.

<details>

<summary>Video of final behavior with this PR</summary>

https://github.com/flutter/flutter/assets/389558/ed16c925-8111-44a7-99e8-35a09d682748

</details>

I think this will be the desired behavior for the vast majority of app developers.  Those that want different behavior can use hasStrings themselves, which will be implemented in flutter/engine#43360.

### References

Fixes #129692
Engine PR to be merged after this: flutter/engine#43360
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
platform-web Code specifically for the web engine
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants