Skip to content
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
17 changes: 17 additions & 0 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,23 @@ class CefWebController {
Future<void> setZoomLevel(double level) => _channel
.invokeMethod('setZoomLevel', {'sessionId': sessionId, 'level': level});

/// Run a browser edit command on the FOCUSED frame. In off-screen-rendering
/// mode a raw ⌘C/⌘V key event does NOT trigger these (there is no AppKit
/// responder chain to translate the shortcut into an editor action), so the
/// host must invoke them explicitly — [CefWebView] wires the standard
/// shortcuts to these. All are no-ops when nothing is focused/selected.
/// [copy]/[cut] write the current selection to the system clipboard; [paste]
/// inserts from it into the focused editable element.
Future<void> copy() => _editCommand(0);
Future<void> cut() => _editCommand(1);
Future<void> paste() => _editCommand(2);
Future<void> selectAll() => _editCommand(3);
Future<void> undo() => _editCommand(4);
Future<void> redo() => _editCommand(5);

Future<void> _editCommand(int command) => _channel.invokeMethod(
'editCommand', {'sessionId': sessionId, 'command': command});

/// Pause or resume frame production. `setVisible(false)` calls CEF's
/// `WasHidden(true)` so the page stops painting (no `OnPaint`, the compositor
/// idles) — the browser stays alive, so it's a cheap pause/resume, not a
Expand Down
71 changes: 71 additions & 0 deletions lib/src/cef_web_view.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@

import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
Expand All @@ -12,6 +14,12 @@ import 'cef_web_controller.dart';
/// swipe distance closer to a native browser. Tunable.
const double _kTrackpadScrollGain = 3.0;

// Content-zoom keyboard stepping (⌘+/-/0). CEF's zoom *factor* is 1.2^level, so a
// 0.5 step ≈ 9.5% per press; the range clamps to ~48%..207%.
const double _kZoomStep = 0.5;
const double _kZoomMin = -4.0;
const double _kZoomMax = 4.0;

/// A live Chromium (CEF) browser rendered into a Flutter [Texture].
///
/// The page renders off-screen in a `cef_host` subprocess and is shown here as
Expand Down Expand Up @@ -317,6 +325,14 @@ class _CefWebViewState extends State<CefWebView>

// ── input forwarding ──────────────────────────────────────────────
int _lastButton = 0;
// Current content-zoom level (CEF factor = 1.2^level), driven by ⌘+/-/0. View-
// local: a fresh session (recovery) starts at 100%, matching this reset to 0.
double _zoomLevel = 0;

void _applyZoom(double level) {
_zoomLevel = level;
unawaited(_controller.setZoomLevel(level));
}
// Multi-click tracking — the page keys word/line selection off clickCount,
// which Flutter's Listener doesn't surface.
Duration _lastDownAt = Duration.zero;
Expand Down Expand Up @@ -458,6 +474,61 @@ class _CefWebViewState extends State<CefWebView>
return KeyEventResult.skipRemainingHandlers;
}

// Standard browser shortcuts. In OSR there's no AppKit responder chain, so a
// raw ⌘-key event never becomes an editor action or a zoom — route the common
// ones to explicit controller calls so a focused webview behaves like a real
// browser (⌘C/X/V/A/Z, ⌘+/-/0). Handled on key-down; zoom also on repeat
// (hold to keep zooming). Returning `handled` keeps the raw combo off the page
// AND off Flutter's own shortcuts.
final isMetaOnly = keys.isMetaPressed &&
!keys.isControlPressed &&
!keys.isAltPressed;
if (isMetaOnly && (event is KeyDownEvent || event is KeyRepeatEvent)) {
final k = event.logicalKey;
// Editing commands: key-down only (repeat would re-cut/re-paste).
if (event is KeyDownEvent && !keys.isShiftPressed) {
if (k == LogicalKeyboardKey.keyC) {
unawaited(_controller.copy());
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.keyX) {
unawaited(_controller.cut());
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.keyV) {
unawaited(_controller.paste());
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.keyA) {
unawaited(_controller.selectAll());
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.keyZ) {
unawaited(_controller.undo());
return KeyEventResult.handled;
}
}
if (event is KeyDownEvent &&
keys.isShiftPressed &&
k == LogicalKeyboardKey.keyZ) {
unawaited(_controller.redo());
return KeyEventResult.handled;
}
// Content zoom (⌘+/-/0). `=`/`+` in, `-` in, `0` reset. Repeat-friendly.
if (k == LogicalKeyboardKey.equal || k == LogicalKeyboardKey.add) {
_applyZoom((_zoomLevel + _kZoomStep).clamp(_kZoomMin, _kZoomMax));
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.minus || k == LogicalKeyboardKey.numpadSubtract) {
_applyZoom((_zoomLevel - _kZoomStep).clamp(_kZoomMin, _kZoomMax));
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.digit0 || k == LogicalKeyboardKey.numpad0) {
_applyZoom(0);
return KeyEventResult.handled;
}
}

