import AppKit import SstCore import SstData import SstUI import SwiftUI /// Text view of the Notitie editor; registers itself while it has the cursor. final class NoteTextView: NSTextView { override func becomeFirstResponder() -> Bool { let accepted = super.becomeFirstResponder() if accepted { NoteEditorRegistry.shared.focused = self } return accepted } override func resignFirstResponder() -> Bool { let accepted = super.resignFirstResponder() if accepted, NoteEditorRegistry.shared.focused === self { NoteEditorRegistry.shared.focused = nil } return accepted } /// Types dictated text at the cursor (replacing a selection) through the normal edit path, so undo works. func insertDictation(_ text: String) { let range = selectedRange() let ns = string as NSString var before: Character? if range.location > 0, range.location <= ns.length { before = ns.substring(with: ns.rangeOfComposedCharacterSequence(at: range.location - 1)).first } let prepared = DictationSpacing.prepared(text, characterBefore: before) if shouldChangeText(in: range, replacementString: prepared) { insertText(prepared, replacementRange: range) } scrollRangeToVisible(selectedRange()) } } @MainActor final class NoteEditorRegistry { static let shared = NoteEditorRegistry() weak var focused: NoteTextView? /// The editor, but only while it really has the cursor in the key window. var active: NoteTextView? { guard let view = focused, let window = view.window, window.isKeyWindow, window.firstResponder === view else { return nil } return view } } /// Sends dictated text into the Notitie editor. struct EditorInserter: TextInserting { func insert(_ text: String) async throws { await MainActor.run { NoteEditorRegistry.shared.active?.insertDictation(text) } } } struct NoteEditor: NSViewRepresentable { @Binding var text: String var isEditable: Bool func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let scroll = NSScrollView() scroll.drawsBackground = false scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.borderType = .noBorder let view = NoteTextView(frame: .zero) view.minSize = .zero view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) view.isVerticallyResizable = true view.isHorizontallyResizable = false view.autoresizingMask = [.width] view.textContainer?.widthTracksTextView = true view.textContainerInset = NSSize(width: Space.s5, height: Space.s5) view.isRichText = false view.allowsUndo = true view.drawsBackground = false view.isAutomaticQuoteSubstitutionEnabled = false view.isAutomaticDashSubstitutionEnabled = false view.delegate = context.coordinator view.string = text scroll.documentView = view context.coordinator.view = view apply(to: view) return scroll } func updateNSView(_ scroll: NSScrollView, context: Context) { context.coordinator.parent = self guard let view = context.coordinator.view else { return } if view.string != text, !view.hasMarkedText() { let selection = view.selectedRange() view.string = text let length = (text as NSString).length view.setSelectedRange(NSRange(location: min(selection.location, length), length: 0)) } view.isEditable = isEditable apply(to: view) } /// Font and colours come from the design tokens; colours follow the current appearance. private func apply(to view: NoteTextView) { let style = Typography.reading let font = NSFont.systemFont(ofSize: style.size, weight: .regular) view.font = font view.effectiveAppearance.performAsCurrentDrawingAppearance { let ink = NSColor(Palette.inkPrimary) view.textColor = ink view.insertionPointColor = ink } let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = max(0, style.line - style.size * 1.2) view.defaultParagraphStyle = paragraph view.typingAttributes = [.font: font, .foregroundColor: view.textColor ?? .labelColor, .paragraphStyle: paragraph] } final class Coordinator: NSObject, NSTextViewDelegate { var parent: NoteEditor weak var view: NoteTextView? init(_ parent: NoteEditor) { self.parent = parent } func textDidChange(_ notification: Notification) { guard let view else { return } parent.text = view.string } } }