Skip to content

Commit 7ca6eea

Browse files
Merge pull request #34 from FlutterFlow/feat/osr-context-menu
Surface the context menu Chromium already builds (OSR)
2 parents 05bb923 + 5ea91b5 commit 7ca6eea

10 files changed

Lines changed: 683 additions & 28 deletions

File tree

example/lib/main.dart

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class _BrowserDemoState extends State<BrowserDemo> {
4141
// with enableCdp, so CDP is only requested in the ephemeral (null) case.
4242
String? _profile;
4343
late CefWebController _controller = _newController();
44+
/// Anchors the context menu: the page reports click coords relative to the
45+
/// view, which must be mapped through this box to global coords.
46+
final GlobalKey _viewKey = GlobalKey();
4447
final FocusNode _webFocus = FocusNode(debugLabel: 'web');
4548
final TextEditingController _urlBar = TextEditingController(text: _startUrl);
4649
double _zoom = 0;
@@ -61,6 +64,57 @@ class _BrowserDemoState extends State<BrowserDemo> {
6164
_wireController();
6265
}
6366

67+
/// Draw the page context menu and return the chosen command id (null =
68+
/// dismissed). The view is a texture, so the menu is ordinary Flutter UI
69+
/// positioned at the click point.
70+
Future<int?> _showContextMenu(CefContextMenuRequest req) async {
71+
final box = _viewKey.currentContext?.findRenderObject() as RenderBox?;
72+
if (box == null || !mounted) return null;
73+
final origin = box.localToGlobal(Offset(req.x, req.y));
74+
final overlay =
75+
Overlay.of(context).context.findRenderObject() as RenderBox?;
76+
if (overlay == null) return null;
77+
debugPrint('context menu: ${req.items.length} items, link="${req.linkUrl}" '
78+
'sel="${req.selectionText}" misspelled="${req.misspelledWord}"');
79+
return showMenu<int>(
80+
context: context,
81+
position: RelativeRect.fromRect(
82+
Rect.fromLTWH(origin.dx, origin.dy, 1, 1),
83+
Offset.zero & overlay.size,
84+
),
85+
items: _menuEntries(req.items),
86+
);
87+
}
88+
89+
List<PopupMenuEntry<int>> _menuEntries(List<CefContextMenuItem> items) {
90+
final out = <PopupMenuEntry<int>>[];
91+
for (final item in items) {
92+
switch (item.type) {
93+
case CefContextMenuItemType.separator:
94+
out.add(const PopupMenuDivider());
95+
case CefContextMenuItemType.submenu:
96+
// Flattened with a header for the demo; a real host would nest.
97+
out.add(PopupMenuItem<int>(
98+
enabled: false,
99+
child: Text(item.label,
100+
style: const TextStyle(fontWeight: FontWeight.w600)),
101+
));
102+
out.addAll(_menuEntries(item.items));
103+
case CefContextMenuItemType.command:
104+
case CefContextMenuItemType.check:
105+
case CefContextMenuItemType.radio:
106+
out.add(PopupMenuItem<int>(
107+
value: item.commandId,
108+
// Chromium's own enabled state — Paste greys out with an empty
109+
// clipboard without the host deriving anything.
110+
enabled: item.enabled,
111+
child: Text(item.checked ? '\u2713 ${item.label}' : item.label),
112+
));
113+
}
114+
}
115+
return out;
116+
}
117+
64118
/// Attach the demo's listeners/callbacks to the current [_controller]. Called
65119
/// once at init and again whenever a profile toggle swaps the controller.
66120
void _wireController() {
@@ -71,6 +125,9 @@ class _BrowserDemoState extends State<BrowserDemo> {
71125
});
72126
_controller.onLoadError = (e) =>
73127
debugPrint('load error ${e.errorCode} ${e.url}: ${e.errorText}');
128+
// Right-click: Chromium built the menu, we draw it. A plain Material menu
129+
// here on purpose — this demonstrates the seam, not a design.
130+
_controller.onContextMenu = _showContextMenu;
74131
// Links that open a new window (target=_blank / window.open) load in place
75132
// rather than spawning a separate native window.
76133
_controller.onCreateWindow = (url) {
@@ -384,7 +441,9 @@ and committed text — including emoji — should appear intact.</p>
384441
),
385442
),
386443
Expanded(
387-
child: CefWebView(
444+
child: KeyedSubtree(
445+
key: _viewKey,
446+
child: CefWebView(
388447
// Key on the profile so toggling it rebuilds the view against
389448
// the fresh controller (a profile is fixed at create() time).
390449
key: ValueKey(_profile),
@@ -404,6 +463,7 @@ and committed text — including emoji — should appear intact.</p>
404463
// exclusive with a named profile, so only request it when none
405464
// is active.
406465
enableCdp: _profile == null,
466+
),
407467
),
408468
),
409469
],

