745 lines
39 KiB
Swift
745 lines
39 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
import Highlighter
|
||
|
||
@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 formatting = NSStackView()
|
||
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.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)
|
||
configureIcon(browseButton, symbol: "note.text", label: "Browse notes (⌘P)")
|
||
browseButton.keyEquivalent = "p"
|
||
actionsButton.target = self
|
||
actionsButton.action = #selector(showActions)
|
||
configureIcon(actionsButton, symbol: "command", label: "Notes actions (⌘K)")
|
||
let create = iconButton("plus", label: "New note (⌘N)", action: #selector(newNote))
|
||
let controls = NSStackView(views: [actionsButton, browseButton, create])
|
||
controls.spacing = 2
|
||
noteTitle.font = .systemFont(ofSize: 13)
|
||
noteTitle.textColor = SunsetPalette.secondaryText
|
||
noteTitle.alignment = .center
|
||
noteTitle.lineBreakMode = .byTruncatingTail
|
||
noteTitle.setAccessibilityLabel("Note title")
|
||
let header = NSView()
|
||
for control in [noteTitle, controls] {
|
||
control.translatesAutoresizingMaskIntoConstraints = false
|
||
header.addSubview(control)
|
||
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
controls.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8),
|
||
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
|
||
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: controls.leadingAnchor, constant: -16),
|
||
])
|
||
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() }
|
||
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"),
|
||
]
|
||
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 footer = NSStackView(views: [formatting, NSView()])
|
||
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: 32),
|
||
body.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
|
||
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
|
||
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -6),
|
||
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: 28),
|
||
])
|
||
guard let store else {
|
||
editor.isEditable = false
|
||
for control in [browseButton, actionsButton, create] { 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)
|
||
}
|
||
}
|
||
|
||
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) {
|
||
guard editor.isEditable else { return }
|
||
let contents = editor.markdownContents
|
||
let source = contents.string as NSString
|
||
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
||
if sender.tag == 6 {
|
||
selection = source.lineRange(for: selection)
|
||
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
|
||
}
|
||
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
|
||
5: ("[", "](url)"), 6: ("```\n", "\n```")]
|
||
if let (open, close) = wrappers[sender.tag] {
|
||
let before = selection.location - open.utf16.count
|
||
let after = NSMaxRange(selection)
|
||
var stars = 0
|
||
if open == "*" {
|
||
while selection.location > stars, after + stars < source.length,
|
||
source.character(at: selection.location - stars - 1) == 42,
|
||
source.character(at: after + stars) == 42 { stars += 1 }
|
||
}
|
||
if before >= 0, after + close.utf16.count <= source.length,
|
||
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
|
||
open != "*" || stars % 2 == 1 {
|
||
let body = contents.attributedSubstring(from: selection)
|
||
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
||
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
||
} else {
|
||
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
|
||
body.append(contents.attributedSubstring(from: selection))
|
||
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
|
||
replaceMarkdown(body, range: selection,
|
||
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
|
||
}
|
||
} else {
|
||
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
|
||
guard let prefix = prefixes[sender.tag] else { return }
|
||
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
|
||
? selection.length - 1 : selection.length
|
||
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
|
||
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
|
||
let rows = body.string.components(separatedBy: "\n")
|
||
var offset = body.length, firstDelta = 0
|
||
for (index, line) in rows.enumerated().reversed() {
|
||
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
|
||
if index == rows.count - 1, index > 0, line.isEmpty { continue }
|
||
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
|
||
let content = String(line.dropFirst(indent.count))
|
||
let item = Self.listMatch(in: content as NSString)
|
||
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
|
||
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
|
||
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
|
||
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
|
||
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
|
||
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
|
||
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
|
||
}
|
||
let start = max(lines.location, selection.location + firstDelta)
|
||
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
|
||
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
|
||
}
|
||
focusEditor()
|
||
}
|
||
|
||
func focusEditor() { view.window?.makeFirstResponder(editor) }
|
||
|
||
func textDidChange(_ notification: Notification) {
|
||
isDirty = true
|
||
MarkdownHighlighting.apply(to: editor)
|
||
editor.refreshTasks()
|
||
_ = save()
|
||
}
|
||
|
||
private static let listItem = try! NSRegularExpression(
|
||
pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX]?)\\]([ \\t]+|$))?"
|
||
)
|
||
|
||
private static func listMatch(in line: NSString) -> NSTextCheckingResult? {
|
||
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
|
||
let marker = line.substring(with: item.range(at: 2))
|
||
// Spaced thematic breaks are not list items or list ancestors.
|
||
if marker == "-" || marker == "*" {
|
||
let characters = (line as String).lazy.filter { !$0.isWhitespace }
|
||
let bullet = marker.first!
|
||
if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return nil }
|
||
}
|
||
return item
|
||
}
|
||
|
||
private static func indentationWidth(_ indentation: String) -> Int {
|
||
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
||
}
|
||
|
||
private static func outdentLength(_ indentation: String) -> Int {
|
||
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
|
||
}
|
||
|
||
private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? {
|
||
var marker = line.substring(with: item.range(at: 2))
|
||
if item.range(at: 3).location != NSNotFound {
|
||
guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil }
|
||
marker = "\(number + 1)" + line.substring(with: item.range(at: 4))
|
||
}
|
||
var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5))
|
||
if item.range(at: 6).location != NSNotFound {
|
||
prefix += "[ ]" + line.substring(with: item.range(at: 7))
|
||
}
|
||
return prefix
|
||
}
|
||
|
||
private func setSelectionWithUndo(_ selection: NSRange) {
|
||
let previous = editor.selectedRange()
|
||
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
|
||
editor.setSelectedRange(selection)
|
||
}
|
||
|
||
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
|
||
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
|
||
}
|
||
|
||
private func replaceMarkdown(_ replacement: NSAttributedString, range: NSRange, selection: NSRange) {
|
||
editor.undoManager?.beginUndoGrouping()
|
||
// Native text undo restores its diff boundary, not the user's selection.
|
||
setSelectionWithUndo(editor.selectedRange())
|
||
editor.replaceMarkdown(replacement, range: range)
|
||
setSelectionWithUndo(editor.renderedRange(for: selection))
|
||
editor.undoManager?.endUndoGrouping()
|
||
}
|
||
|
||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
|
||
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
|
||
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
|
||
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
|
||
let source = editor.markdownString as NSString
|
||
let selection = editor.markdownInsertionRange(for: editor.selectedRange())
|
||
var start = 0, end = 0
|
||
source.getLineStart(&start, end: nil, contentsEnd: &end,
|
||
for: NSRange(location: selection.location, length: 0))
|
||
let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString
|
||
guard let item = Self.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
|
||
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
|
||
guard !codeBlocks.contains(where: {
|
||
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
|
||
}) else { return false }
|
||
let indent = line.substring(with: item.range(at: 1))
|
||
if indenting {
|
||
let marker = editor.renderedRange(for:
|
||
NSRange(location: start + item.range(at: 2).location, length: 0)).location
|
||
// A literal marker still needs native Tab to complete its separator.
|
||
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
|
||
}
|
||
if indenting || outdenting {
|
||
let removed = outdenting ? Self.outdentLength(indent) : 0
|
||
guard indenting || removed > 0 else { return true }
|
||
let replacement = indenting ? " " : ""
|
||
let delta = replacement.utf16.count - removed
|
||
var selectedStart = max(start, selection.location + delta)
|
||
var selectedEnd = max(start, NSMaxRange(selection) + delta)
|
||
func isListLine(_ range: NSRange) -> Bool {
|
||
Self.listMatch(in: source.substring(with: range) as NSString) != nil
|
||
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
|
||
}
|
||
var block = source.lineRange(for: NSRange(location: start, length: 0))
|
||
while block.location > 0 {
|
||
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
|
||
guard isListLine(previous) else { break }
|
||
block = NSUnionRange(previous, block)
|
||
}
|
||
while NSMaxRange(block) < source.length {
|
||
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
|
||
guard isListLine(next) else { break }
|
||
block = NSUnionRange(block, next)
|
||
}
|
||
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
|
||
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
|
||
let changed = body.string as NSString
|
||
let oldDepth = Self.indentationWidth(indent)
|
||
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
|
||
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
|
||
var edits: [(range: NSRange, number: String)] = []
|
||
var oldLevel = true, newLevel = true
|
||
var offset = 0
|
||
while offset < changed.length {
|
||
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
|
||
let text = changed.substring(with: row) as NSString
|
||
guard let match = Self.listMatch(in: text) else { break }
|
||
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
|
||
if offset + block.location > start {
|
||
if depth < oldDepth { oldLevel = false }
|
||
if depth < newDepth { newLevel = false }
|
||
}
|
||
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
|
||
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
|
||
var number: UInt64?
|
||
var delimiter = ""
|
||
if match.range(at: 3).location != NSNotFound {
|
||
number = UInt64(text.substring(with: match.range(at: 3)))
|
||
delimiter = text.substring(with: match.range(at: 4))
|
||
if offset + block.location >= start,
|
||
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
|
||
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
|
||
number = previous + 1
|
||
} else if !ancestors.isEmpty || offset + block.location > start {
|
||
number = 1
|
||
}
|
||
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
|
||
let digits = match.range(at: 3)
|
||
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
|
||
}
|
||
}
|
||
}
|
||
ancestors.append((depth, number, delimiter))
|
||
offset = NSMaxRange(row)
|
||
}
|
||
for edit in edits.reversed() {
|
||
body.replaceCharacters(in: edit.range, with: edit.number)
|
||
let location = block.location + edit.range.location
|
||
let difference = edit.number.utf16.count - edit.range.length
|
||
func adjusted(_ position: Int) -> Int {
|
||
if position <= location { return position }
|
||
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
|
||
return position + difference
|
||
}
|
||
selectedStart = adjusted(selectedStart)
|
||
selectedEnd = adjusted(selectedEnd)
|
||
}
|
||
replaceMarkdown(body, range: block,
|
||
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
|
||
return true
|
||
}
|
||
let markerEnd = item.range(at: 6).location == NSNotFound
|
||
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
|
||
guard selection.location >= start + markerEnd else { return false }
|
||
let body = line.substring(from: item.range.length)
|
||
if body.trimmingCharacters(in: .whitespaces).isEmpty {
|
||
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
|
||
var previous = start
|
||
let depth = Self.indentationWidth(indent)
|
||
while depth > 0, previous > 0 {
|
||
let range = source.lineRange(for: NSRange(location: previous - 1, length: 0))
|
||
previous = range.location
|
||
let ancestor = source.substring(with: range) as NSString
|
||
if let parent = Self.listMatch(in: ancestor),
|
||
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
|
||
let parentIndent = ancestor.substring(with: parent.range(at: 1))
|
||
if Self.indentationWidth(parentIndent) < depth {
|
||
replacement = Self.continuedPrefix(parent, in: ancestor) ?? ""
|
||
break
|
||
}
|
||
} else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" }
|
||
if Self.indentationWidth(String(leading)) < depth { break }
|
||
}
|
||
}
|
||
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
|
||
selection: NSRange(location: start + replacement.utf16.count, length: 0))
|
||
} else {
|
||
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
|
||
replaceListMarkdown("\n" + prefix, range: selection,
|
||
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
|
||
}
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
func save() -> Bool {
|
||
guard isDirty, let store, let selectedID else { return true }
|
||
do {
|
||
try store.update(id: selectedID, contents: editor.markdownContents)
|
||
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 {
|
||
actions.isShown && actions.handle(selector)
|
||
}
|
||
|
||
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])
|
||
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 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.setMarkdownContents(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.setMarkdownContents(note.contents)
|
||
editor.undoManager?.removeAllActions()
|
||
editor.isEditable = true
|
||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||
MarkdownHighlighting.apply(to: editor)
|
||
editor.refreshTasks()
|
||
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)
|
||
}
|
||
}
|
||
|
||
|
||
@MainActor
|
||
enum MarkdownHighlighting {
|
||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
|
||
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
|
||
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
|
||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
||
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
||
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
||
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
|
||
]
|
||
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
|
||
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
|
||
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
|
||
}
|
||
|
||
private static let highlighter: Highlighter = {
|
||
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
|
||
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
|
||
return value
|
||
}()
|
||
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
|
||
|
||
static func codeBlocks(in text: String) -> [(range: NSRange, body: NSRange, language: String?)] {
|
||
let source = text as NSString
|
||
var blocks: [(NSRange, NSRange, String?)] = []
|
||
var cursor = 0
|
||
while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) {
|
||
let fence = source.substring(with: opening.range(at: 1))
|
||
let marker = NSRegularExpression.escapedPattern(for: String(fence.first!))
|
||
let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$")
|
||
let start = NSMaxRange(opening.range)
|
||
let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start))
|
||
let bodyEnd = end?.range.location ?? source.length
|
||
cursor = end.map { NSMaxRange($0.range) } ?? source.length
|
||
let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||
blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location),
|
||
NSRange(location: start, length: bodyEnd - start), language))
|
||
if cursor == source.length { break }
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
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.removeAttribute(.strikethroughStyle, range: range)
|
||
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
||
let blocks = codeBlocks(in: text)
|
||
func hideSyntax(_ match: NSTextCheckingResult) {
|
||
let body = match.range(withName: "body")
|
||
guard body.location != NSNotFound else { return }
|
||
if body.location > match.range.location {
|
||
storage.addAttribute(hiddenMarkup, value: true,
|
||
range: NSRange(location: match.range.location, length: body.location - match.range.location))
|
||
}
|
||
if NSMaxRange(body) < NSMaxRange(match.range) {
|
||
storage.addAttribute(hiddenMarkup, value: true,
|
||
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
|
||
}
|
||
}
|
||
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
|
||
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
|
||
}
|
||
for match in codeSpans {
|
||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
|
||
hideSyntax(match)
|
||
}
|
||
for (expression, attributes) in rules {
|
||
for match in expression.matches(in: text, range: range) {
|
||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }),
|
||
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||
storage.addAttributes(attributes, range: match.range)
|
||
if match.range(withName: "marker").length == 3,
|
||
let font = attributes[.font] as? NSFont {
|
||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
|
||
range: match.range)
|
||
}
|
||
hideSyntax(match)
|
||
}
|
||
}
|
||
for match in heading.matches(in: text, range: range) {
|
||
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
||
hideSyntax(match)
|
||
let font = headingFonts[match.range(at: 1).length - 1]
|
||
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
||
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
|
||
}
|
||
}
|
||
for block in blocks {
|
||
let source = text as NSString
|
||
let opening = source.lineRange(for: NSRange(location: block.range.location, length: 0))
|
||
let openingText = source.substring(with: opening).trimmingCharacters(in: .newlines)
|
||
storage.addAttribute(hiddenMarkup, value: true,
|
||
range: NSRange(location: opening.location, length: openingText.utf16.count))
|
||
if NSMaxRange(block.body) < NSMaxRange(block.range) {
|
||
storage.addAttribute(hiddenMarkup, value: true,
|
||
range: NSRange(location: NSMaxRange(block.body), length: NSMaxRange(block.range) - NSMaxRange(block.body)))
|
||
}
|
||
storage.addAttributes([.font: NotesAppearance.font,
|
||
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
||
let code = (text as NSString).substring(with: block.body)
|
||
guard let highlighted = highlighter.highlight(code, as: block.language),
|
||
highlighted.string == code else { continue }
|
||
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in
|
||
if let color {
|
||
storage.addAttribute(.foregroundColor, value: color,
|
||
range: NSRange(location: block.body.location + range.location, length: range.length))
|
||
}
|
||
}
|
||
}
|
||
editor.typingAttributes = [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||
.paragraphStyle: NotesAppearance.paragraph]
|
||
}
|
||
}
|