diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index aa9dd33..b483959 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -16,6 +16,24 @@ import AppKit extension NativeTextViewCoordinator { + /// Supplies a per-document `UndoManager` to the text view. + /// + /// AppKit reuses one `NSTextView` across every open document, so the built-in + /// view-wide undo manager would blend files together (and used to be wiped on + /// each switch). Returning a manager keyed on the current `documentId` gives + /// each file its own undo stack that survives switching away and back. + /// Returning the *same* instance for a given document on every call is + /// required — a fresh manager per call breaks undo. + public func undoManager(for view: NSTextView) -> UndoManager? { + let key = documentId ?? "__default__" + if let existing = undoManagers[key] { + return existing + } + let manager = UndoManager() + undoManagers[key] = manager + return manager + } + /// Force base typingAttributes on every change so AppKit's auto-inheritance /// can't bleed a heading paragraphStyle into the trailing extra-line /// fragment's metrics. diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 2fa786f..f21e8c9 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -22,6 +22,12 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// Remembered scroll offset (`bounds.origin.y`) per `documentId` — saved on /// switch-away, restored on switch-back. var scrollOffsets: [String: CGFloat] = [:] + /// Per-`documentId` undo manager. AppKit reuses a single `NSTextView` across + /// all open documents, so its built-in (view-wide) undo manager would mix + /// files together. Keying a manager on the current document gives each file + /// its own undo stack that survives switching away and back. Vended through + /// the `undoManager(for:)` delegate method; pruned alongside `scrollOffsets`. + var undoManagers: [String: UndoManager] = [:] @Binding var text: String @Binding var isWikiLinkActive: Bool var fontName: String diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 59d1ed7..bc4c3fd 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -367,6 +367,16 @@ public struct NativeTextViewWrapper: NSViewRepresentable { $0.key == documentId || retained.contains($0.key) } } + // Evict undo stacks for documents no longer retained (keep the + // current one). removeAllActions() first so a stale registered undo + // can't later fire against a swapped-out document. + let staleUndoKeys = context.coordinator.undoManagers.keys.filter { key in + key != documentId && key != "__default__" && !retained.contains(key) + } + for key in staleUndoKeys { + context.coordinator.undoManagers[key]?.removeAllActions() + context.coordinator.undoManagers.removeValue(forKey: key) + } } let wtActive: Bool = { @@ -479,8 +489,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { retainedScrollDocumentIds?.contains(outgoingId) ?? true { context.coordinator.scrollOffsets[outgoingId] = nsView.contentView.bounds.origin.y } + // Per-document undo: close the OUTGOING document's open coalescing group + // (while its manager is still active), then switch the active documentId so + // `undoManager(for:)` starts vending the INCOMING document's own manager. We + // no longer clear undo here — that `removeAllActions()` is what killed Cmd+Z + // across a file switch. + textView.breakUndoCoalescing() context.coordinator.documentId = documentId - textView.undoManager?.removeAllActions() context.coordinator.didInitialFormatting = false context.coordinator.didEnsureLayoutForCurrentDocument = false context.coordinator.resetImageEmbedState()