Files
conjure/Sources/Conjure/NotesController.swift
T

237 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ConjureKit
import UniformTypeIdentifiers
@MainActor
final class NotesController: NSViewController, NSTextViewDelegate {
private let notePicker = NSPopUpButton()
private let editor = NotesTextView()
private let status = NSTextField(labelWithString: "")
private let defaults: UserDefaults
private var store: NotesStore?
private var selectedID: UUID?
private var isDirty = false
private var loadError: Error?
var closed: (() -> Void)?
init(defaults: UserDefaults = .standard, directory: URL? = nil) {
self.defaults = defaults
super.init(nibName: nil, bundle: nil)
do {
store = try directory.map { try NotesStore(directory: $0) } ?? NotesStore()
} catch {
loadError = error
}
}
required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") }
override func loadView() {
view = NSView()
notePicker.target = self
notePicker.action = #selector(switchNote)
notePicker.setAccessibilityLabel("Choose a note")
let create = NSButton(title: "New Note", target: self, action: #selector(newNote))
create.keyEquivalent = "n"
create.keyEquivalentModifierMask = .command
let image = NSButton(title: "Insert Image…", target: self, action: #selector(insertImage))
let header = NSStackView(views: [notePicker, create, image])
header.spacing = 8
notePicker.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
editor.delegate = self
editor.isRichText = true
editor.importsGraphics = true
editor.allowsUndo = true
editor.drawsBackground = false
editor.textColor = SunsetPalette.text
editor.insertionPointColor = SunsetPalette.accent
editor.font = .systemFont(ofSize: 14)
editor.isAutomaticQuoteSubstitutionEnabled = false
editor.isAutomaticDashSubstitutionEnabled = false
editor.isAutomaticTextReplacementEnabled = false
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.widthTracksTextView = true
editor.textContainerInset = NSSize(width: 10, height: 10)
editor.setAccessibilityLabel("Note editor")
let scroll = NSScrollView()
scroll.documentView = editor
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.wantsLayer = true
scroll.layer?.cornerRadius = 8
scroll.layer?.borderWidth = 0.5
scroll.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail
for control in [header, scroll, status] {
control.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(control)
}
NSLayoutConstraint.activate([
header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
header.topAnchor.constraint(equalTo: view.topAnchor),
notePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10),
scroll.leadingAnchor.constraint(equalTo: header.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: header.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: status.topAnchor, constant: -8),
status.leadingAnchor.constraint(equalTo: header.leadingAnchor),
status.trailingAnchor.constraint(equalTo: header.trailingAnchor),
status.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12),
])
guard let store else {
editor.isEditable = false
for control in [notePicker, create, image] { control.isEnabled = false }
showError(loadError!)
return
}
do {
let savedID = defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))
let id = store.notes.first { $0.id == savedID }?.id ?? store.notes.first?.id
select(try id ?? store.create())
} catch {
editor.isEditable = false
showError(error)
}
}
func focusEditor() { view.window?.makeFirstResponder(editor) }
func textDidChange(_ notification: Notification) {
isDirty = true
MarkdownHighlighting.apply(to: editor)
_ = save()
}
@discardableResult
func save() -> Bool {
guard isDirty, let store, let selectedID else { return true }
do {
try store.update(id: selectedID, contents: editor.attributedString())
isDirty = false
updatePicker()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
status.textColor = SunsetPalette.secondaryText
return true
} catch {
showError(error)
return false
}
}
func goBack() {
guard save() else { return }
closed?()
}
@objc private func newNote() {
guard save(), let store else { return }
do { select(try store.create()); focusEditor() }
catch { showError(error) }
}
@objc private func switchNote() {
guard let value = notePicker.selectedItem?.representedObject as? UUID else { return }
guard save() else { updatePicker(); return }
select(value)
focusEditor()
}
private func select(_ id: UUID) {
guard let note = store?.notes.first(where: { $0.id == id }) else { return }
selectedID = id
defaults.set(id.uuidString, forKey: "selectedNote")
editor.textStorage?.setAttributedString(note.contents)
editor.undoManager?.removeAllActions()
editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
MarkdownHighlighting.apply(to: editor)
updatePicker()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
status.textColor = SunsetPalette.secondaryText
}
private func updatePicker() {
notePicker.removeAllItems()
for note in store?.notes ?? [] {
notePicker.menu?.addItem(withTitle: note.title, action: nil, keyEquivalent: "")
notePicker.lastItem?.representedObject = note.id
if note.id == selectedID { notePicker.select(notePicker.lastItem) }
}
}
private func showError(_ error: Error) {
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
status.toolTip = status.stringValue
status.textColor = .systemRed
}
@objc private func insertImage() {
guard let window = view.window, editor.isEditable else { return }
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image]
panel.canChooseDirectories = false
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let self, let url = panel.url else { return }
do { try self.editor.insertImage(at: url); self.focusEditor() }
catch { self.showError(error) }
}
}
}
@MainActor
final class NotesTextView: NSTextView {
func insertImage(at url: URL) throws {
guard let image = NSImage(contentsOf: url) else {
throw CocoaError(.fileReadCorruptFile)
}
let attachment = NSTextAttachment()
attachment.attachmentCell = NSTextAttachmentCell(imageCell: image)
attachment.fileWrapper = try FileWrapper(url: url, options: .immediate)
let contents = NSAttributedString(attachment: attachment)
insertText(contents, replacementRange: selectedRange())
}
}
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 17, weight: .semibold), .foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 14)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(.systemFont(ofSize: 14), toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`|(?ms)^```[^\\n]*\\n.*?(?:^```[^\\n]*$|\\z)"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
]
static func apply(to editor: NSTextView) {
guard let storage = editor.textStorage else { return }
let text = editor.string
let range = NSRange(location: 0, length: text.utf16.count)
storage.beginEditing()
defer { storage.endEditing() }
storage.enumerateAttribute(.attachment, in: range) { value, _, _ in
guard let attachment = value as? NSTextAttachment,
let cell = attachment.attachmentCell as? NSTextAttachmentCell,
let image = cell.image else { return }
let size = image.size
let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1))
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
}
storage.addAttributes([.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text], range: range)
for (expression, attributes) in rules {
for match in expression.matches(in: text, range: range) {
storage.addAttributes(attributes, range: match.range)
}
}
editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text]
}
}