Fix markdown panel text click focus (#991)

* Add markdown click regression test

* Fix markdown panel click focus

* Preserve markdown text selection

* Make markdown observer tests deterministic
This commit is contained in:
Lawrence Chen 2026-03-05 22:38:10 -08:00 committed by GitHub
parent 46e810fef2
commit e04787789e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 175 additions and 3 deletions

View file

@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import MarkdownUI
@ -30,9 +31,12 @@ struct MarkdownPanelView: View {
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
.overlay {
if isVisibleInUI {
// Observe left-clicks without intercepting them so markdown text
// selection and link activation continue to use the native path.
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
}
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
@ -283,3 +287,67 @@ struct MarkdownPanelView: View {
}
}
}
private struct MarkdownPointerObserver: NSViewRepresentable {
let onPointerDown: () -> Void
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
let view = MarkdownPanelPointerObserverView()
view.onPointerDown = onPointerDown
return view
}
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
nsView.onPointerDown = onPointerDown
}
}
final class MarkdownPanelPointerObserverView: NSView {
var onPointerDown: (() -> Void)?
private var eventMonitor: Any?
override var mouseDownCanMoveWindow: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
installEventMonitorIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
func shouldHandle(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown,
let window,
event.window === window,
!isHiddenOrHasHiddenAncestor else { return false }
let point = convert(event.locationInWindow, from: nil)
return bounds.contains(point)
}
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
guard shouldHandle(event) else { return event }
DispatchQueue.main.async { [weak self] in
self?.onPointerDown?()
}
return event
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
self?.handleEventIfNeeded(event) ?? event
}
}
}

View file

@ -10830,6 +10830,110 @@ final class FileDropOverlayViewTests: XCTestCase {
}
}
@MainActor
final class MarkdownPanelPointerObserverViewTests: XCTestCase {
private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
return window
}
private func makeMouseEvent(
type: NSEvent.EventType,
location: NSPoint,
window: NSWindow,
eventNumber: Int = 1
) -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: type,
location: location,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: eventNumber,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Expected to create mouse event")
}
return event
}
func testObserverTriggersFocusForVisibleLeftClickInsideBounds() {
let window = makeWindow()
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
overlay.autoresizingMask = [.width, .height]
let focusExpectation = expectation(description: "observer forwards focus callback")
var pointerDownCount = 0
overlay.onPointerDown = {
pointerDownCount += 1
focusExpectation.fulfill()
}
contentView.addSubview(overlay)
_ = overlay.handleEventIfNeeded(
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window)
)
wait(for: [focusExpectation], timeout: 1.0)
XCTAssertEqual(pointerDownCount, 1)
}
func testObserverIgnoresOutsideOrForeignWindowClicks() {
let window = makeWindow()
defer { window.orderOut(nil) }
let otherWindow = makeWindow()
defer { otherWindow.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds)
overlay.autoresizingMask = [.width, .height]
let noFocusExpectation = expectation(description: "observer ignores invalid clicks")
noFocusExpectation.isInverted = true
var pointerDownCount = 0
overlay.onPointerDown = {
pointerDownCount += 1
noFocusExpectation.fulfill()
}
contentView.addSubview(overlay)
_ = overlay.handleEventIfNeeded(
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window)
)
_ = overlay.handleEventIfNeeded(
makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2)
)
_ = overlay.handleEventIfNeeded(
makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3)
)
wait(for: [noFocusExpectation], timeout: 0.1)
XCTAssertEqual(pointerDownCount, 0)
}
func testObserverDoesNotParticipateInHitTesting() {
let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30)))
}
}
final class BrowserLinkOpenSettingsTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!