example/pubspec.lock

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ packages:
2121
dependency: transitive
2222
description:
2323
name: characters
24-
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
24+
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
2525
url: "https://pub.dev"
2626
source: hosted
27-
version: "1.4.0"
27+
version: "1.4.1"
2828
clock:
2929
dependency: transitive
3030
description:
@@ -162,26 +162,26 @@ packages:
162162
dependency: transitive
163163
description:
164164
name: matcher
165-
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
165+
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
166166
url: "https://pub.dev"
167167
source: hosted
168-
version: "0.12.17"
168+
version: "0.12.20"
169169
material_color_utilities:
170170
dependency: transitive
171171
description:
172172
name: material_color_utilities
173-
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
173+
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
174174
url: "https://pub.dev"
175175
source: hosted
176-
version: "0.11.1"
176+
version: "0.13.0"
177177
meta:
178178
dependency: transitive
179179
description:
180180
name: meta
181-
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
181+
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
182182
url: "https://pub.dev"
183183
source: hosted
184-
version: "1.17.0"
184+
version: "1.19.0"
185185
path:
186186
dependency: transitive
187187
description:
@@ -271,18 +271,18 @@ packages:
271271
dependency: transitive
272272
description:
273273
name: test_api
274-
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
274+
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
275275
url: "https://pub.dev"
276276
source: hosted
277-
version: "0.7.7"
277+
version: "0.7.12"
278278
vector_math:
279279
dependency: transitive
280280
description:
281281
name: vector_math
282-
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
282+
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
283283
url: "https://pub.dev"
284284
source: hosted
285-
version: "2.2.0"
285+
version: "2.4.2"
286286
vm_service:
287287
dependency: transitive
288288
description:

lib/flutter_cef.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.da
1212
show
1313
CefCookie,
1414
CefConsoleMessage,
15+
CefContextMenuItem,
16+
CefContextMenuItemType,
17+
CefContextMenuRequest,
1518
CefFindResult,
1619
CefJsDialogRequest,
1720
CefLoadError,

