refactor: use structured rich text for Notes editing
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Highlighter
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
@@ -236,66 +235,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
|
||||
|
||||
@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```"), 11: ("<u>", "</u>")]
|
||||
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))
|
||||
}
|
||||
editor.format(sender.tag)
|
||||
focusEditor()
|
||||
}
|
||||
|
||||
@@ -306,203 +246,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
_ = 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)
|
||||
try store.update(id: selectedID, contents: editor.documentContents)
|
||||
isDirty = false
|
||||
noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note"
|
||||
status.isHidden = true
|
||||
@@ -530,7 +279,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
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.clearVimCommand() }
|
||||
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,
|
||||
@@ -571,7 +320,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private func copyAsMarkdown() {
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||
let clipboard = NSPasteboard.general
|
||||
clipboard.clearContents()
|
||||
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||||
@@ -584,7 +333,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
guard let window = view.window, window.attachedSheet == nil else { return }
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let export = try NoteMarkdownExport(contents: editor.documentContents)
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export as Markdown"
|
||||
panel.prompt = "Export"
|
||||
@@ -635,7 +384,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
try store.delete(id: id)
|
||||
self.selectedID = nil
|
||||
self.defaults.removeObject(forKey: "selectedNote")
|
||||
self.editor.setMarkdownContents(NSAttributedString(string: ""))
|
||||
self.editor.setDocumentContents(NSAttributedString(string: ""))
|
||||
self.editor.undoManager?.removeAllActions()
|
||||
self.editor.isEditable = false
|
||||
self.noteTitle.stringValue = "Untitled Note"
|
||||
@@ -654,7 +403,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
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.setDocumentContents(note.contents)
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.isEditable = true
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
@@ -694,174 +443,3 @@ private final class NotesView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
|
||||
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
|
||||
super.draw(withFrame: cellFrame, in: controlView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
enum MarkdownHighlighting {
|
||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||||
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
|
||||
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
|
||||
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: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: 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 removeCodeBackground(from contents: NSMutableAttributedString) {
|
||||
let range = NSRange(location: 0, length: contents.length)
|
||||
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
|
||||
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
|
||||
}
|
||||
contents.removeAttribute(codeBackground, range: range)
|
||||
}
|
||||
// Whole visible tokens carry their delimiters through Vim delete/yank/change operations.
|
||||
static func completeInlineRange(_ selection: NSRange, in contents: NSAttributedString) -> NSRange {
|
||||
var range = selection
|
||||
let full = NSRange(location: 0, length: contents.length)
|
||||
let matches = ([inlineCode] + rules.map(\.0)).flatMap { $0.matches(in: contents.string, range: full) }
|
||||
var previous: NSRange
|
||||
repeat {
|
||||
previous = range
|
||||
for match in matches {
|
||||
let body = match.range(withName: "body")
|
||||
guard body.location != NSNotFound, body.length > 0,
|
||||
body.location > match.range.location, NSMaxRange(body) < NSMaxRange(match.range),
|
||||
range.location <= body.location, NSMaxRange(range) >= NSMaxRange(body),
|
||||
contents.attribute(hiddenMarkup, at: match.range.location, effectiveRange: nil) as? Bool == true
|
||||
else { continue }
|
||||
range = NSUnionRange(range, match.range)
|
||||
}
|
||||
} while range != previous
|
||||
return range
|
||||
}
|
||||
|
||||
|
||||
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) }
|
||||
if !(cell is RoundedImageAttachmentCell) {
|
||||
attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image)
|
||||
}
|
||||
}
|
||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
||||
storage.removeAttribute(.underlineStyle, range: range)
|
||||
removeCodeBackground(from: storage)
|
||||
storage.addAttribute(codeBackground, value: false, 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 {
|
||||
let body = match.range(withName: "body")
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
|
||||
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
|
||||
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 {
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
+538
-651
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ struct NotesVimState {
|
||||
var linewiseRegister = false
|
||||
}
|
||||
|
||||
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
|
||||
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
|
||||
@MainActor
|
||||
private struct VimBuffer {
|
||||
var characters: [Character] = []
|
||||
@@ -29,12 +29,8 @@ private struct VimBuffer {
|
||||
var offset = 0
|
||||
for character in contents.string {
|
||||
let size = String(character).utf16.count
|
||||
let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true
|
||||
|| contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true
|
||||
if !hidden {
|
||||
characters.append(character)
|
||||
ranges.append(NSRange(location: offset, length: size))
|
||||
}
|
||||
characters.append(character)
|
||||
ranges.append(NSRange(location: offset, length: size))
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
@@ -98,10 +94,12 @@ extension NotesTextView {
|
||||
var vimMode: NotesVimMode { vimState.mode }
|
||||
|
||||
func resetVim() {
|
||||
endInsertionSession()
|
||||
let register = vimState.register, linewise = vimState.linewiseRegister
|
||||
vimState = NotesVimState()
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
@@ -113,19 +111,22 @@ extension NotesTextView {
|
||||
}
|
||||
|
||||
private func setVimMode(_ mode: NotesVimMode) {
|
||||
if vimState.mode == .insert { endInsertionSession() }
|
||||
breakUndoCoalescing()
|
||||
clearVimCommand()
|
||||
vimState.mode = mode
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
vimState.column = nil
|
||||
if mode == .insert { beginInsertionSession() }
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleVimEscape() -> Bool {
|
||||
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
var head = vimState.head ?? buffer.index(selectedRange().location)
|
||||
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
|
||||
setVimMode(.normal)
|
||||
@@ -192,11 +193,12 @@ extension NotesTextView {
|
||||
vimState.count = min(9999, vimState.count * 10 + digit)
|
||||
return true
|
||||
}
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
if vimState.mode == .normal, selectedRange().length > 0 {
|
||||
vimState.mode = .visual
|
||||
vimState.anchor = buffer.index(selectedRange().location)
|
||||
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
|
||||
@@ -213,7 +215,7 @@ extension NotesTextView {
|
||||
if key == "g" { vimState.awaitingG = true; return true }
|
||||
if "dcy".contains(key) {
|
||||
if visual {
|
||||
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
||||
let range = selectedRange()
|
||||
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
|
||||
} else if vimState.pending == key {
|
||||
var end = buffer.lineEnd(origin)
|
||||
@@ -305,22 +307,22 @@ extension NotesTextView {
|
||||
setVimCaret(target, buffer: buffer)
|
||||
insertNewline(nil)
|
||||
if key == "O" {
|
||||
let updated = VimBuffer(attributedString())
|
||||
let updated = VimBuffer(documentContents)
|
||||
let caret = updated.index(selectedRange().location)
|
||||
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
|
||||
}
|
||||
case "x":
|
||||
if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) }
|
||||
if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) }
|
||||
else {
|
||||
let end = min(buffer.lineEnd(origin), origin + count)
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) }
|
||||
if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) }
|
||||
}
|
||||
case "D", "C":
|
||||
var end = buffer.lineEnd(origin)
|
||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
applyVimOperator(key == "D" ? "d" : "c", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false)
|
||||
applyVimOperator(key == "D" ? "d" : "c", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false)
|
||||
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
|
||||
case "u":
|
||||
setVimMode(.normal)
|
||||
@@ -348,7 +350,7 @@ extension NotesTextView {
|
||||
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
|
||||
let start = buffer.offset(low), end = buffer.offset(high)
|
||||
// A motion against a boundary is a no-op, not a character deletion.
|
||||
if end > start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) }
|
||||
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
|
||||
else { clearVimCommand() }
|
||||
}
|
||||
} else {
|
||||
@@ -358,24 +360,21 @@ extension NotesTextView {
|
||||
}
|
||||
}
|
||||
|
||||
private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange {
|
||||
let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString()))
|
||||
return linewise ? (markdownString as NSString).lineRange(for: range) : range
|
||||
}
|
||||
|
||||
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
|
||||
let source = markdownString as NSString
|
||||
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location
|
||||
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location
|
||||
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
|
||||
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
|
||||
let first = buffer.offset(buffer.lineStart(start))
|
||||
let lastLineEnd = buffer.lineEnd(end)
|
||||
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
|
||||
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
|
||||
}
|
||||
|
||||
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
|
||||
let source = markdownContents
|
||||
let source = documentContents
|
||||
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
|
||||
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
|
||||
if linewise && !register.string.hasSuffix("\n") {
|
||||
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
|
||||
attributes[.attachment] = nil
|
||||
register.append(NSAttributedString(string: "\n", attributes: attributes))
|
||||
}
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
let originalCaret = selectedRange().location
|
||||
@@ -386,12 +385,14 @@ extension NotesTextView {
|
||||
return
|
||||
}
|
||||
var deletion = range
|
||||
var replacement = ""
|
||||
var replacement = NSAttributedString(string: "")
|
||||
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
|
||||
attributes[.attachment] = nil
|
||||
if linewise {
|
||||
let text = source.string as NSString
|
||||
if operation == "c" {
|
||||
// cc keeps the following line separate while opening a blank line for insertion.
|
||||
if NSMaxRange(range) < text.length { replacement = "\n" }
|
||||
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
|
||||
} else if NSMaxRange(range) == text.length, range.location > 0 {
|
||||
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
|
||||
if text.substring(with: previous).allSatisfy(\.isNewline) {
|
||||
@@ -400,10 +401,9 @@ extension NotesTextView {
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceMarkdown(replacement, range: deletion)
|
||||
if operation == "c" {
|
||||
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
|
||||
} else { normalizeVimCaret() }
|
||||
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
|
||||
if operation == "c" { typingAttributes = attributes }
|
||||
else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
|
||||
@@ -412,11 +412,10 @@ extension NotesTextView {
|
||||
for _ in 0..<count { insertion.append(register) }
|
||||
var range: NSRange
|
||||
if vimState.mode == .visual || vimState.mode == .visualLine {
|
||||
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
||||
range = selectedRange()
|
||||
} else if vimState.linewiseRegister {
|
||||
let source = markdownString as NSString
|
||||
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location
|
||||
let line = source.lineRange(for: NSRange(location: position, length: 0))
|
||||
let source = documentContents.string as NSString
|
||||
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
|
||||
let offset = before ? line.location : NSMaxRange(line)
|
||||
range = NSRange(location: offset, length: 0)
|
||||
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
|
||||
@@ -427,20 +426,20 @@ extension NotesTextView {
|
||||
}
|
||||
} else {
|
||||
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
|
||||
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
|
||||
range = NSRange(location: buffer.offset(index), length: 0)
|
||||
}
|
||||
setVimMode(.normal)
|
||||
replaceMarkdown(insertion, range: range)
|
||||
replaceDocument(insertion, range: range)
|
||||
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
|
||||
setSelectedRange(renderedRange(for: NSRange(location: caret, length: 0)))
|
||||
setSelectedRange(NSRange(location: caret, length: 0))
|
||||
if !vimState.linewiseRegister {
|
||||
let updated = VimBuffer(attributedString())
|
||||
let updated = VimBuffer(documentContents)
|
||||
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
|
||||
} else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func normalizeVimCaret() {
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
|
||||
Reference in New Issue
Block a user