feat: Notitie editor in the app, dictation routed to system or editor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user