final mods = _cefModifiers();
final wkc = cefWindowsKeyCode(event.logicalKey);
// native_key_code MUST be the macOS keycode for the physical key — CEF on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CefProfileHost {
// processGone) instead of silently mis-parsing frames into frozen/blank tiles; the
// skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale
// embedded copies (the content-hash fetch can't drift on the normal path).
static let protocolVersion: UInt8 = 1
static let protocolVersion: UInt8 = 2

// Profile identity / config.
let profileId: String
Expand Down
7 changes: 7 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class CefWebSession: NSObject, FlutterTexture {
private static let opPointer: UInt8 = 0x10
private static let opResize: UInt8 = 0x11
private static let opInvalidate: UInt8 = 0x37 // us -> cef_host: force a repaint (re-kick a stuck resize)
private static let opEditCommand: UInt8 = 0x38 // us -> cef_host: {u8 cmd} run a focused-frame edit command (copy/cut/paste/selectAll/undo/redo)
private static let opKey: UInt8 = 0x12
private static let opFindResult: UInt8 = 0x0e
private static let opJsDialog: UInt8 = 0x0f
Expand Down Expand Up @@ -367,6 +368,12 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opSetZoom, p)
}

/// Run a browser edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo
/// 5=redo) on the focused frame in the cef_host subprocess.
func editCommand(_ command: Int) {
sendFrame(Self.opEditCommand, [UInt8(truncatingIfNeeded: command)])
}

/// Pause/resume frame production in the cef_host subprocess. `false` calls
/// CefBrowserHost::WasHidden(true) so an off-screen tile stops rendering; the
/// session and browser stay alive, so it's a cheap toggle, not a teardown.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
case "setZoomLevel":
withSession(args) { $0.setZoomLevel(args["level"] as? Double ?? 0) }
result(nil)
case "editCommand":
withSession(args) { $0.editCommand(args["command"] as? Int ?? -1) }
result(nil)
case "setVisible":
withSession(args) { $0.setVisible(args["visible"] as? Bool ?? true) }
result(nil)
Expand Down
28 changes: 27 additions & 1 deletion packages/flutter_cef_macos/native/cef_host/main.mm
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
// stale embedded copy). BUMP THIS on any semantic change to the kOp wire protocol
// below, together with CefProfileHost.protocolVersion (Swift side) — the two must
// stay equal. Hosts predating the handshake send a 1-byte payload and read as v0.
constexpr uint8_t kCefHostProtocolVersion = 1;
constexpr uint8_t kCefHostProtocolVersion = 2;

// ---- Opcodes ----
constexpr uint8_t kOpPresent = 0x01;
Expand Down Expand Up @@ -161,6 +161,7 @@
constexpr uint8_t kOpSetVisible = 0x35; // {u8 visible} -> CefBrowserHost::WasHidden(!visible)
constexpr uint8_t kOpResolveTargetId = 0x36; // {} resolve this browser's CDP targetId (CEF-2b) -> kOpTargetId
constexpr uint8_t kOpInvalidate = 0x37; // {} C1: force a repaint (Invalidate PET_VIEW) to re-kick a stalled first frame
constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} run a focused-frame edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo)

