fix(editor): restore viewport after mouse click scroll
This commit is contained in:
parent
bfedd11186
commit
5fca1c8c92
2 changed files with 54 additions and 8 deletions
|
|
@ -209,7 +209,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||||
}
|
}
|
||||||
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in
|
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in
|
||||||
coordinator?.recordUserEditingInteraction(interactionKind)
|
coordinator?.recordUserEditingInteraction(interactionKind, in: textView)
|
||||||
}
|
}
|
||||||
textView.isRichText = false
|
textView.isRichText = false
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
|
|
@ -274,6 +274,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
fileprivate var viewportStabilityEvents: [ViewportStabilityEvent] = []
|
fileprivate var viewportStabilityEvents: [ViewportStabilityEvent] = []
|
||||||
private var lastUserInteractionKind: EditorInteractionKind = .programmatic
|
private var lastUserInteractionKind: EditorInteractionKind = .programmatic
|
||||||
private var pendingSelectionInteractionKind: EditorInteractionKind?
|
private var pendingSelectionInteractionKind: EditorInteractionKind?
|
||||||
|
private var pendingMouseVisibleOrigin: NSPoint?
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
@ -320,6 +321,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
if interactionKind != .programmatic {
|
if interactionKind != .programmatic {
|
||||||
hasUserActivatedEditing = true
|
hasUserActivatedEditing = true
|
||||||
}
|
}
|
||||||
|
defer {
|
||||||
|
if interactionKind == .mouse {
|
||||||
|
pendingMouseVisibleOrigin = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind))
|
applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind))
|
||||||
parent.selection = newSelection
|
parent.selection = newSelection
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +351,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
? ViewportPresentationAnchor.capture(
|
? ViewportPresentationAnchor.capture(
|
||||||
in: textView,
|
in: textView,
|
||||||
selectedRange: selectedRange,
|
selectedRange: selectedRange,
|
||||||
lineIndex: currentLineIndex
|
lineIndex: currentLineIndex,
|
||||||
|
visibleOrigin: visibleOriginOverride(for: cause)
|
||||||
)
|
)
|
||||||
: nil
|
: nil
|
||||||
let start = Date()
|
let start = Date()
|
||||||
|
|
@ -420,8 +427,13 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind))
|
applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind))
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordUserEditingInteraction(_ interactionKind: EditorInteractionKind) {
|
func recordUserEditingInteraction(_ interactionKind: EditorInteractionKind, in textView: NSTextView) {
|
||||||
lastUserInteractionKind = interactionKind
|
lastUserInteractionKind = interactionKind
|
||||||
|
if interactionKind == .mouse {
|
||||||
|
pendingMouseVisibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
||||||
|
} else {
|
||||||
|
pendingMouseVisibleOrigin = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSelection(
|
func setSelection(
|
||||||
|
|
@ -452,6 +464,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
return !visibleRect.contains(point)
|
return !visibleRect.contains(point)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func visibleOriginOverride(for cause: PresentationUpdateCause) -> NSPoint? {
|
||||||
|
guard case .selectionChange(.mouse) = cause else { return nil }
|
||||||
|
return pendingMouseVisibleOrigin
|
||||||
|
}
|
||||||
|
|
||||||
func performProgrammaticUpdate(_ updates: () -> Void) {
|
func performProgrammaticUpdate(_ updates: () -> Void) {
|
||||||
programmaticUpdateDepth += 1
|
programmaticUpdateDepth += 1
|
||||||
defer {
|
defer {
|
||||||
|
|
@ -783,7 +800,8 @@ private struct ViewportPresentationAnchor {
|
||||||
static func capture(
|
static func capture(
|
||||||
in textView: NSTextView,
|
in textView: NSTextView,
|
||||||
selectedRange: NSRange,
|
selectedRange: NSRange,
|
||||||
lineIndex: DocumentLineIndex
|
lineIndex: DocumentLineIndex,
|
||||||
|
visibleOrigin visibleOriginOverride: NSPoint? = nil
|
||||||
) -> ViewportPresentationAnchor? {
|
) -> ViewportPresentationAnchor? {
|
||||||
guard let scrollView = textView.enclosingScrollView,
|
guard let scrollView = textView.enclosingScrollView,
|
||||||
textView.string.utf16.count > 0
|
textView.string.utf16.count > 0
|
||||||
|
|
@ -796,7 +814,7 @@ private struct ViewportPresentationAnchor {
|
||||||
)
|
)
|
||||||
guard let point = textView.presentationAnchorPoint(at: location) else { return nil }
|
guard let point = textView.presentationAnchorPoint(at: location) else { return nil }
|
||||||
|
|
||||||
let visibleOrigin = scrollView.contentView.bounds.origin
|
let visibleOrigin = visibleOriginOverride ?? scrollView.contentView.bounds.origin
|
||||||
return ViewportPresentationAnchor(
|
return ViewportPresentationAnchor(
|
||||||
characterLocation: location,
|
characterLocation: location,
|
||||||
visibleOrigin: visibleOrigin,
|
visibleOrigin: visibleOrigin,
|
||||||
|
|
@ -1130,7 +1148,7 @@ public final class HybridMarkdownLiveEditorHarness {
|
||||||
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
|
||||||
}
|
}
|
||||||
self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in
|
self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in
|
||||||
coordinator?.recordUserEditingInteraction(interactionKind)
|
coordinator?.recordUserEditingInteraction(interactionKind, in: textView)
|
||||||
}
|
}
|
||||||
self.textView.string = source
|
self.textView.string = source
|
||||||
self.textView.delegate = coordinator
|
self.textView.delegate = coordinator
|
||||||
|
|
@ -1192,11 +1210,19 @@ public final class HybridMarkdownLiveEditorHarness {
|
||||||
setSelection(range, interactionKind: .mouse)
|
setSelection(range, interactionKind: .mouse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func simulateMouseSelectionAfterNativeScroll(_ range: NSRange, nativeScrollY: CGFloat) {
|
||||||
|
coordinator.recordUserEditingInteraction(.mouse, in: textView)
|
||||||
|
scrollViewportWithoutSync(toY: nativeScrollY)
|
||||||
|
textView.setSelectedRange(range)
|
||||||
|
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) {
|
private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) {
|
||||||
if interactionKind == .programmatic {
|
if interactionKind == .programmatic {
|
||||||
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
|
||||||
} else {
|
} else {
|
||||||
coordinator.recordUserEditingInteraction(interactionKind)
|
coordinator.recordUserEditingInteraction(interactionKind, in: textView)
|
||||||
}
|
}
|
||||||
coordinator.setSelection(range, in: textView, interactionKind: interactionKind)
|
coordinator.setSelection(range, in: textView, interactionKind: interactionKind)
|
||||||
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
||||||
|
|
@ -1220,11 +1246,15 @@ public final class HybridMarkdownLiveEditorHarness {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func scrollViewport(toY y: CGFloat) {
|
public func scrollViewport(toY y: CGFloat) {
|
||||||
|
scrollViewportWithoutSync(toY: y)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollViewportWithoutSync(toY y: CGFloat) {
|
||||||
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
||||||
let target = NSPoint(x: 0, y: max(0, min(y, maxY)))
|
let target = NSPoint(x: 0, y: max(0, min(y, maxY)))
|
||||||
scrollView.contentView.scroll(to: target)
|
scrollView.contentView.scroll(to: target)
|
||||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||||
syncState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func viewportOrigin() -> CGPoint {
|
public func viewportOrigin() -> CGPoint {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,22 @@ final class HybridMarkdownLiveEditorHarnessTests: XCTestCase {
|
||||||
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testNativeMouseScrollDuringTopClickIsRestored() {
|
||||||
|
let source = (["# Heading", "Opening paragraph"] + (1...80).map { "Line \($0)" })
|
||||||
|
.joined(separator: "\n")
|
||||||
|
let harness = HybridMarkdownLiveEditorHarness(source: source)
|
||||||
|
harness.simulateLaunchFirstResponder()
|
||||||
|
harness.scrollViewport(toY: 0)
|
||||||
|
|
||||||
|
let paragraphLocation = (source as NSString).range(of: "Opening").location
|
||||||
|
harness.simulateMouseSelectionAfterNativeScroll(
|
||||||
|
NSRange(location: paragraphLocation, length: 0),
|
||||||
|
nativeScrollY: 48
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(harness.viewportOrigin().y, 0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
|
func testLiveParagraphGeometryReturnsAfterClickAndFocusAway() throws {
|
||||||
let source = """
|
let source = """
|
||||||
# Heading
|
# Heading
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue