131 lines
4.8 KiB
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
|
|
}
|
|
}
|
|
}
|