Files
sst/Sources/Sst/NoteEditor.swift

131 lines
4.8 KiB
Swift

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
}
}
}