feat: add persistent Markdown notes
This commit is contained in:
@@ -70,6 +70,9 @@ struct ConjureApp {
|
||||
editItem.submenu = editMenu
|
||||
menu.addItem(editItem)
|
||||
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
||||
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
|
||||
redo.keyEquivalentModifierMask = [.command, .shift]
|
||||
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
editMenu.addItem(.separator())
|
||||
@@ -155,6 +158,9 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
startupRecoveryMessage = nil
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
launcher.saveNotes() ? .terminateNow : .terminateCancel
|
||||
}
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
shortcuts.stop()
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var launcherFrameBeforeReview: NSRect?
|
||||
private var translation: TranslationController?
|
||||
private var emojiPicker: EmojiPickerController?
|
||||
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
|
||||
private var notes: NotesController?
|
||||
private let notesDirectory: URL?
|
||||
private var isCommandOpen: Bool { activeCommand != nil }
|
||||
private var activeCommand: Application.Command?
|
||||
private var pendingPresentation: (() -> Void)?
|
||||
private static let emojiIcon: NSImage = {
|
||||
@@ -52,11 +54,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}()
|
||||
|
||||
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(),
|
||||
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
|
||||
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil) {
|
||||
self.history = history
|
||||
self.uninstaller = uninstaller
|
||||
self.defaults = defaults
|
||||
self.pasteboard = pasteboard
|
||||
self.notesDirectory = notesDirectory
|
||||
let window = LauncherPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||
styleMask: [.borderless],
|
||||
@@ -85,6 +88,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
else { self.moveSelection(by: offset) }
|
||||
}
|
||||
window.commandNavigation = { [weak self] key in self?.emojiPicker?.handleControlKey(key) ?? false }
|
||||
window.usesTextNavigation = { [weak self] in self?.notes != nil }
|
||||
window.dismiss = { [weak self] in self?.dismiss() }
|
||||
window.canUseNumberShortcuts = { [weak self] in
|
||||
guard let self else { return false }
|
||||
@@ -122,7 +126,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
uninstallReview?.goBack()
|
||||
return
|
||||
}
|
||||
closeCommand()
|
||||
guard closeCommand() else { return }
|
||||
actions.close(animated: false)
|
||||
(window as? LauncherPanel)?.beginNumberHintSession()
|
||||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||
@@ -140,7 +144,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
showWindow(nil)
|
||||
NSApp.activate()
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
window?.makeFirstResponder(searchField)
|
||||
if let notes { notes.focusEditor() }
|
||||
else { window?.makeFirstResponder(searchField) }
|
||||
(window as? LauncherPanel)?.sampleNumberHints()
|
||||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
refreshApplications()
|
||||
@@ -164,7 +169,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
uninstallReview?.goBack()
|
||||
return
|
||||
}
|
||||
closeCommand()
|
||||
guard closeCommand() else { return }
|
||||
actions.close(animated: false)
|
||||
alignmentGuides?.end()
|
||||
let previous = previousApplication
|
||||
@@ -567,10 +572,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
cell.locationLabel.isHidden = true
|
||||
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
|
||||
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
|
||||
cell.imageView?.image = entry.command == .emoji ? Self.emojiIcon
|
||||
: NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
|
||||
switch entry.command {
|
||||
case .emoji:
|
||||
cell.imageView?.image = Self.emojiIcon
|
||||
cell.toolTip = "Search and copy emoji"
|
||||
case .notes:
|
||||
cell.imageView?.image = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil)
|
||||
cell.toolTip = "Write notes with Markdown and images"
|
||||
default:
|
||||
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
|
||||
cell.toolTip = "Translate English to Japanese"
|
||||
}
|
||||
cell.imageView?.contentTintColor = entry.command == .emoji ? nil : SunsetPalette.accent
|
||||
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
|
||||
} else {
|
||||
let application = results[row]
|
||||
cell.textField?.stringValue = application.name
|
||||
@@ -794,11 +807,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
@objc private func goBackCommand() {
|
||||
translation?.goBack()
|
||||
emojiPicker?.goBack()
|
||||
notes?.goBack()
|
||||
}
|
||||
|
||||
private func closeCommand() {
|
||||
func saveNotes() -> Bool { notes?.save() ?? true }
|
||||
|
||||
@discardableResult
|
||||
private func closeCommand() -> Bool {
|
||||
guard saveNotes() else { return false }
|
||||
notes?.closed?()
|
||||
translation?.goBack()
|
||||
emojiPicker?.close()
|
||||
return true
|
||||
}
|
||||
|
||||
private func showCommand(_ command: Application.Command) {
|
||||
@@ -825,6 +845,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
controller = tool
|
||||
placeholder = "Search emoji by name or :shortcode:…"
|
||||
accessibilityLabel = "Search emoji"
|
||||
case .notes:
|
||||
let tool = NotesController(defaults: defaults, directory: notesDirectory)
|
||||
notes = tool
|
||||
controller = tool
|
||||
placeholder = "Conjure Notes"
|
||||
accessibilityLabel = "Conjure Notes"
|
||||
}
|
||||
let previousQuery = searchField.stringValue
|
||||
let previousPlaceholder = searchField.placeholderAttributedString
|
||||
@@ -833,6 +859,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
controller.view.removeFromSuperview()
|
||||
self.translation = nil
|
||||
self.emojiPicker = nil
|
||||
self.notes = nil
|
||||
self.searchField.isEnabled = true
|
||||
self.activeCommand = nil
|
||||
self.commandBack.isHidden = true
|
||||
self.commandBack.target = nil
|
||||
@@ -851,6 +879,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
translation?.closed = close
|
||||
emojiPicker?.closed = close
|
||||
notes?.closed = close
|
||||
actions.close(animated: false)
|
||||
(window as? LauncherPanel)?.resetNumberHints()
|
||||
openButton.keyEquivalent = ""
|
||||
@@ -864,6 +893,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||||
)
|
||||
searchField.setAccessibilityLabel(accessibilityLabel)
|
||||
if command == .notes {
|
||||
searchField.stringValue = "Conjure Notes"
|
||||
searchField.isEnabled = false
|
||||
}
|
||||
for view in launcherViews.dropFirst() {
|
||||
view.isHidden = true
|
||||
view.setAccessibilityHidden(true)
|
||||
@@ -876,7 +909,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
|
||||
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||||
])
|
||||
window?.makeFirstResponder(searchField)
|
||||
if let notes { notes.focusEditor() }
|
||||
else { window?.makeFirstResponder(searchField) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1149,7 @@ final class LauncherPanel: NSPanel {
|
||||
var numberHintsChanged: (() -> Void)?
|
||||
var launchNumber: ((Int) -> Void)?
|
||||
var commandNavigation: ((String) -> Bool)?
|
||||
var usesTextNavigation: (() -> Bool)?
|
||||
private var numberShortcuts = CommandNumberShortcuts()
|
||||
private var commandHoldTask: Task<Void, Never>?
|
||||
var numberHintsVisible: Bool { numberShortcuts.isVisible }
|
||||
@@ -1218,7 +1253,7 @@ final class LauncherPanel: NSPanel {
|
||||
}
|
||||
resetNumberHints(modifierFlags: event.modifierFlags)
|
||||
}
|
||||
if event.type == .keyDown, attachedSheet == nil,
|
||||
if event.type == .keyDown, attachedSheet == nil, usesTextNavigation?() != true,
|
||||
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
|
||||
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
|
||||
switch event.charactersIgnoringModifiers?.lowercased() {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user