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