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
+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