fix(editor): prevent recursive selection updates

This commit is contained in:
Feror 2026-05-29 19:02:51 +02:00
parent 527a99c2e7
commit 5e1b7ad885

View file

@ -37,10 +37,12 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
} }
public func updateSource(_ source: String) { public func updateSource(_ source: String) {
guard state.document.source != source else { return }
state.updateSource(source) state.updateSource(source)
} }
public func updateSelection(_ selection: EditorSelection) { public func updateSelection(_ selection: EditorSelection) {
guard state.selection != selection else { return }
state.updateSelection(selection) state.updateSelection(selection)
} }
@ -172,13 +174,15 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
guard let textView = scrollView.documentView as? NSTextView else { return } guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text { if textView.string != text {
context.coordinator.performProgrammaticUpdate {
textView.string = text textView.string = text
} }
}
let selectedRange = selection.range let selectedRange = selection.range
if textView.selectedRange() != selectedRange, if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count { selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange) context.coordinator.setSelection(selectedRange, in: textView)
} }
context.coordinator.applyHybridAttributes(to: textView) context.coordinator.applyHybridAttributes(to: textView)
@ -186,32 +190,39 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
final class Coordinator: NSObject, NSTextViewDelegate { final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView var parent: NativeMarkdownTextView
private var isApplyingAttributes = false private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
init(_ parent: NativeMarkdownTextView) { init(_ parent: NativeMarkdownTextView) {
self.parent = parent self.parent = parent
} }
func textDidChange(_ notification: Notification) { func textDidChange(_ notification: Notification) {
guard !isApplyingAttributes, guard !isPerformingProgrammaticUpdate else { return }
let textView = notification.object as? NSTextView guard let textView = notification.object as? NSTextView else { return }
else { return }
parent.text = textView.string parent.text = textView.string
parent.selection = EditorSelection(range: textView.selectedRange()) parent.selection = EditorSelection(range: textView.selectedRange())
lastStyledText = nil
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
} }
func textViewDidChangeSelection(_ notification: Notification) { func textViewDidChangeSelection(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return } guard let textView = notification.object as? NSTextView else { return }
parent.selection = EditorSelection(range: textView.selectedRange()) let newSelection = EditorSelection(range: textView.selectedRange())
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
parent.selection = newSelection
} }
func applyHybridAttributes(to textView: NSTextView) { func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return } guard let textStorage = textView.textStorage else { return }
isApplyingAttributes = true guard shouldRestyle(textView.string) else { return }
let selectedRange = textView.selectedRange() let selectedRange = textView.selectedRange()
performProgrammaticUpdate {
MarkdownTextStyler.apply( MarkdownTextStyler.apply(
to: textStorage, to: textStorage,
activeLineIndex: parent.activeLineIndex, activeLineIndex: parent.activeLineIndex,
@ -221,8 +232,37 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
secondaryTextColor: .secondaryLabelColor, secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor accentColor: .controlAccentColor
) )
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange) textView.setSelectedRange(selectedRange)
isApplyingAttributes = false }
}
lastStyledText = textView.string
lastStyledActiveLineIndex = parent.activeLineIndex
}
func setSelection(_ range: NSRange, in textView: NSTextView) {
guard textView.selectedRange() != range else { return }
performProgrammaticUpdate {
textView.setSelectedRange(range)
}
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
} }
} }
} }
@ -254,34 +294,44 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
func updateUIView(_ textView: UITextView, context: Context) { func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self context.coordinator.parent = self
if textView.text != text { if textView.text != text {
context.coordinator.performProgrammaticUpdate {
textView.text = text textView.text = text
} }
}
context.coordinator.applyHybridAttributes(to: textView) context.coordinator.applyHybridAttributes(to: textView)
} }
final class Coordinator: NSObject, UITextViewDelegate { final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView var parent: NativeMarkdownTextView
private var isApplyingAttributes = false private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
init(_ parent: NativeMarkdownTextView) { init(_ parent: NativeMarkdownTextView) {
self.parent = parent self.parent = parent
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
guard !isApplyingAttributes else { return } guard !isPerformingProgrammaticUpdate else { return }
parent.text = textView.text parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange) parent.selection = EditorSelection(range: textView.selectedRange)
lastStyledText = nil
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
} }
func textViewDidChangeSelection(_ textView: UITextView) { func textViewDidChangeSelection(_ textView: UITextView) {
parent.selection = EditorSelection(range: textView.selectedRange) guard !isPerformingProgrammaticUpdate else { return }
let newSelection = EditorSelection(range: textView.selectedRange)
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView) applyHybridAttributes(to: textView)
parent.selection = newSelection
} }
func applyHybridAttributes(to textView: UITextView) { func applyHybridAttributes(to textView: UITextView) {
isApplyingAttributes = true guard shouldRestyle(textView.text) else { return }
let selectedRange = textView.selectedRange let selectedRange = textView.selectedRange
performProgrammaticUpdate {
MarkdownTextStyler.apply( MarkdownTextStyler.apply(
to: textView.textStorage, to: textView.textStorage,
activeLineIndex: parent.activeLineIndex, activeLineIndex: parent.activeLineIndex,
@ -291,8 +341,30 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
secondaryTextColor: .secondaryLabel, secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue accentColor: .systemBlue
) )
if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange textView.selectedRange = selectedRange
isApplyingAttributes = false }
}
lastStyledText = textView.text
lastStyledActiveLineIndex = parent.activeLineIndex
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
} }
} }
} }