Handle multi-button mouse events in the browser panel's WKWebView: - Mouse back button (button 3) triggers goBack(), forward button (button 4) triggers goForward(), enabling side-button navigation on mice like Logitech - Middle-click (button 2) on a link opens it in a new browser tab by hit-testing the click position via JavaScript and routing through the existing openLinkInNewTab mechanism
132 lines
5.3 KiB
Swift
132 lines
5.3 KiB
Swift
import AppKit
|
|
import WebKit
|
|
|
|
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
|
|
/// preventing the app menu/SwiftUI Commands from receiving them. Route menu
|
|
/// key equivalents first so app-level shortcuts continue to work when WebKit is
|
|
/// the first responder.
|
|
final class CmuxWebView: WKWebView {
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
|
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
|
return true
|
|
}
|
|
|
|
// Handle app-level shortcuts that are not menu-backed (for example split commands).
|
|
// Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them.
|
|
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
|
return true
|
|
}
|
|
|
|
return super.performKeyEquivalent(with: event)
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
// Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent.
|
|
// Route them through the same app-level shortcut handler as a fallback.
|
|
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
|
|
AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
|
return
|
|
}
|
|
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
// MARK: - Focus on click
|
|
|
|
// The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive
|
|
// clicks when a WKWebView is underneath — AppKit delivers the click to the deepest
|
|
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
|
|
// bonsplit focus tracks which pane the user clicked in.
|
|
override func mouseDown(with event: NSEvent) {
|
|
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
|
|
super.mouseDown(with: event)
|
|
}
|
|
|
|
// MARK: - Mouse back/forward buttons & middle-click
|
|
|
|
override func otherMouseDown(with event: NSEvent) {
|
|
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
|
|
// Consume the event so WebKit doesn't handle it.
|
|
switch event.buttonNumber {
|
|
case 3:
|
|
goBack()
|
|
return
|
|
case 4:
|
|
goForward()
|
|
return
|
|
default:
|
|
break
|
|
}
|
|
super.otherMouseDown(with: event)
|
|
}
|
|
|
|
override func otherMouseUp(with event: NSEvent) {
|
|
// Middle-click (button 2) on a link opens it in a new tab.
|
|
if event.buttonNumber == 2 {
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
findLinkAtPoint(point) { [weak self] url in
|
|
guard let self, let url else { return }
|
|
NotificationCenter.default.post(
|
|
name: .webViewMiddleClickedLink,
|
|
object: self,
|
|
userInfo: ["url": url]
|
|
)
|
|
}
|
|
return
|
|
}
|
|
super.otherMouseUp(with: event)
|
|
}
|
|
|
|
/// Use JavaScript to find the nearest anchor element at the given view-local point.
|
|
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
|
// WKWebView's coordinate system is flipped (origin top-left for web content).
|
|
let flippedY = bounds.height - point.y
|
|
let js = """
|
|
(() => {
|
|
let el = document.elementFromPoint(\(point.x), \(flippedY));
|
|
while (el) {
|
|
if (el.tagName === 'A' && el.href) return el.href;
|
|
el = el.parentElement;
|
|
}
|
|
return '';
|
|
})();
|
|
"""
|
|
evaluateJavaScript(js) { result, _ in
|
|
guard let href = result as? String, !href.isEmpty,
|
|
let url = URL(string: href) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
completion(url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Drag-and-drop passthrough
|
|
|
|
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
|
|
// Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text,
|
|
// so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead
|
|
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
|
|
// AppKit only bubbles up through superviews, not siblings.
|
|
//
|
|
// Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't
|
|
// route drags here, so they reach the SwiftUI overlay drop zones as intended.
|
|
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
|
|
// No-op: suppress WKWebView's automatic drag type registration.
|
|
}
|
|
|
|
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
|
super.willOpenMenu(menu, with: event)
|
|
|
|
for item in menu.items {
|
|
// Rename "Open Link in New Window" to "Open Link in New Tab".
|
|
// The UIDelegate's createWebViewWith already handles the action
|
|
// by opening the link as a new surface in the same pane.
|
|
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|
|
|| item.title.contains("Open Link in New Window") {
|
|
item.title = "Open Link in New Tab"
|
|
}
|
|
}
|
|
}
|
|
}
|