// ---- Shared runtime state ----
// Atomic: the reader thread reads it (ReadAll), SendFrame on any thread reads it,
Expand Down Expand Up @@ -1715,6 +1716,25 @@ void DoExecuteJs(const std::shared_ptr<Slot>& slot, const std::string& code) {
void DoSetZoom(const std::shared_ptr<Slot>& slot, double level) {
if (slot->browser) slot->browser->GetHost()->SetZoomLevel(level);
}
// Run a browser edit command on the FOCUSED frame. OSR has no AppKit responder
// chain, so a raw ⌘C/⌘V key event never becomes an editor action — the host
// invokes these explicitly (CefWebView wires the shortcuts). CefFrame's methods
// are no-ops when nothing is focused/selected. UI-thread only.
void DoEditCommand(const std::shared_ptr<Slot>& slot, int command) {
CEF_REQUIRE_UI_THREAD();
if (!slot->browser) return;
CefRefPtr<CefFrame> frame = slot->browser->GetFocusedFrame();
if (!frame) return;
switch (command) {
case 0: frame->Copy(); break;
case 1: frame->Cut(); break;
case 2: frame->Paste(); break;
case 3: frame->SelectAll(); break;
case 4: frame->Undo(); break;
case 5: frame->Redo(); break;
default: break;
}
}
// Off-screen render gating. WasHidden(true) makes CEF stop producing frames
// (no OnPaint, the compositor idles) until WasHidden(false); the browser stays
// alive, so this is a cheap pause/resume — not a teardown. The host pauses a
Expand Down Expand Up @@ -2197,6 +2217,12 @@ void IpcReadLoop() {
CefPostTask(TID_UI, base::BindOnce(&DoSetZoom, slot, ReadF64BE(p)));
break;
}
case kOpEditCommand: {
if (!slot) break;
if (plen < 1) break;
CefPostTask(TID_UI, base::BindOnce(&DoEditCommand, slot, int{p[0]}));
break;
}
case kOpSetVisible: {
if (!slot) break;
bool vis = plen >= 1 ? p[0] != 0 : true;
Expand Down
49 changes: 49 additions & 0 deletions test/cef_web_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,53 @@ void main() {
expect(callsTo('imeCommitText'), isEmpty);
});
}

// Browser editing shortcuts (⌘C/X/V/A/Z) route to explicit edit commands —
// OSR has no responder chain, so a raw ⌘-key never becomes an editor action.
int? editCommandOf(MethodCall c) =>
(c.arguments as Map)['command'] as int?;

for (final (name, key, shift, cmd) in <(String, LogicalKeyboardKey, bool, int)>[
('⌘C copies', LogicalKeyboardKey.keyC, false, 0),
('⌘X cuts', LogicalKeyboardKey.keyX, false, 1),
('⌘V pastes', LogicalKeyboardKey.keyV, false, 2),
('⌘A selects all', LogicalKeyboardKey.keyA, false, 3),
('⌘Z undoes', LogicalKeyboardKey.keyZ, false, 4),
('⌘⇧Z redoes', LogicalKeyboardKey.keyZ, true, 5),
]) {
testWidgets('$name via an editCommand (not a raw key)', (tester) async {
await focusedView(tester);
log.clear();
await tester.sendKeyDownEvent(LogicalKeyboardKey.meta);
if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(key);
if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.sendKeyUpEvent(LogicalKeyboardKey.meta);
await tester.pump();
final edits = callsTo('editCommand');
expect(edits, hasLength(1), reason: 'exactly one edit command');
expect(editCommandOf(edits.single), cmd);
// The shortcut LETTER isn't also forwarded to the page as a text char
// (the ⌘/⇧ modifier keydowns themselves do forward, like a real browser).
expect(callsTo('imeCommitText'), isEmpty);
});
}

for (final (name, key, expectLevel) in <(String, LogicalKeyboardKey, double)>[
('⌘= zooms in', LogicalKeyboardKey.equal, 0.5),
('⌘- zooms out', LogicalKeyboardKey.minus, -0.5),
('⌘0 resets zoom', LogicalKeyboardKey.digit0, 0.0),
]) {
testWidgets('$name via setZoomLevel', (tester) async {
await focusedView(tester);
log.clear();
await tester.sendKeyDownEvent(LogicalKeyboardKey.meta);
await tester.sendKeyEvent(key);
await tester.sendKeyUpEvent(LogicalKeyboardKey.meta);
await tester.pump();
final zooms = callsTo('setZoomLevel');
expect(zooms, hasLength(1));
expect((zooms.single.arguments as Map)['level'], expectLevel);
});
}
}
Loading