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
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Markdown
|
||||
|
||||
public enum NoteAttributes {
|
||||
public static let block = NSAttributedString.Key("ConjureNote.block")
|
||||
public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel")
|
||||
public static let depth = NSAttributedString.Key("ConjureNote.depth")
|
||||
public static let number = NSAttributedString.Key("ConjureNote.number")
|
||||
public static let checked = NSAttributedString.Key("ConjureNote.checked")
|
||||
public static let language = NSAttributedString.Key("ConjureNote.language")
|
||||
public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode")
|
||||
public static let bold = NSAttributedString.Key("ConjureNote.bold")
|
||||
public static let italic = NSAttributedString.Key("ConjureNote.italic")
|
||||
}
|
||||
|
||||
/// The attributed string is the document; Markdown is only an import/export format.
|
||||
@MainActor
|
||||
public enum NoteDocument {
|
||||
static let metadataFilename = "ConjureNote.json"
|
||||
|
||||
public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString {
|
||||
MarkdownImporter(source).contents()
|
||||
}
|
||||
|
||||
public static func markdown(_ contents: NSAttributedString) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString(string: "")
|
||||
let text = contents.string as NSString
|
||||
var cursor = 0
|
||||
while cursor < contents.length {
|
||||
let range = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||
let attributes = contents.attributes(at: cursor, effectiveRange: nil)
|
||||
let block = attributes[NoteAttributes.block] as? String
|
||||
if block == "code" {
|
||||
let language = attributes[NoteAttributes.language] as? String ?? ""
|
||||
var end = NSMaxRange(range)
|
||||
while end < contents.length,
|
||||
contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code",
|
||||
(contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language {
|
||||
end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0)))
|
||||
}
|
||||
let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor))
|
||||
let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1))
|
||||
result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n"))
|
||||
result.append(code)
|
||||
if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) }
|
||||
result.append(NSAttributedString(string: fence))
|
||||
if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) }
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0)
|
||||
var prefix = ""
|
||||
switch block {
|
||||
case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " "
|
||||
case "quote": prefix = String(repeating: "> ", count: depth + 1)
|
||||
case "bullet": prefix = String(repeating: " ", count: depth) + "- "
|
||||
case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). "
|
||||
case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ")
|
||||
default: break
|
||||
}
|
||||
result.append(NSAttributedString(string: prefix))
|
||||
var bodyEnd = NSMaxRange(range)
|
||||
while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 }
|
||||
appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result)
|
||||
if bodyEnd < NSMaxRange(range) {
|
||||
result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd)))
|
||||
}
|
||||
cursor = NSMaxRange(range)
|
||||
}
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper {
|
||||
let wrapper = try contents.fileWrapper(
|
||||
from: NSRange(location: 0, length: contents.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename }
|
||||
// Bind metadata to the text AppKit actually stores (RTF normalizes line endings).
|
||||
guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||
throw DocumentError.corruptMetadata
|
||||
}
|
||||
guard stored.length == contents.length, stored.string == contents.string else {
|
||||
throw DocumentError.textChangedDuringEncoding
|
||||
}
|
||||
let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents))
|
||||
let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata))
|
||||
json.preferredFilename = metadataFilename
|
||||
wrapper.addFileWrapper(json)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString {
|
||||
guard wrapper.isDirectory else { throw DocumentError.corruptMetadata }
|
||||
try validate(wrapper)
|
||||
guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile,
|
||||
let data = file.regularFileContents else { throw DocumentError.corruptMetadata }
|
||||
let metadata: Metadata
|
||||
do { metadata = try JSONDecoder().decode(Metadata.self, from: data) }
|
||||
catch { throw DocumentError.corruptMetadata }
|
||||
guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) }
|
||||
guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil),
|
||||
metadata.length == decoded.length, metadata.digest == digest(decoded.string) else {
|
||||
throw DocumentError.corruptMetadata
|
||||
}
|
||||
let result = NSMutableAttributedString(attributedString: decoded)
|
||||
var end = 0
|
||||
for run in metadata.runs {
|
||||
guard run.location == end, run.length > 0, run.location >= 0,
|
||||
run.location <= result.length, run.length <= result.length - run.location,
|
||||
run.values.isValid else { throw DocumentError.corruptMetadata }
|
||||
result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length))
|
||||
end += run.length
|
||||
}
|
||||
guard end == result.length else { throw DocumentError.corruptMetadata }
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
private static func validate(_ wrapper: FileWrapper) throws {
|
||||
guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata }
|
||||
if wrapper.isDirectory {
|
||||
guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata }
|
||||
for (name, child) in children {
|
||||
guard !name.isEmpty, name != ".", name != "..",
|
||||
!name.contains("/"), !name.contains("\\"),
|
||||
child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata }
|
||||
try validate(child)
|
||||
}
|
||||
} else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata }
|
||||
}
|
||||
|
||||
public enum DocumentError: LocalizedError {
|
||||
case corruptMetadata
|
||||
case unsupportedVersion(Int)
|
||||
case textChangedDuringEncoding
|
||||
case reservedMetadataFilename
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed."
|
||||
case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed."
|
||||
case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed."
|
||||
case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func digest(_ string: String) -> String {
|
||||
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private struct Metadata: Codable {
|
||||
let version: Int
|
||||
let length: Int
|
||||
let digest: String
|
||||
let runs: [Run]
|
||||
}
|
||||
|
||||
private struct Run: Codable {
|
||||
let location: Int
|
||||
let length: Int
|
||||
let values: Values
|
||||
}
|
||||
|
||||
private struct Values: Codable {
|
||||
var block: String?
|
||||
var headingLevel: Int?
|
||||
var depth: Int?
|
||||
var number: Int?
|
||||
var checked: Bool?
|
||||
var language: String?
|
||||
var inlineCode: Bool?
|
||||
var bold: Bool?
|
||||
var italic: Bool?
|
||||
var underline: Int?
|
||||
var strike: Int?
|
||||
var link: String?
|
||||
|
||||
init(_ attributes: [NSAttributedString.Key: Any]) {
|
||||
block = attributes[NoteAttributes.block] as? String
|
||||
headingLevel = attributes[NoteAttributes.headingLevel] as? Int
|
||||
depth = attributes[NoteAttributes.depth] as? Int
|
||||
number = attributes[NoteAttributes.number] as? Int
|
||||
checked = attributes[NoteAttributes.checked] as? Bool
|
||||
language = attributes[NoteAttributes.language] as? String
|
||||
inlineCode = attributes[NoteAttributes.inlineCode] as? Bool
|
||||
bold = attributes[NoteAttributes.bold] as? Bool
|
||||
italic = attributes[NoteAttributes.italic] as? Bool
|
||||
underline = attributes[.underlineStyle] as? Int
|
||||
strike = attributes[.strikethroughStyle] as? Int
|
||||
link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
(block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!))
|
||||
&& (headingLevel == nil || (1...6).contains(headingLevel!))
|
||||
&& (depth == nil || depth! >= 0)
|
||||
&& (number == nil || number! >= 0)
|
||||
}
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] {
|
||||
var result: [NSAttributedString.Key: Any] = [:]
|
||||
result[NoteAttributes.block] = block
|
||||
result[NoteAttributes.headingLevel] = headingLevel
|
||||
result[NoteAttributes.depth] = depth
|
||||
result[NoteAttributes.number] = number
|
||||
result[NoteAttributes.checked] = checked
|
||||
result[NoteAttributes.language] = language
|
||||
result[NoteAttributes.inlineCode] = inlineCode
|
||||
result[NoteAttributes.bold] = bold
|
||||
result[NoteAttributes.italic] = italic
|
||||
result[.underlineStyle] = underline
|
||||
result[.strikethroughStyle] = strike
|
||||
result[.link] = link
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private static func runs(in contents: NSAttributedString) -> [Run] {
|
||||
var result: [Run] = []
|
||||
contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in
|
||||
result.append(Run(location: range.location, length: range.length, values: Values(attributes)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func longestRun(of character: Character, in text: String) -> Int {
|
||||
var longest = 0
|
||||
var count = 0
|
||||
for value in text {
|
||||
count = value == character ? count + 1 : 0
|
||||
longest = max(longest, count)
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) {
|
||||
let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range))
|
||||
contents.enumerateAttributes(in: range) { attributes, run, _ in
|
||||
let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||
var marks: [NSAttributedString.Key: Any] = [:]
|
||||
if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true }
|
||||
if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true }
|
||||
if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true }
|
||||
if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 }
|
||||
if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 }
|
||||
marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
||||
semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length))
|
||||
}
|
||||
// Markdown emphasis cannot open/close against whitespace. Trim each semantic
|
||||
// span, not each display-font/color run; underline and links retain spaces.
|
||||
let string = semantic.string as NSString
|
||||
for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] {
|
||||
var edges: [NSRange] = []
|
||||
semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in
|
||||
guard value != nil else { return }
|
||||
var start = span.location
|
||||
var end = NSMaxRange(span)
|
||||
while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 }
|
||||
while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 }
|
||||
if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) }
|
||||
if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) }
|
||||
}
|
||||
for edge in edges { semantic.removeAttribute(key, range: edge) }
|
||||
}
|
||||
var active: [(open: String, close: String)] = []
|
||||
semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in
|
||||
let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length))
|
||||
let text = value.string
|
||||
let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false
|
||||
var desired: [(open: String, close: String)] = []
|
||||
if let link = attributes[.link] as? String {
|
||||
let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A")
|
||||
desired.append(("[", "](<\(destination)>)"))
|
||||
}
|
||||
for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, "<u>"), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled {
|
||||
desired.append((delimiter, delimiter == "<u>" ? "</u>" : delimiter))
|
||||
}
|
||||
var common = 0
|
||||
while common < min(active.count, desired.count),
|
||||
active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 }
|
||||
for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||
for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) }
|
||||
active = desired
|
||||
if code {
|
||||
let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1))
|
||||
let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " }))
|
||||
output.append(NSAttributedString(string: fence + (pad ? " " : "")))
|
||||
output.append(value)
|
||||
output.append(NSAttributedString(string: (pad ? " " : "") + fence))
|
||||
} else {
|
||||
var index = 0
|
||||
for character in text {
|
||||
let length = String(character).utf16.count
|
||||
if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) }
|
||||
output.append(value.attributedSubstring(from: NSRange(location: index, length: length)))
|
||||
index += length
|
||||
}
|
||||
}
|
||||
}
|
||||
for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private final class MarkdownImporter {
|
||||
private let source: NSAttributedString
|
||||
private let text: NSString
|
||||
private let bytes: [UInt8]
|
||||
private let lineStarts: [Int]
|
||||
private let utf16Offsets: [Int]
|
||||
private let result: NSMutableAttributedString
|
||||
private var deletions: [NSRange] = []
|
||||
private var replacements: [(NSRange, NSAttributedString)] = []
|
||||
|
||||
init(_ source: NSAttributedString) {
|
||||
self.source = source
|
||||
text = source.string as NSString
|
||||
bytes = Array(source.string.utf8)
|
||||
var starts = [0]
|
||||
for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) }
|
||||
lineStarts = starts
|
||||
var offsets = Array(repeating: 0, count: bytes.count + 1)
|
||||
var byteOffset = 0
|
||||
var utf16Offset = 0
|
||||
for scalar in source.string.unicodeScalars {
|
||||
for index in 0..<scalar.utf8.count { offsets[byteOffset + index] = utf16Offset }
|
||||
byteOffset += scalar.utf8.count
|
||||
utf16Offset += scalar.utf16.count
|
||||
}
|
||||
offsets[bytes.count] = utf16Offset
|
||||
utf16Offsets = offsets
|
||||
result = NSMutableAttributedString(attributedString: source)
|
||||
}
|
||||
|
||||
func contents() -> NSAttributedString {
|
||||
let document = Document(parsing: source.string)
|
||||
visit(document)
|
||||
var merged: [NSRange] = []
|
||||
for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) {
|
||||
if let last = merged.last, NSMaxRange(last) >= range.location {
|
||||
merged[merged.count - 1] = NSUnionRange(last, range)
|
||||
} else { merged.append(range) }
|
||||
}
|
||||
var edits = replacements
|
||||
for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) {
|
||||
edits.append((range, NSAttributedString(string: "")))
|
||||
}
|
||||
for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) {
|
||||
result.replaceCharacters(in: range, with: value)
|
||||
}
|
||||
return NSAttributedString(attributedString: result)
|
||||
}
|
||||
|
||||
private func offset(_ location: SourceLocation) -> Int {
|
||||
guard location.line > 0, location.line <= lineStarts.count else { return text.length }
|
||||
let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1))
|
||||
return utf16Offsets[byte]
|
||||
}
|
||||
|
||||
private func range(_ markup: any Markup) -> NSRange? {
|
||||
guard let range = markup.range else { return nil }
|
||||
let start = offset(range.lowerBound)
|
||||
let end = offset(range.upperBound)
|
||||
guard end >= start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) {
|
||||
let target = paragraph ? text.paragraphRange(for: range) : range
|
||||
result.addAttributes(attributes, range: target)
|
||||
}
|
||||
|
||||
private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString {
|
||||
var cursor = sourceRange.location
|
||||
var attachments: [NSTextAttachment] = []
|
||||
source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in
|
||||
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||
}
|
||||
var attachmentIndex = 0
|
||||
func literal(_ string: String) -> NSMutableAttributedString {
|
||||
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||
let match = text.range(of: string, options: [], range: remaining)
|
||||
let value: NSMutableAttributedString
|
||||
if !string.isEmpty, match.location != NSNotFound {
|
||||
value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match))
|
||||
cursor = NSMaxRange(match)
|
||||
} else {
|
||||
let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:]
|
||||
value = NSMutableAttributedString(string: string, attributes: attributes)
|
||||
}
|
||||
let characters = string as NSString
|
||||
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||
if attachmentIndex < attachments.count {
|
||||
value.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1))
|
||||
}
|
||||
attachmentIndex += 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
func children(_ container: any Markup) -> NSMutableAttributedString {
|
||||
let output = NSMutableAttributedString(string: "")
|
||||
var underline: [(offset: Int, literal: String)] = []
|
||||
for child in container.children {
|
||||
if let html = child as? InlineHTML {
|
||||
if html.rawHTML.lowercased() == "<u>" { underline.append((output.length, html.rawHTML)); continue }
|
||||
if html.rawHTML.lowercased() == "</u>", let start = underline.popLast() {
|
||||
output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset))
|
||||
continue
|
||||
}
|
||||
}
|
||||
output.append(render(child))
|
||||
}
|
||||
// Unmatched HTML is unsupported literal content, not a disappearing delimiter.
|
||||
for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) }
|
||||
return output
|
||||
}
|
||||
func render(_ child: any Markup) -> NSMutableAttributedString {
|
||||
switch child {
|
||||
case let value as Text: return literal(value.string)
|
||||
case let value as InlineCode:
|
||||
let output = literal(value.code)
|
||||
output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length))
|
||||
return output
|
||||
case is SoftBreak, is LineBreak:
|
||||
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
||||
let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining)
|
||||
let output = NSMutableAttributedString(string: "")
|
||||
if newline.location != NSNotFound {
|
||||
var start = newline.location
|
||||
while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 }
|
||||
if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) }
|
||||
cursor = newline.location
|
||||
}
|
||||
output.append(literal("\n"))
|
||||
return output
|
||||
case let html as InlineHTML: return literal(html.rawHTML)
|
||||
case is Strong, is Emphasis, is Strikethrough, is Link:
|
||||
let output = children(child)
|
||||
let range = NSRange(location: 0, length: output.length)
|
||||
if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) }
|
||||
if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) }
|
||||
if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) }
|
||||
if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) }
|
||||
return output
|
||||
default: return literal(child.format())
|
||||
}
|
||||
}
|
||||
let output = children(node)
|
||||
// The parser deliberately omits terminal Markdown hard-break whitespace.
|
||||
// It is still native note text and must survive migration/import.
|
||||
var suffix = NSMaxRange(sourceRange)
|
||||
while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 }
|
||||
let rendered = output.string as NSString
|
||||
var renderedSuffix = rendered.length
|
||||
while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 }
|
||||
let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix)
|
||||
if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) }
|
||||
return output
|
||||
}
|
||||
|
||||
private func visit(_ node: any Markup, listDepth: Int = 0) {
|
||||
guard let whole = range(node) else {
|
||||
for child in node.children { visit(child, listDepth: listDepth) }
|
||||
return
|
||||
}
|
||||
switch node {
|
||||
case let heading as Heading:
|
||||
mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true)
|
||||
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||
return
|
||||
case let code as CodeBlock:
|
||||
let full = text.paragraphRange(for: whole)
|
||||
var value = code.code
|
||||
if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() }
|
||||
// An empty code paragraph still needs a real character on which semantics can live.
|
||||
if value.isEmpty { value = "\n" }
|
||||
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"]
|
||||
if let language = code.language { attributes[NoteAttributes.language] = language }
|
||||
let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:])
|
||||
valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length))
|
||||
var attachmentIndex = 0
|
||||
var attachments: [NSTextAttachment] = []
|
||||
source.enumerateAttribute(.attachment, in: full) { value, _, _ in
|
||||
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
||||
}
|
||||
let characters = value as NSString
|
||||
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
||||
if attachmentIndex < attachments.count { valueContents.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1)) }
|
||||
attachmentIndex += 1
|
||||
}
|
||||
replacements.append((full, valueContents))
|
||||
return
|
||||
case is BlockQuote:
|
||||
var quoteDepth = 0
|
||||
var ancestor = node.parent
|
||||
while let current = ancestor {
|
||||
if current is BlockQuote { quoteDepth += 1 }
|
||||
ancestor = current.parent
|
||||
}
|
||||
mark(whole, [NoteAttributes.block: "quote", NoteAttributes.depth: quoteDepth], paragraph: true)
|
||||
var cursor = text.paragraphRange(for: NSRange(location: whole.location, length: 0)).location
|
||||
while cursor < NSMaxRange(whole) {
|
||||
let line = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
||||
var end = cursor
|
||||
while end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||
while end < NSMaxRange(line), text.character(at: end) == 62 {
|
||||
end += 1
|
||||
if end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
||||
}
|
||||
if end > cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) }
|
||||
cursor = NSMaxRange(line)
|
||||
}
|
||||
case is OrderedList, is UnorderedList:
|
||||
let ordered = node as? OrderedList
|
||||
var number = Int(ordered?.startIndex ?? 1)
|
||||
for child in node.children {
|
||||
guard let item = child as? ListItem else { continue }
|
||||
for block in item.children {
|
||||
if block is OrderedList || block is UnorderedList {
|
||||
visit(block, listDepth: listDepth + 1)
|
||||
} else {
|
||||
if let itemRange = range(item), let body = range(block) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth]
|
||||
if ordered != nil { attributes[NoteAttributes.number] = number }
|
||||
if let checked = item.checkbox {
|
||||
attributes[NoteAttributes.block] = "task"
|
||||
switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false }
|
||||
}
|
||||
mark(body, attributes, paragraph: true)
|
||||
if body.location > itemRange.location, block.indexInParent == 0 {
|
||||
let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location
|
||||
deletions.append(NSRange(location: start, length: body.location - start))
|
||||
}
|
||||
}
|
||||
visit(block, listDepth: listDepth)
|
||||
}
|
||||
}
|
||||
number += 1
|
||||
}
|
||||
return
|
||||
case is Paragraph:
|
||||
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
||||
return
|
||||
case is Document, is ListItem:
|
||||
break
|
||||
default:
|
||||
// Tables, HTML, remote image syntax and other unsupported constructs stay literal.
|
||||
return
|
||||
}
|
||||
for child in node.children { visit(child, listDepth: listDepth) }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public struct NoteMarkdownExport {
|
||||
private let images: [(name: String, data: Data)]
|
||||
|
||||
public init(contents: NSAttributedString) throws {
|
||||
let contents = NoteDocument.markdown(contents)
|
||||
let source = contents.string as NSString
|
||||
var markdown = ""
|
||||
var clipboard = ""
|
||||
|
||||
@@ -24,6 +24,7 @@ public struct Note: Identifiable {
|
||||
public final class NotesStore {
|
||||
public private(set) var notes: [Note] = []
|
||||
private let directory: URL
|
||||
private var legacyIDs: Set<UUID> = []
|
||||
|
||||
public init(directory: URL = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask
|
||||
@@ -34,16 +35,26 @@ public final class NotesStore {
|
||||
at: directory, includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension.lowercased() == "rtfd" }
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
notes = try files.map { url in
|
||||
var loaded: [Note] = []
|
||||
for url in files {
|
||||
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
let contents = try NSAttributedString(
|
||||
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd],
|
||||
documentAttributes: nil
|
||||
)
|
||||
return Note(id: id, contents: contents)
|
||||
let wrapper = try FileWrapper(url: url, options: .immediate)
|
||||
guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) }
|
||||
let contents: NSAttributedString
|
||||
if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil {
|
||||
contents = try NoteDocument.contents(from: wrapper)
|
||||
} else {
|
||||
guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
contents = NoteDocument.importMarkdown(legacy)
|
||||
legacyIDs.insert(id)
|
||||
}
|
||||
loaded.append(Note(id: id, contents: contents))
|
||||
}
|
||||
notes = loaded
|
||||
}
|
||||
|
||||
public func create() throws -> UUID {
|
||||
@@ -69,14 +80,29 @@ public final class NotesStore {
|
||||
}
|
||||
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
|
||||
notes.remove(at: index)
|
||||
legacyIDs.remove(id)
|
||||
}
|
||||
|
||||
private func save(_ note: Note) throws {
|
||||
let wrapper = try note.contents.fileWrapper(
|
||||
from: NSRange(location: 0, length: note.contents.length),
|
||||
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
||||
)
|
||||
let wrapper = try NoteDocument.fileWrapper(note.contents)
|
||||
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||
if legacyIDs.contains(note.id) {
|
||||
let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
|
||||
let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
|
||||
if !FileManager.default.fileExists(atPath: backup.path) {
|
||||
// copyItem leaves the live legacy package untouched if backup creation fails.
|
||||
try FileManager.default.copyItem(at: url, to: backup)
|
||||
} else {
|
||||
// A previous failed save may already have preserved the original package.
|
||||
let original = try FileWrapper(url: backup, options: .immediate)
|
||||
guard original.isDirectory,
|
||||
NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else {
|
||||
throw CocoaError(.fileWriteFileExists)
|
||||
}
|
||||
}
|
||||
}
|
||||
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
|
||||
legacyIDs.remove(note.id)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user