From 630a6bb7bdd688d60d7fb3e89df3eb5d08577bcc Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Thu, 2 Jul 2026 21:16:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(input):=20browser=20editing=20+=20zoom=20s?= =?UTF-8?q?hortcuts=20(=E2=8C=98C/X/V/A/Z,=20=E2=8C=98+/-/0)=20for=20the?= =?UTF-8?q?=20OSR=20webview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In off-screen-rendering mode there's no AppKit responder chain, so a raw ⌘-key event never becomes an editor action or a zoom — a focused CefWebView couldn't copy/paste/select-all or zoom like a real browser (the #1 nit reported for webviews in Campus). Wire the standard shortcuts to explicit commands: - Native (main.mm): kOpEditCommand → DoEditCommand runs a focused-frame edit command via CefFrame::Copy/Cut/Paste/SelectAll/Undo/Redo (no-ops when nothing is focused/selected). Zoom reuses the existing kOpSetZoom. - Plugin (Swift + Dart): editCommand op + CefWebController.copy/cut/paste/selectAll/undo/redo; setZoomLevel already existed. - CefWebView._onKeyEvent: intercept ⌘C/X/V/A/Z + ⌘⇧Z → the edit commands, and ⌘=/-/0 → a tracked content-zoom level (1.2^level steps, clamped ~48%..207%). Editing on key-down; zoom repeat-friendly. The ⌘/⇧ modifier keydowns still forward to the page (a browser fires keydown for them); the shortcut letter itself is consumed. - Protocol version bumped 1 → 2 (new inbound op); the handshake refuses a mismatched host, so this ships with a republished host + atomic consumer pin bump. Tests: 9 new widget tests drive the real focus/key path and assert the channel calls (⌘C→editCommand(0) … ⌘0→setZoomLevel(0)); full suite 141 green; analyze clean. Native CefFrame edit commands are the canonical CEF-OSR approach (not unit-testable here) — validated live in Campus. Co-Authored-By: Claude Fable 5 --- lib/src/cef_web_controller.dart | 17 +++++ lib/src/cef_web_view.dart | 71 +++++++++++++++++++ .../macos/Classes/CefProfileHost.swift | 2 +- .../macos/Classes/CefWebSession.swift | 7 ++ .../macos/Classes/FlutterCefPlugin.swift | 3 + .../flutter_cef_macos/native/cef_host/main.mm | 28 +++++++- test/cef_web_view_test.dart | 49 +++++++++++++ 7 files changed, 175 insertions(+), 2 deletions(-) diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 81f762e..aa29d70 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -800,6 +800,23 @@ class CefWebController { Future 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 copy() => _editCommand(0); + Future cut() => _editCommand(1); + Future paste() => _editCommand(2); + Future selectAll() => _editCommand(3); + Future undo() => _editCommand(4); + Future redo() => _editCommand(5); + + Future _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 diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart index e6b5442..671ed23 100644 --- a/lib/src/cef_web_view.dart +++ b/lib/src/cef_web_view.dart @@ -1,4 +1,6 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -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 @@ -317,6 +325,14 @@ class _CefWebViewState extends State // ── 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; @@ -458,6 +474,61 @@ class _CefWebViewState extends State 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 diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index 7727b31..edfb865 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -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 diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index 5c28b99..18b2e31 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -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 @@ -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. diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift index a194864..85e2adb 100644 --- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift +++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift @@ -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) diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 4f6200d..f4b7188 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -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; @@ -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, @@ -1715,6 +1716,25 @@ void DoExecuteJs(const std::shared_ptr& slot, const std::string& code) { void DoSetZoom(const std::shared_ptr& 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, int command) { + CEF_REQUIRE_UI_THREAD(); + if (!slot->browser) return; + CefRefPtr 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 @@ -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; diff --git a/test/cef_web_view_test.dart b/test/cef_web_view_test.dart index 9b3d8e1..f3530cb 100644 --- a/test/cef_web_view_test.dart +++ b/test/cef_web_view_test.dart @@ -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); + }); + } }