lib/src/cef_web_controller.dart

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ class CefWebController {
178178
Future<bool?> Function(CefMediaPermissionRequest request)?
179179
onMediaPermissionRequest;
180180

181+
/// A right-click landed in the page. Return the `commandId` of the chosen
182+
/// item, or null to dismiss.
183+
///
184+
/// Chromium has already built the menu (and decided each item's enabled /
185+
/// checked state); the host only DRAWS it, because an OSR browser has no
186+
/// window for a native menu. Whatever id comes back is executed by Chromium,
187+
/// so copy/paste/back/view-source/spellcheck behave exactly as in Chrome.
188+
///
189+
/// If unset, the menu is dismissed — right-click then does nothing, which is
190+
/// the behaviour before this callback existed.
191+
Future<int?> Function(CefContextMenuRequest request)? onContextMenu;
192+
181193
/// Live camera/mic status for the current page: what is actually capturing
182194
/// right now, plus the site's remembered decision. Drives an "in use" or
183195
/// "blocked" indicator; pair with [setMediaSetting] to change the decision.
@@ -275,6 +287,9 @@ class CefWebController {
275287
case 'mediaRequest':
276288
_handleMediaRequest(a);
277289
break;
290+
case 'contextMenu':
291+
_handleContextMenu(a);
292+
break;
278293
case 'mediaState':
279294
mediaState.value = CefMediaState(
280295
videoActive: a['videoActive'] as bool? ?? false,
@@ -424,6 +439,38 @@ class CefWebController {
424439
/// A page asked for the camera/mic and the site has no remembered decision.
425440
/// Mirrors [_handleJsDialog]: the page's `getUserMedia` is blocked on the
426441
/// native callback until this answers, so every path must answer exactly once.
442+
Future<void> _handleContextMenu(Map<String, dynamic> a) async {
443+
final id = a['id'] as int? ?? 0;
444+
// Answer EXACTLY ONCE, whatever happens: CEF requires the menu callback be
445+
// continued or cancelled, and a dropped one wedges the page's menu handling
446+
// so later right-clicks are ignored. Hence 0 (= dismiss) on every failure
447+
// path, including no handler and a throwing handler.
448+
int? command;
449+
if (onContextMenu != null) {
450+
try {
451+
final req = CefContextMenuRequest.fromJson(
452+
id,
453+
jsonDecode(a['json'] as String? ?? '{}') as Map<String, dynamic>,
454+
);
455+
command = await onContextMenu?.call(req);
456+
} catch (e, st) {
457+
command = null;
458+
FlutterError.reportError(FlutterErrorDetails(
459+
exception: e,
460+
stack: st,
461+
library: 'flutter_cef',
462+
context: ErrorDescription('handling a page context menu'),
463+
));
464+
}
465+
}
466+
if (_disposed) return;
467+
await _channel.invokeMethod('chooseContextMenu', {
468+
'sessionId': sessionId,
469+
'id': id,
470+
'commandId': command ?? 0,
471+
});
472+
}
473+
427474
Future<void> _handleMediaRequest(Map<String, dynamic> a) async {
428475
final id = a['id'] as int? ?? 0;
429476
// Bits from cef_media_access_permission_types_t: audio = 1<<0, video = 1<<1.
@@ -868,8 +915,18 @@ class CefWebController {
868915
}
869916

870917
/// Open Chromium's DevTools for this page in a separate window.
871-
Future<void> openDevTools() =>
872-
_channel.invokeMethod('showDevTools', {'sessionId': sessionId});
918+
///
919+
/// [inspectAt] (page DIP coordinates, as reported by
920+
/// [CefContextMenuRequest.x]/[CefContextMenuRequest.y]) opens DevTools already
921+
/// inspecting the element at that point — what "Inspect" on a right-click
922+
/// means. DevTools is a real window even though the page is windowless, so
923+
/// this works from an OSR view.
924+
Future<void> openDevTools({Offset? inspectAt}) =>
925+
_channel.invokeMethod('showDevTools', {
926+
'sessionId': sessionId,
927+
if (inspectAt != null) 'inspectX': inspectAt.dx.round(),
928+
if (inspectAt != null) 'inspectY': inspectAt.dy.round(),
929+
});
873930

874931
/// Open the macOS Character Viewer (the emoji & symbols picker — the same
875932
/// panel as ⌃⌘Space) targeting this view. The view must be focused so the

packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ final class CefProfileHost {
3939
// processGone) instead of silently mis-parsing frames into frozen/blank tiles; the
4040
// skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale
4141
// embedded copies (the content-hash fetch can't drift on the normal path).
42-
static let protocolVersion: UInt8 = 6
42+
static let protocolVersion: UInt8 = 7
4343

4444
// Profile identity / config.
4545
let profileId: String

packages/flutter_cef_macos/macos/Classes/CefWebSession.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ final class CefWebSession: NSObject, FlutterTexture {
5252
// cef_host -> us: a page called getUserMedia and the site has no remembered
5353
// decision, so the host must show a permission prompt. {u32 id}{u32 mask}{utf8 origin}
5454
private static let opMediaRequest: UInt8 = 0x1e
55+
private static let opContextMenu: UInt8 = 0x40
5556
// cef_host -> us: {u8 videoActive}{u8 audioActive}{u8 setting 0=ask 1=allow}
5657
private static let opMediaState: UInt8 = 0x1f
5758
private static let opNavigate: UInt8 = 0x20
@@ -79,6 +80,7 @@ final class CefWebSession: NSObject, FlutterTexture {
7980
// us -> cef_host: answer a permission prompt {u32 id}{u8 allow}{u8 remember};
8081
// remembered per-origin only when a human chose, exactly like a browser.
8182
private static let opMediaResponse: UInt8 = 0x3c
83+
private static let opContextMenuCommand: UInt8 = 0x3e
8284
// us -> cef_host: {u8 0=ask 1=allow 2=block} rewrite this site's remembered
8385
// camera/mic decision (the URL-bar "site settings" path). No reload.
8486
private static let opSetMediaSetting: UInt8 = 0x3d
@@ -106,6 +108,9 @@ final class CefWebSession: NSObject, FlutterTexture {
106108
var onImeBounds: ((Int, Int, Int, Int) -> Void)? // caret rect x,y,w,h (DIP)
107109
var onCookies: ((Int, String) -> Void)? // request id, json array
108110
var onMediaRequest: ((Int, Int, String) -> Void)? // id, permission mask, origin
111+
/// Right-click in the page. `json` carries Chromium's own menu model + hit
112+
/// context; the Flutter side draws it and answers with `chooseContextMenu`.
113+
var onContextMenu: ((Int, String) -> Void)? // id, json
109114
var onMediaState: ((Bool, Bool, Int) -> Void)? // videoActive, audioActive, setting
110115
// Fired when the backing IOSurface is (re)allocated — at create and on every
111116
// resize() (which reallocs). Args are the live global surface id and the
@@ -483,6 +488,16 @@ final class CefWebSession: NSObject, FlutterTexture {
483488
sendFrame(Self.opMediaResponse, p)
484489
}
485490

491+
/// Answer a context menu. `commandId` 0 means dismissed without choosing —
492+
/// which must still be sent: CEF requires the menu callback be answered
493+
/// exactly once, and skipping it wedges the page's menu handling.
494+
func chooseContextMenu(id: Int, commandId: Int) {
495+
var p = [UInt8]()
496+
appendU32(&p, UInt32(truncatingIfNeeded: id))
497+
appendU32(&p, UInt32(truncatingIfNeeded: commandId))
498+
sendFrame(Self.opContextMenuCommand, p)
499+
}
500+
486501
/// Rewrite this site's remembered camera/mic decision (0 = ask again, 1 =
487502
/// allow, 2 = block). No reload — it applies next time the page asks.
488503
func setMediaSetting(_ value: Int) {
@@ -555,7 +570,18 @@ final class CefWebSession: NSObject, FlutterTexture {
555570
sendFrame(Self.opDeleteCookie, Array((url + "\u{0}" + name).utf8))
556571
}
557572

558-
func showDevTools() { sendFrame(Self.opShowDevTools) }
573+
/// Open DevTools. With a point (page DIP coords) it opens INSPECTING the
574+
/// element there — the right-click "Inspect" path.
575+
func showDevTools(inspectAt: (x: Int, y: Int)? = nil) {
576+
guard let at = inspectAt else {
577+
sendFrame(Self.opShowDevTools)
578+
return
579+
}
580+
var p = [UInt8]()
581+
appendU32(&p, UInt32(truncatingIfNeeded: max(0, at.x)))
582+
appendU32(&p, UInt32(truncatingIfNeeded: max(0, at.y)))
583+
sendFrame(Self.opShowDevTools, p)
584+
}
559585

560586
func imeSetComposition(_ text: String) {
561587
sendFrame(Self.opImeSetComp, Array(text.utf8))
@@ -824,6 +850,13 @@ final class CefWebSession: NSObject, FlutterTexture {
824850
: ""
825851
onMediaRequest?(readU32(payload, 0), readU32(payload, 4), origin)
826852
}
853+
case Self.opContextMenu:
854+
if payload.count >= 4 {
855+
let json = payload.count > 4
856+
? (String(bytes: payload[4...], encoding: .utf8) ?? "{}")
857+
: "{}"
858+
onContextMenu?(readU32(payload, 0), json)
859+
}
827860
case Self.opMediaState:
828861
if payload.count >= 3 {
829862
onMediaState?(payload[0] != 0, payload[1] != 0, Int(payload[2]))

packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
167167
remember: args["remember"] as? Bool ?? false)
168168
}
169169
result(nil)
170+
case "chooseContextMenu":
171+
withSession(args) {
172+
$0.chooseContextMenu(id: args["id"] as? Int ?? 0,
173+
commandId: args["commandId"] as? Int ?? 0)
174+
}
175+
result(nil)
170176
case "setMediaSetting":
171177
withSession(args) { $0.setMediaSetting(args["value"] as? Int ?? 0) }
172178
result(nil)
@@ -234,7 +240,13 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
234240
}
235241
result(nil)
236242
case "showDevTools":
237-
withSession(args) { $0.showDevTools() }
243+
withSession(args) {
244+
if let x = args["inspectX"] as? Int, let y = args["inspectY"] as? Int {
245+
$0.showDevTools(inspectAt: (x: x, y: y))
246+
} else {
247+
$0.showDevTools()
248+
}
249+
}
238250
result(nil)
239251
case "enableAgentControl":
240252
// CEF-2b: broker a token-gated CDP endpoint scoped to THIS tile's CDP target.
@@ -469,6 +481,11 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
469481
"permissions": permissions, "origin": origin,
470482
])
471483
}
484+
session.onContextMenu = { [weak self] id, json in
485+
self?.emit("contextMenu", [
486+
"sessionId": sessionId, "id": id, "json": json,
487+
])
488+
}
472489
session.onMediaState = { [weak self] video, audio, setting in
473490
self?.emit("mediaState", [
474491
"sessionId": sessionId, "videoActive": video,

0 commit comments

Comments
 (0)