446 lines
19 KiB
Swift
446 lines
19 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
import UniformTypeIdentifiers
|
||
|
||
@MainActor
|
||
enum NotesAppearance {
|
||
static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular)
|
||
static let paragraph: NSParagraphStyle = {
|
||
let style = NSMutableParagraphStyle()
|
||
style.lineSpacing = 5
|
||
style.paragraphSpacing = 8
|
||
return style
|
||
}()
|
||
}
|
||
|
||
@MainActor
|
||
final class NotesController: NSViewController, NSTextViewDelegate {
|
||
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil)
|
||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||
private let actions = ApplicationActionsController()
|
||
private let editor = NotesTextView()
|
||
private let status = NSTextField(labelWithString: "")
|
||
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
||
private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil)
|
||
private let formatting = NSStackView()
|
||
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||
("s", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||
("o", [.command, .shift]), ("l", [.command, .shift]), ("t", [.command, .shift]),
|
||
("u", [.command, .shift]),
|
||
]
|
||
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() {
|
||
let content = NotesView()
|
||
content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
|
||
view = content
|
||
browseButton.target = self
|
||
browseButton.action = #selector(browseNotes)
|
||
actionsButton.target = self
|
||
actionsButton.action = #selector(showActions)
|
||
let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p")
|
||
noteTitle.font = .systemFont(ofSize: 13)
|
||
noteTitle.textColor = SunsetPalette.secondaryText
|
||
noteTitle.alignment = .center
|
||
noteTitle.lineBreakMode = .byTruncatingTail
|
||
noteTitle.setAccessibilityLabel("Note title")
|
||
let header = NSView()
|
||
vimButton.target = self
|
||
vimButton.action = #selector(toggleVim)
|
||
vimButton.bezelStyle = .accessoryBarAction
|
||
vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium)
|
||
vimButton.contentTintColor = SunsetPalette.secondaryText
|
||
vimButton.setAccessibilityLabel("Toggle Vim editing")
|
||
vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual."
|
||
editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() }
|
||
editor.vimEnabled = defaults.bool(forKey: "notesVimEnabled")
|
||
for control in [noteTitle, vimButton] {
|
||
control.translatesAutoresizingMaskIntoConstraints = false
|
||
header.addSubview(control)
|
||
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||
vimButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
|
||
vimButton.widthAnchor.constraint(equalToConstant: 90),
|
||
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
|
||
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: vimButton.leadingAnchor, constant: -8),
|
||
])
|
||
actions.closed = { [weak self] in self?.focusEditor() }
|
||
actions.selectNote = { [weak self] in self?.switchNote($0) }
|
||
actions.newNote = { [weak self] in self?.newNote() }
|
||
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
||
actions.deleteNote = { [weak self] in self?.deleteNote() }
|
||
actions.copyNoteMarkdown = { [weak self] in self?.copyAsMarkdown() }
|
||
actions.exportNoteMarkdown = { [weak self] in self?.exportAsMarkdown() }
|
||
let tools = [
|
||
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||
("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"),
|
||
("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"),
|
||
("underline", "Underline"),
|
||
]
|
||
for (index, tool) in tools.enumerated() {
|
||
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
|
||
button.tag = index
|
||
let shortcut = Self.formattingKeys[index]
|
||
button.toolTip = "\(tool.1) (⌘\(shortcut.modifiers.contains(.shift) ? "⇧" : "")\(shortcut.key.uppercased()))"
|
||
formatting.addArrangedSubview(button)
|
||
}
|
||
formatting.spacing = 8
|
||
let formattingScroll = NSScrollView()
|
||
formattingScroll.drawsBackground = false
|
||
formattingScroll.contentView.drawsBackground = false
|
||
formattingScroll.hasHorizontalScroller = true
|
||
formattingScroll.autohidesScrollers = true
|
||
formattingScroll.scrollerStyle = .overlay
|
||
formattingScroll.verticalScrollElasticity = .none
|
||
formattingScroll.documentView = formatting
|
||
formatting.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
formatting.widthAnchor.constraint(equalToConstant: CGFloat(tools.count * 28 + (tools.count - 1) * 8)),
|
||
formatting.heightAnchor.constraint(equalToConstant: 28),
|
||
formattingScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 28),
|
||
formattingScroll.heightAnchor.constraint(equalToConstant: 36),
|
||
])
|
||
let footer = NSStackView(views: [formattingScroll, controls])
|
||
footer.alignment = .centerY
|
||
footer.spacing = 8
|
||
editor.delegate = self
|
||
editor.isRichText = true
|
||
editor.importsGraphics = true
|
||
editor.allowsUndo = true
|
||
editor.drawsBackground = false
|
||
editor.textColor = SunsetPalette.text
|
||
editor.insertionPointColor = SunsetPalette.accent
|
||
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.selectionFill]
|
||
editor.font = NotesAppearance.font
|
||
editor.defaultParagraphStyle = NotesAppearance.paragraph
|
||
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: 22, height: 8)
|
||
editor.setAccessibilityLabel("Note editor")
|
||
let scroll = LauncherScrollView()
|
||
scroll.edgeFadeHeight = 16
|
||
scroll.documentView = editor
|
||
scroll.hasVerticalScroller = true
|
||
scroll.autohidesScrollers = true
|
||
scroll.drawsBackground = false
|
||
scroll.scrollerStyle = .overlay
|
||
scroll.automaticallyAdjustsContentInsets = false
|
||
scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0,
|
||
bottom: scroll.edgeFadeHeight, right: 0)
|
||
status.font = .systemFont(ofSize: 12)
|
||
status.textColor = SunsetPalette.secondaryText
|
||
status.setAccessibilityLabel("Notes save status")
|
||
status.lineBreakMode = .byTruncatingTail
|
||
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
status.isHidden = true
|
||
let body = NSStackView(views: [scroll, status, footer])
|
||
body.orientation = .vertical
|
||
body.alignment = .leading
|
||
body.spacing = 0
|
||
for control in [header, body] {
|
||
control.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(control)
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
header.heightAnchor.constraint(equalToConstant: 36),
|
||
body.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
|
||
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
|
||
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -8),
|
||
status.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 28),
|
||
status.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -12),
|
||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80),
|
||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||
])
|
||
guard let store else {
|
||
editor.isEditable = false
|
||
for control in [browseButton, actionsButton] { control.isEnabled = false }
|
||
formatting.subviews.compactMap { $0 as? NSButton }.forEach { $0.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)
|
||
}
|
||
}
|
||
@objc private func toggleVim() {
|
||
editor.vimEnabled.toggle()
|
||
defaults.set(editor.vimEnabled, forKey: "notesVimEnabled")
|
||
focusEditor()
|
||
}
|
||
|
||
private func updateVimIndicator() {
|
||
vimButton.title = editor.vimEnabled ? editor.vimMode.rawValue : "Vim Off"
|
||
vimButton.setAccessibilityValue(editor.vimEnabled ? editor.vimMode.rawValue : "Off")
|
||
vimButton.state = editor.vimEnabled ? .on : .off
|
||
}
|
||
|
||
|
||
private func configureIcon(_ button: NSButton, symbol: String, label: String) {
|
||
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
|
||
.withSymbolConfiguration(.init(pointSize: 16, weight: .regular))
|
||
button.imagePosition = .imageOnly
|
||
button.bezelStyle = .accessoryBarAction
|
||
button.showsBorderOnlyWhileMouseInside = true
|
||
button.contentTintColor = SunsetPalette.secondaryText
|
||
button.toolTip = label
|
||
button.setAccessibilityLabel(label)
|
||
NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)])
|
||
}
|
||
|
||
private func iconButton(_ symbol: String, label: String, action: Selector) -> NSButton {
|
||
let button = NSButton(title: "", target: self, action: action)
|
||
configureIcon(button, symbol: symbol, label: label)
|
||
return button
|
||
}
|
||
|
||
|
||
|
||
@objc private func formatNote(_ sender: NSButton) {
|
||
editor.format(sender.tag)
|
||
focusEditor()
|
||
}
|
||
|
||
func focusEditor() { view.window?.makeFirstResponder(editor) }
|
||
|
||
func textDidChange(_ notification: Notification) {
|
||
isDirty = true
|
||
_ = save()
|
||
}
|
||
|
||
|
||
@discardableResult
|
||
func save() -> Bool {
|
||
guard isDirty, let store, let selectedID else { return true }
|
||
do {
|
||
try store.update(id: selectedID, contents: editor.documentContents)
|
||
isDirty = false
|
||
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
||
status.isHidden = true
|
||
return true
|
||
} catch {
|
||
showError(error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handle(_ selector: Selector) -> Bool {
|
||
if actions.isShown { return actions.handle(selector) }
|
||
if selector == #selector(NSResponder.cancelOperation(_:)), view.window?.firstResponder === editor {
|
||
return editor.handleVimEscape()
|
||
}
|
||
return false
|
||
}
|
||
|
||
func handleControlKey(_ key: String) -> Bool {
|
||
guard actions.isShown, key == "j" || key == "k" else { return false }
|
||
actions.moveSelection(by: key == "j" ? 1 : -1)
|
||
return true
|
||
}
|
||
|
||
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||
if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() }
|
||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||
let button = formatting.arrangedSubviews[index] as? NSButton else { return false }
|
||
formatNote(button)
|
||
return true
|
||
}
|
||
guard modifiers == .command else { return false }
|
||
switch key {
|
||
case "p": browseNotes()
|
||
case "k": showActions()
|
||
case "n": newNote()
|
||
default: return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
func close() -> Bool {
|
||
guard save() else { return false }
|
||
actions.close(animated: false)
|
||
closed?()
|
||
return true
|
||
}
|
||
|
||
func goBack() {
|
||
if actions.isShown { actions.close() }
|
||
else { _ = close() }
|
||
}
|
||
|
||
@objc private func newNote() {
|
||
actions.close(animated: false)
|
||
guard save(), let store else { return }
|
||
do { select(try store.create()); focusEditor() }
|
||
catch { showError(error) }
|
||
}
|
||
|
||
private func copyAsMarkdown() {
|
||
actions.close(animated: false)
|
||
do {
|
||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||
let clipboard = NSPasteboard.general
|
||
clipboard.clearContents()
|
||
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||
throw CocoaError(.fileWriteUnknown)
|
||
}
|
||
} catch { showExportError(error) }
|
||
}
|
||
|
||
private func exportAsMarkdown() {
|
||
guard let window = view.window, window.attachedSheet == nil else { return }
|
||
actions.close(animated: false)
|
||
do {
|
||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||
let panel = NSSavePanel()
|
||
panel.title = "Export as Markdown"
|
||
panel.prompt = "Export"
|
||
panel.canCreateDirectories = true
|
||
panel.nameFieldStringValue = export.hasImages ? "Note" : "Note.md"
|
||
if export.hasImages {
|
||
panel.message = "Create a new folder containing note.md and its images. Choose a folder name that does not already exist."
|
||
} else {
|
||
panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
|
||
}
|
||
panel.beginSheetModal(for: window) { [weak self] response in
|
||
guard let self else { return }
|
||
defer { self.focusEditor() }
|
||
guard response == .OK, let url = panel.url else { return }
|
||
do { try export.write(to: url) }
|
||
catch { self.showExportError(error) }
|
||
}
|
||
} catch { showExportError(error) }
|
||
}
|
||
|
||
private func showExportError(_ error: Error) {
|
||
status.isHidden = false
|
||
status.stringValue = "Couldn’t export note: \(error.localizedDescription)"
|
||
status.toolTip = status.stringValue
|
||
status.textColor = .systemRed
|
||
}
|
||
|
||
|
||
private func deleteNote() {
|
||
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||
actions.close(animated: false)
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .warning
|
||
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
|
||
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
|
||
alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}"
|
||
let delete = alert.addButton(withTitle: "Delete Note")
|
||
delete.contentTintColor = .systemRed
|
||
delete.keyEquivalent = "\r"
|
||
alert.window.initialFirstResponder = delete
|
||
alert.window.defaultButtonCell = delete.cell as? NSButtonCell
|
||
alert.beginSheetModal(for: window) { [weak self] response in
|
||
guard let self else { return }
|
||
defer { self.focusEditor() }
|
||
guard response == .alertSecondButtonReturn, self.selectedID == id,
|
||
self.save(), let store = self.store else { return }
|
||
do {
|
||
try store.delete(id: id)
|
||
self.selectedID = nil
|
||
self.defaults.removeObject(forKey: "selectedNote")
|
||
self.editor.setDocumentContents(NSAttributedString(string: ""))
|
||
self.editor.undoManager?.removeAllActions()
|
||
self.editor.isEditable = false
|
||
self.noteTitle.stringValue = "Untitled Note"
|
||
self.select(try store.notes.first?.id ?? store.create())
|
||
} catch { self.showError(error) }
|
||
}
|
||
}
|
||
|
||
private func switchNote(_ id: UUID) {
|
||
guard save() else { return }
|
||
select(id)
|
||
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.setDocumentContents(note.contents)
|
||
editor.undoManager?.removeAllActions()
|
||
editor.isEditable = true
|
||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||
noteTitle.stringValue = note.title
|
||
status.isHidden = true
|
||
}
|
||
|
||
|
||
private func showError(_ error: Error) {
|
||
status.isHidden = false
|
||
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
|
||
status.toolTip = status.stringValue
|
||
status.textColor = .systemRed
|
||
}
|
||
|
||
@objc func browseNotes() {
|
||
guard let store else { return }
|
||
actions.showNotes(store.notes.map { (id: $0.id, title: $0.title) },
|
||
selected: selectedID, relativeTo: browseButton)
|
||
}
|
||
|
||
@objc func showActions() {
|
||
guard store != nil else { return }
|
||
actions.showNotesActions(relativeTo: actionsButton)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class NotesView: NSView {
|
||
var commandShortcut: ((NSEvent) -> Bool)?
|
||
|
||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||
if commandShortcut?(event) == true {
|
||
return true
|
||
}
|
||
return super.performKeyEquivalent(with: event)
|
||
}
|
||
}
|
||
|