feat: add persistent Markdown notes

This commit is contained in:
2026-10-17 00:53:28 +02:00
parent 0f49209d95
commit e36a8bbc6b
13 changed files with 584 additions and 23 deletions
+236
View File
@@ -0,0 +1,236 @@
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]
}
}