fix(web): prevent browser context menu over task menus (#1627)

This commit is contained in:
HouKangle authored and GitHub committed 2026-10-30 18:02:02 +08:00
1 parent c0b70d319d
commit 8719166dbf
9 files changed
+420 -1

No files matched your search

@@ -0,0 +1,81 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'browser_context_menu_region_stub.dart'
if (dart.library.js_interop) 'browser_context_menu_region_web.dart'
as platform;
/// Keeps the browser menu out of an application's context-menu region on Web.
///
/// Captured themes carry the policy into shadcn's menu and submenu overlays.
/// Overlay regions defer to their children so a full-screen positioning layer
/// does not suppress the browser menu over the rest of the page.
class BrowserContextMenuRegion extends StatelessWidget {
const BrowserContextMenuRegion({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
if (!kIsWeb) return child;
return _ContextMenuTheme(
child: _ContextMenuRegion(behavior: HitTestBehavior.translucent, child: child),
);
}
}
class _ContextMenuTheme extends InheritedTheme {
const _ContextMenuTheme({required super.child});
@override
Widget wrap(BuildContext context, Widget child) {
return _ContextMenuTheme(child: _ContextMenuRegion(child: child));
}
@override
bool updateShouldNotify(_ContextMenuTheme oldWidget) => false;
}
class _ContextMenuRegion extends SingleChildRenderObjectWidget {
const _ContextMenuRegion({this.behavior = HitTestBehavior.deferToChild, required super.child});
final HitTestBehavior behavior;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderContextMenuRegion(View.of(context).viewId, behavior);
}
@override
void updateRenderObject(BuildContext context, _RenderContextMenuRegion renderObject) {
renderObject
..viewId = View.of(context).viewId
..behavior = behavior;
}
}
class _RenderContextMenuRegion extends RenderProxyBoxWithHitTestBehavior {
_RenderContextMenuRegion(this._viewId, HitTestBehavior behavior) : super(behavior: behavior);
int _viewId;
set viewId(int value) {
if (value == _viewId) return;
if (attached) platform.unregisterRegion(this, _viewId);
_viewId = value;
if (attached) platform.registerRegion(this, _viewId);
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
platform.registerRegion(this, _viewId);
}
@override
void detach() {
platform.unregisterRegion(this, _viewId);
super.detach();
}
}
@@ -0,0 +1,5 @@
import 'package:flutter/rendering.dart';
void registerRegion(RenderBox region, int viewId) {}
void unregisterRegion(RenderBox region, int viewId) {}
@@ -0,0 +1,51 @@
import 'dart:js_interop';
import 'dart:ui' as ui;
import 'dart:ui_web' as ui_web;
import 'package:flutter/rendering.dart';
import 'package:web/web.dart' as web;
final _regions = <int, Set<RenderBox>>{};
JSFunction? _listener;
void registerRegion(RenderBox region, int viewId) {
_regions.putIfAbsent(viewId, () => <RenderBox>{}).add(region);
if (_listener != null) return;
_listener = _onContextMenu.toJS;
web.document.addEventListener('contextmenu', _listener, true.toJS);
}
void unregisterRegion(RenderBox region, int viewId) {
final regions = _regions[viewId];
regions?.remove(region);
if (regions?.isEmpty ?? false) _regions.remove(viewId);
if (_regions.isNotEmpty) return;
web.document.removeEventListener('contextmenu', _listener, true.toJS);
_listener = null;
}
void _onContextMenu(web.MouseEvent event) {
if (!event.cancelable || event.defaultPrevented) return;
for (final view in ui.PlatformDispatcher.instance.views) {
final regions = _regions[view.viewId];
if (regions == null) continue;
final host = ui_web.views.getHostElement(view.viewId) as web.HTMLElement?;
if (host == null || !event.composedPath().toDart.contains(host)) continue;
final bounds = host.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) continue;
final x = event.clientX - bounds.left;
final y = event.clientY - bounds.top;
if (x < 0 || y < 0 || x >= bounds.width || y >= bounds.height) continue;
// DOM client coordinates are CSS pixels. Map them through the public view
// host bounds rather than assuming a DPR, page origin or browser zoom.
final logicalSize = view.physicalSize / view.devicePixelRatio;
final position = Offset(x * logicalSize.width / bounds.width, y * logicalSize.height / bounds.height);
final result = HitTestResult();
RendererBinding.instance.hitTestInView(result, position, view.viewId);
if (result.path.any((entry) => regions.contains(entry.target))) {
event.preventDefault();
return;
}
}
}