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:
parent
46e810fef2
commit
e04787789e
2 changed files with 175 additions and 3 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue