refactor: use structured rich text for Notes editing

This commit is contained in:
2026-10-22 13:13:31 +02:00
parent 5803a00944
commit 751f22f6b1
20 changed files with 2155 additions and 2583 deletions
+7 -429
View File
@@ -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]
}
}
File diff suppressed because it is too large Load Diff
+41 -42
View File
@@ -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
+555
View File
@@ -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 = ""
+36 -10
View File
@@ -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)
}
}