mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
fix(web): prevent browser context menu over task menus (#1627)
This commit is contained in:
1 parent
c0b70d319d
commit
8719166dbf
9 files changed
+420
-1
No files matched your search
@@ -86,6 +86,32 @@ jobs:
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
flutter gen-l10n
|
flutter gen-l10n
|
||||||
flutter test test/api/downloader_config_test.dart
|
flutter test test/api/downloader_config_test.dart
|
||||||
|
test-flutter-task-context-menu:
|
||||||
|
name: Test task context menus and text inputs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
CHROME_EXECUTABLE: /usr/bin/google-chrome
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
- name: Prepare Flutter tests
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
flutter pub get
|
||||||
|
flutter gen-l10n
|
||||||
|
- name: Test task menu actions and native input policy
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
flutter test --no-pub test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||||
|
flutter test --no-pub test/widget_test.dart --plain-name 'task context menu exposes'
|
||||||
|
- name: Test browser menu cancellation and input policy
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
"$CHROME_EXECUTABLE" --version
|
||||||
|
flutter test --no-pub --platform chrome test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||||
test-webview-integration:
|
test-webview-integration:
|
||||||
name: Test webview integration (${{ matrix.name }})
|
name: Test webview integration (${{ matrix.name }})
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|||||||
|
|
||||||
import '../../domain/task_record.dart';
|
import '../../domain/task_record.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
|
import '../../../../shared/widgets/browser_context_menu_region.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
|
|
||||||
class TaskContextActions {
|
class TaskContextActions {
|
||||||
@@ -50,7 +51,7 @@ class TaskContextMenu extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return shad.ContextMenu(
|
final menu = shad.ContextMenu(
|
||||||
items: [
|
items: [
|
||||||
shad.MenuButton(
|
shad.MenuButton(
|
||||||
key: const ValueKey('task-context-select-all'),
|
key: const ValueKey('task-context-select-all'),
|
||||||
@@ -134,6 +135,7 @@ class TaskContextMenu extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
return BrowserContextMenuRegion(child: menu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/model/task.dart' as api_task;
|
||||||
|
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||||
|
import 'package:gopeed/features/tasks/presentation/widgets/task_context_menu.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||||
|
import 'package:gopeed/shared/widgets/app_text_field.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
import '../../../../support/context_menu_event.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('task actions remain enabled by status and run once after reopening', (tester) async {
|
||||||
|
final calls = <String, int>{};
|
||||||
|
await tester.pumpWidget(_host(calls));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
expect(_button(tester, 'resume').enabled, isTrue);
|
||||||
|
expect(_button(tester, 'pause').enabled, isFalse);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-resume')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls, {'paused.resume': 1});
|
||||||
|
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-details')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls['paused.details'], 1);
|
||||||
|
|
||||||
|
await _open(tester, 'downloading');
|
||||||
|
expect(_button(tester, 'resume').enabled, isFalse);
|
||||||
|
expect(_button(tester, 'pause').enabled, isTrue);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls['downloading.pause'], 1);
|
||||||
|
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls['paused.select'], 1);
|
||||||
|
expect(calls.values, everyElement(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('task menus close on outside click and Escape and reopen after removal', (tester) async {
|
||||||
|
final calls = <String, int>{};
|
||||||
|
await tester.pumpWidget(_host(calls));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
await tester.tapAt(_blank);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('task-context-details')), findsNothing);
|
||||||
|
expect(calls, isEmpty, reason: 'outside click only closes the menu');
|
||||||
|
|
||||||
|
await _open(tester, 'downloading');
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('task-context-details')), findsNothing);
|
||||||
|
expect(calls, isEmpty, reason: 'Escape only closes the menu');
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(calls, showTasks: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pumpWidget(_host(calls));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-details')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls, {'paused.details': 1});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Web cancels only task and popup contextmenu events and cleans up after removal', (tester) async {
|
||||||
|
final calls = <String, int>{};
|
||||||
|
await tester.pumpWidget(_host(calls));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(BrowserContextMenu.enabled, isTrue);
|
||||||
|
final firstPosition = tester.getCenter(find.byKey(const ValueKey('paused')));
|
||||||
|
final secondPosition = tester.getCenter(find.byKey(const ValueKey('downloading')));
|
||||||
|
final inputPosition = tester.getCenter(find.byType(AppTextField));
|
||||||
|
|
||||||
|
// Unlike a Flutter gesture, this observes cancellation of a DOM event.
|
||||||
|
expect(dispatchContextMenu(firstPosition, tester.view.viewId), isTrue);
|
||||||
|
expect(dispatchContextMenu(secondPosition, tester.view.viewId), isTrue);
|
||||||
|
expect(dispatchContextMenu(inputPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(_blank, tester.view.viewId), isFalse);
|
||||||
|
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
final popupPosition = tester.getCenter(find.byKey(const ValueKey('task-context-details')));
|
||||||
|
expect(dispatchContextMenu(popupPosition, tester.view.viewId), isTrue);
|
||||||
|
expect(dispatchContextMenu(_blank, tester.view.viewId), isFalse);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-update-url')));
|
||||||
|
// Submenus follow their anchor with a persistent ticker while open.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
final manualUpdate = find.text('Update manually');
|
||||||
|
expect(manualUpdate, findsOneWidget);
|
||||||
|
expect(dispatchContextMenu(tester.getCenter(manualUpdate), tester.view.viewId), isTrue);
|
||||||
|
|
||||||
|
// shadcn keeps an already-open overlay alive when its anchor is removed.
|
||||||
|
// Its own captured region must survive independently of the task cards.
|
||||||
|
await tester.pumpWidget(_host(calls, showTasks: false));
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
expect(find.byType(TaskContextMenu), findsNothing);
|
||||||
|
expect(find.byKey(const ValueKey('task-context-details')), findsOneWidget);
|
||||||
|
expect(dispatchContextMenu(popupPosition, tester.view.viewId), isTrue);
|
||||||
|
expect(dispatchContextMenu(firstPosition, tester.view.viewId), isFalse);
|
||||||
|
|
||||||
|
await tester.tapAt(_blank);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(dispatchContextMenu(popupPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(inputPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(BrowserContextMenu.enabled, isTrue);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(calls, showTasks: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(dispatchContextMenu(firstPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(secondPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(inputPosition, tester.view.viewId), isFalse);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(calls));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(dispatchContextMenu(firstPosition, tester.view.viewId), isTrue);
|
||||||
|
await _open(tester, 'downloading');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(calls, {'downloading.select': 1});
|
||||||
|
expect(BrowserContextMenu.enabled, isTrue);
|
||||||
|
|
||||||
|
await _open(tester, 'paused');
|
||||||
|
await tester.pumpWidget(const SizedBox.expand());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(dispatchContextMenu(firstPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(popupPosition, tester.view.viewId), isFalse);
|
||||||
|
expect(dispatchContextMenu(inputPosition, tester.view.viewId), isFalse);
|
||||||
|
}, skip: !kIsWeb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _blank = Offset(700, 500);
|
||||||
|
|
||||||
|
Future<void> _open(WidgetTester tester, String id) async {
|
||||||
|
await tester.tapAt(tester.getCenter(find.byKey(ValueKey(id))), buttons: kSecondaryMouseButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('task-context-details')), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
shad.MenuButton _button(WidgetTester tester, String name) {
|
||||||
|
return tester.widget<shad.MenuButton>(find.byKey(ValueKey('task-context-$name')));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _host(Map<String, int> calls, {bool showTasks = true}) {
|
||||||
|
return shad.ShadcnApp(
|
||||||
|
disableBrowserContextMenu: false,
|
||||||
|
theme: AppTheme.light().copyWith(platform: () => TargetPlatform.windows),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(
|
||||||
|
child: shad.Scaffold(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (showTasks)
|
||||||
|
for (final (id, status, top) in [
|
||||||
|
('paused', TaskStatus.paused, 20.0),
|
||||||
|
('downloading', TaskStatus.downloading, 100.0),
|
||||||
|
])
|
||||||
|
Positioned(
|
||||||
|
left: 20,
|
||||||
|
top: top,
|
||||||
|
child: TaskContextMenu(
|
||||||
|
key: ValueKey('menu-$id'),
|
||||||
|
task: TaskRecord(
|
||||||
|
id: id,
|
||||||
|
name: '$id.zip',
|
||||||
|
status: status,
|
||||||
|
downloaded: '0 B',
|
||||||
|
url: 'https://example.com/$id.zip',
|
||||||
|
storagePath: '/downloads/$id.zip',
|
||||||
|
files: const [],
|
||||||
|
uploading: false,
|
||||||
|
protocol: api_task.Protocol.http,
|
||||||
|
),
|
||||||
|
actions: _actions(id, calls),
|
||||||
|
child: SizedBox(
|
||||||
|
key: ValueKey(id),
|
||||||
|
width: 240,
|
||||||
|
height: 64,
|
||||||
|
child: const ColoredBox(color: Color(0xFFFFFFFF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned(
|
||||||
|
left: 20,
|
||||||
|
top: 220,
|
||||||
|
width: 240,
|
||||||
|
child: AppTextField(initialValue: 'https://example.com/download'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskContextActions _actions(String id, Map<String, int> calls) {
|
||||||
|
void record(String name) => calls.update('$id.$name', (count) => count + 1, ifAbsent: () => 1);
|
||||||
|
return TaskContextActions(
|
||||||
|
selected: false,
|
||||||
|
allSelected: false,
|
||||||
|
listeningForUpdate: false,
|
||||||
|
onShowDetails: () => record('details'),
|
||||||
|
onOpenFile: () => record('openFile'),
|
||||||
|
onOpenDirectory: () => record('openDirectory'),
|
||||||
|
onBrowseFiles: () => record('browseFiles'),
|
||||||
|
onToggleSelectAll: () => record('selectAll'),
|
||||||
|
onToggleSelected: () => record('select'),
|
||||||
|
onPause: () => record('pause'),
|
||||||
|
onResume: () => record('resume'),
|
||||||
|
onDelete: () => record('delete'),
|
||||||
|
onUpdateUrl: () => record('updateUrl'),
|
||||||
|
onToggleUpdateListener: () => record('listen'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export 'context_menu_event_stub.dart' if (dart.library.js_interop) 'context_menu_event_web.dart';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
bool dispatchContextMenu(Offset position, int viewId) {
|
||||||
|
throw UnsupportedError('DOM contextmenu events require a browser');
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
import 'dart:ui_web' as ui_web;
|
||||||
|
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Exercises DOM cancellation, independently of Flutter's global menu flag.
|
||||||
|
/// Synthetic events cannot prove whether a browser's native menu is visible.
|
||||||
|
bool dispatchContextMenu(Offset position, int viewId) {
|
||||||
|
final view = PlatformDispatcher.instance.views.singleWhere((view) => view.viewId == viewId);
|
||||||
|
final host = ui_web.views.getHostElement(viewId) as web.HTMLElement;
|
||||||
|
final bounds = host.getBoundingClientRect();
|
||||||
|
final logicalSize = view.physicalSize / view.devicePixelRatio;
|
||||||
|
final event = web.MouseEvent(
|
||||||
|
'contextmenu',
|
||||||
|
web.MouseEventInit(
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
button: 2,
|
||||||
|
clientX: (bounds.left + position.dx * bounds.width / logicalSize.width).round(),
|
||||||
|
clientY: (bounds.top + position.dy * bounds.height / logicalSize.height).round(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
host.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user