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 {
textView.string = text context.coordinator.performProgrammaticUpdate {
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,43 +190,79 @@ 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()
MarkdownTextStyler.apply( performProgrammaticUpdate {
to: textStorage, MarkdownTextStyler.apply(
activeLineIndex: parent.activeLineIndex, to: textStorage,
backgroundColor: .textBackgroundColor, activeLineIndex: parent.activeLineIndex,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), backgroundColor: .textBackgroundColor,
textColor: .labelColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
secondaryTextColor: .secondaryLabelColor, textColor: .labelColor,
accentColor: .controlAccentColor secondaryTextColor: .secondaryLabelColor,
) accentColor: .controlAccentColor
textView.setSelectedRange(selectedRange) )
isApplyingAttributes = false if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
}
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,45 +294,77 @@ 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 {
textView.text = text context.coordinator.performProgrammaticUpdate {
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
MarkdownTextStyler.apply( performProgrammaticUpdate {
to: textView.textStorage, MarkdownTextStyler.apply(
activeLineIndex: parent.activeLineIndex, to: textView.textStorage,
backgroundColor: .systemBackground, activeLineIndex: parent.activeLineIndex,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10), backgroundColor: .systemBackground,
textColor: .label, activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
secondaryTextColor: .secondaryLabel, textColor: .label,
accentColor: .systemBlue secondaryTextColor: .secondaryLabel,
) accentColor: .systemBlue
textView.selectedRange = selectedRange )
isApplyingAttributes = false if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange
}
}
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
} }
} }
} }