diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp index b675b623bbd..b396bdd3f4e 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp @@ -378,10 +378,30 @@ class NativeEventListeners class WebBrowserComponent::Impl { public: - Impl (WebBrowserComponent& ownerIn, const Options& optionsIn) + Impl (WebBrowserComponent& ownerIn, const Options& optionsInRaw) : owner (ownerIn), options ([&] { + // Auto-register a native function so JS can flip the editable-focus + // routing without every consumer wiring up their own bridge plumbing. + // Only registered when native integration is already in use — adding + // it unconditionally would force `withNativeIntegrationEnabled` on for + // every WebBrowserComponent, which JUCE flags as a security risk for + // components loading untrusted content. + const bool nativeIntegrationInUse = optionsInRaw.getNativeIntegrationsEnabled() + || ! optionsInRaw.getNativeFunctions().empty(); + + const auto optionsIn = nativeIntegrationInUse + ? optionsInRaw.withNativeFunction ( + "__juceSetEditableFocusActive", + [this] (const Array& args, const std::function& completion) + { + if (! args.isEmpty()) + setEditableFocusActive (static_cast (args[0])); + completion ({}); + }) + : optionsInRaw; + makeFunctionsProviderIfNecessary (nativeFunctionsProvider, *this, optionsIn); if (nativeFunctionsProvider.has_value()) @@ -483,6 +503,11 @@ class WebBrowserComponent::Impl platform->focusGainedWithDirection (type, dir); } + void setEditableFocusActive (bool active) + { + platform->setEditableFocusActive (active); + } + struct Platform; private: @@ -503,6 +528,13 @@ class WebBrowserComponent::Impl virtual void focusGainedWithDirection (FocusChangeType, FocusChangeDirection) {} virtual void fallbackPaint (Graphics&) {} + + // Default no-op — platforms that support host keyboard pass-through + // (macOS WKWebView, Windows WebView2) override this to flip the + // routing of OS-level key events between the host responder chain + // and the embedded DOM. See WebBrowserComponent::setEditableFocusActive + // for the semantics. + virtual void setEditableFocusActive (bool) {} }; static void makeFunctionsProviderIfNecessary (std::optional& provider, @@ -724,6 +756,11 @@ void WebBrowserComponent::focusGainedWithDirection (FocusChangeType type, impl->focusGainedWithDirection (type, direction); } +void WebBrowserComponent::setEditableFocusActive (bool editableFocusActive) +{ + impl->setEditableFocusActive (editableFocusActive); +} + #endif } // namespace juce diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h index f80e59cb1f3..73b65d7fa02 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h @@ -609,6 +609,36 @@ class JUCE_API WebBrowserComponent : public Component */ void emitEventIfBrowserIsVisible (const Identifier& eventId, const var& object); + /** Controls whether key events received by the underlying OS web surface should be + forwarded to the host responder chain instead of being consumed by the web view. + + When the WebBrowserComponent is hosted inside a plugin, the OS web surface + (WKWebView on macOS, WebView2 on Windows) becomes first responder on click and + swallows keystrokes — preventing host shortcuts (transport, MIDI typing keyboards, + etc.) from reaching the DAW. JUCE's default behaviour is to forward those keys + to the host so the DAW remains controllable. Call this with `true` while an + editable DOM element (e.g. an ``) has focus so character input reaches + the DOM, and `false` again when the editable element loses focus. + + Frontend code can drive this through the auto-registered native function + `__juceSetEditableFocusActive`, available via JUCE's `getNativeFunction` helper: + + @code + import { getNativeFunction } from 'juce-framework-frontend'; + const setEditableFocus = getNativeFunction('__juceSetEditableFocusActive'); + document.addEventListener('focusin', e => { + const t = e.target; + const editable = + t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + t instanceof HTMLSelectElement || + (t instanceof HTMLElement && t.isContentEditable); + setEditableFocus(editable); + }); + @endcode + */ + void setEditableFocusActive (bool editableFocusActive); + //============================================================================== /** This callback is called when the browser is about to navigate to a new location. diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm index d2ab19eb652..a36acbca2bf 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm @@ -176,6 +176,22 @@ static var fromObject (id object) return getIvar (instance, lastFocusChangeMemberName); } +// Per-instance pointer to the WKWebViewImpl's std::atomic tracking whether +// the JS layer has reported editable DOM focus. Read on every key event; when +// false (the default) the responder forwards keys to the host so the DAW +// receives transport and shortcut keys instead of WKWebView swallowing them. +static const char* editableFocusActiveMemberName = "editableFocusActiveHandle"; + +[[maybe_unused]] static void setEditableFocusActiveHandle (id instance, std::atomic* flag) +{ + object_setInstanceVariable (instance, editableFocusActiveMemberName, flag); +} + +[[maybe_unused]] static std::atomic* getEditableFocusActiveHandle (id instance) +{ + return getIvar*> (instance, editableFocusActiveMemberName); +} + #if JUCE_MAC template struct WebViewKeyEquivalentResponder final : public ObjCClass @@ -186,10 +202,55 @@ explicit WebViewKeyEquivalentResponder (bool acceptsFirstMouse) : Base ("WebViewKeyEquivalentResponder_") { this->template addIvar (lastFocusChangeMemberName); + this->template addIvar*> (editableFocusActiveMemberName); + + // When an editable DOM element has focus (signalled by JS via + // __juceSetEditableFocusActive), keys are routed to WKWebView's default + // handlers so character input reaches the DOM. Otherwise we forward them + // to the next responder so the plugin host (DAW) receives shortcuts and + // MIDI-typing keys. Default is "forward to host" — the OS web surface + // would otherwise swallow every keystroke once it became first responder. + // + // The lambdas passed to addMethod must be captureless so JUCE's + // toFnPtr can lower them to plain function pointers, so the + // editable-focus read is inlined into each method rather than living + // in a local helper closure. + + this->addMethod (@selector (keyDown:), + [] (id self, SEL selector, NSEvent* event) + { + auto* handle = getEditableFocusActiveHandle (self); + + if (handle != nullptr && handle->load()) + Base::template sendSuperclassMessage (self, selector, event); + else + [[self nextResponder] keyDown:event]; + }); + + this->addMethod (@selector (keyUp:), + [] (id self, SEL selector, NSEvent* event) + { + auto* handle = getEditableFocusActiveHandle (self); + + if (handle != nullptr && handle->load()) + Base::template sendSuperclassMessage (self, selector, event); + else + [[self nextResponder] keyUp:event]; + }); this->addMethod (@selector (performKeyEquivalent:), [] (id self, SEL selector, NSEvent* event) { + // Outside of editable focus, return NO so AppKit walks + // the responder chain past us — that lets host Cmd-shortcuts + // (DAW save, undo, transport variants) reach the plugin + // host instead of being trapped by WKWebView. + auto* handle = getEditableFocusActiveHandle (self); + const bool editableFocusActive = handle != nullptr && handle->load(); + + if (! editableFocusActive) + return (BOOL) NO; + const auto isCommandDown = [event] { const auto modifierFlags = [event modifierFlags]; @@ -702,6 +763,7 @@ static void displayError (WebBrowserComponent* owner, NSError* error) groupName: nsEmptyString()]); setLastFocusChangeHandle (webView.get(), &lastFocusChange); + setEditableFocusActiveHandle (webView.get(), &editableFocusActive); webView.get().customUserAgent = juceStringToNS (userAgent); @@ -809,9 +871,15 @@ void evaluateJavascript (const String&, WebBrowserComponent::EvaluationCallback) jassertfalse; } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + private: WebBrowserComponent& browser; LastFocusChange lastFocusChange; + std::atomic editableFocusActive { false }; ObjCObjectHandle webView; ObjCObjectHandle clickListener; }; @@ -903,6 +971,7 @@ void evaluateJavascript (const String&, WebBrowserComponent::EvaluationCallback) configuration: config.get()]); setLastFocusChangeHandle (webView.get(), &lastFocusChange); + setEditableFocusActiveHandle (webView.get(), &editableFocusActive); #else webView.reset ([[WKWebView alloc] initWithFrame: CGRectMake (0, 0, 100.0f, 100.0f) configuration: config.get()]); @@ -1208,6 +1277,11 @@ void evaluateJavascript (const String& script, WebBrowserComponent::EvaluationCa }]; } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + private: static inline auto blankPageUrl = "about:blank"; @@ -1215,6 +1289,10 @@ void evaluateJavascript (const String& script, WebBrowserComponent::EvaluationCa DelegateConnector delegateConnector; bool allowAccessToEnclosingDirectory = false; LastFocusChange lastFocusChange; + // Default false → keyDown forwards to the host responder chain. Flipped + // to true by the JS layer via __juceSetEditableFocusActive when an + // editable DOM element gains focus, so character input reaches the DOM. + std::atomic editableFocusActive { false }; ObjCObjectHandle webView; ObjCObjectHandle webViewDelegate; String lastRequestedUrl, lastLoadedUrl; diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp index 1f2afa47212..cbf3c64e313 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp @@ -517,6 +517,11 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom webViewController->MoveFocus (moveFocusReason); } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + ~WebView2() override { if (webView2ConstructionHelper.webView2BeingCreated == this) @@ -961,6 +966,27 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom return S_OK; }).Get(), &moveFocusRequestedToken); + + // AcceleratorKeyPressed fires for accelerator keys (Tab, Esc, + // F-keys, modifier combos) before WebView2 processes them. Marking + // put_Handled(FALSE) yields the key to the host's window proc so + // the plugin host (DAW) receives Cmd/Ctrl shortcuts and transport + // controls — the default behaviour users expect. When the JS layer + // has reported editable DOM focus we set put_Handled(TRUE) so the + // WebView keeps the key for in-DOM editing (e.g. Tab to indent, + // Esc to dismiss a popover). This complements + // ICoreWebView2ControllerOptions2::AllowHostInputProcessing, which + // covers the character keys (spacebar, QWERTY) that + // AcceleratorKeyPressed does not fire for. + webViewController->add_AcceleratorKeyPressed ( + Callback ( + [this] (ICoreWebView2Controller*, ICoreWebView2AcceleratorKeyPressedEventArgs* args) -> HRESULT + { + if (args != nullptr) + args->put_Handled (editableFocusActive.load (std::memory_order_relaxed) ? TRUE : FALSE); + + return S_OK; + }).Get(), &acceleratorKeyPressedToken); } } @@ -994,6 +1020,9 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom { if (moveFocusRequestedToken.value != 0) webViewController->remove_MoveFocusRequested (moveFocusRequestedToken); + + if (acceleratorKeyPressedToken.value != 0) + webViewController->remove_AcceleratorKeyPressed (acceleratorKeyPressedToken); } } @@ -1057,8 +1086,8 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom webView2ConstructionHelper.viewsWaitingForCreation.erase (this); webView2ConstructionHelper.webView2BeingCreated = this; - webViewHandle.environment->CreateCoreWebView2Controller ((HWND) peer->getNativeHandle(), - Callback ( + const auto hostHwnd = (HWND) peer->getNativeHandle(); + auto completionHandler = Callback ( [weakThis = WeakReference { this }] (HRESULT, ICoreWebView2Controller* controller) -> HRESULT { if (weakThis != nullptr) @@ -1137,7 +1166,49 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom } return S_OK; - }).Get()); + }); + + // Prefer CreateCoreWebView2ControllerWithOptions so we can enable + // AllowHostInputProcessing — that routes keyboard/mouse input + // through the host's window proc, letting the plugin host (DAW) + // see character keys (spacebar, QWERTY) that WebView2 would + // otherwise consume entirely. AcceleratorKeyPressed handles the + // accelerator-key subset for runtimes/SDKs that don't expose the + // option. Falling back to the plain Create function keeps the + // backend working when either the environment (older runtime) or + // the controller options (older SDK) interface is unavailable. + const auto createWithHostInputProcessing = [&]() -> HRESULT + { + ComSmartPtr environment10; + webViewHandle.environment.QueryInterface (environment10); + + if (environment10 == nullptr) + return E_NOINTERFACE; + + ComSmartPtr controllerOptions; + + if (environment10->CreateCoreWebView2ControllerOptions (controllerOptions.resetAndGetPointerAddress()) != S_OK + || controllerOptions == nullptr) + { + return E_NOINTERFACE; + } + + ComSmartPtr controllerOptions2; + controllerOptions.QueryInterface (controllerOptions2); + + if (controllerOptions2 == nullptr) + return E_NOINTERFACE; + + if (controllerOptions2->put_AllowHostInputProcessing (TRUE) != S_OK) + return E_FAIL; + + return environment10->CreateCoreWebView2ControllerWithOptions (hostHwnd, + controllerOptions.get(), + completionHandler.Get()); + }(); + + if (! SUCCEEDED (createWithHostInputProcessing)) + webViewHandle.environment->CreateCoreWebView2Controller (hostHwnd, completionHandler.Get()); } } @@ -1206,16 +1277,23 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom ComSmartPtr webViewController; ComSmartPtr webView; - EventRegistrationToken navigationStartingToken { 0 }, - newWindowRequestedToken { 0 }, - windowCloseRequestedToken { 0 }, - navigationCompletedToken { 0 }, - webResourceRequestedToken { 0 }, - moveFocusRequestedToken { 0 }, - webMessageReceivedToken { 0 }; + EventRegistrationToken navigationStartingToken { 0 }, + newWindowRequestedToken { 0 }, + windowCloseRequestedToken { 0 }, + navigationCompletedToken { 0 }, + webResourceRequestedToken { 0 }, + moveFocusRequestedToken { 0 }, + webMessageReceivedToken { 0 }, + acceleratorKeyPressedToken { 0 }; bool inMoveFocusRequested = false; + // Default false → AcceleratorKeyPressed releases keys to the host's window + // proc so the DAW receives shortcuts. Flipped to true by the JS layer via + // __juceSetEditableFocusActive whenever an editable DOM element gains + // focus, so the WebView retains keys for in-DOM editing. + std::atomic editableFocusActive { false }; + struct URLRequest { String url;