refactor: use structured rich text for Notes editing
This commit is contained in:
@@ -17,7 +17,7 @@ struct NotesVimState {
|
||||
var linewiseRegister = false
|
||||
}
|
||||
|
||||
// ponytail: project the whole note for each command; cache visible offsets if large notes make motions slow.
|
||||
// ponytail: scan the whole note for each command; cache character offsets if large notes make motions slow.
|
||||
@MainActor
|
||||
private struct VimBuffer {
|
||||
var characters: [Character] = []
|
||||
@@ -29,12 +29,8 @@ private struct VimBuffer {
|
||||
var offset = 0
|
||||
for character in contents.string {
|
||||
let size = String(character).utf16.count
|
||||
let hidden = contents.attribute(MarkdownHighlighting.hiddenMarkup, at: offset, effectiveRange: nil) as? Bool == true
|
||||
|| contents.attribute(listWhitespace, at: offset, effectiveRange: nil) as? Bool == true
|
||||
if !hidden {
|
||||
characters.append(character)
|
||||
ranges.append(NSRange(location: offset, length: size))
|
||||
}
|
||||
characters.append(character)
|
||||
ranges.append(NSRange(location: offset, length: size))
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
@@ -98,10 +94,12 @@ extension NotesTextView {
|
||||
var vimMode: NotesVimMode { vimState.mode }
|
||||
|
||||
func resetVim() {
|
||||
endInsertionSession()
|
||||
let register = vimState.register, linewise = vimState.linewiseRegister
|
||||
vimState = NotesVimState()
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
@@ -113,19 +111,22 @@ extension NotesTextView {
|
||||
}
|
||||
|
||||
private func setVimMode(_ mode: NotesVimMode) {
|
||||
if vimState.mode == .insert { endInsertionSession() }
|
||||
breakUndoCoalescing()
|
||||
clearVimCommand()
|
||||
vimState.mode = mode
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
vimState.column = nil
|
||||
if mode == .insert { beginInsertionSession() }
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleVimEscape() -> Bool {
|
||||
guard vimEnabled, vimState.mode != .normal, !hasMarkedText() else { return false }
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
var head = vimState.head ?? buffer.index(selectedRange().location)
|
||||
if vimState.mode == .insert && head > buffer.lineStart(head) { head -= 1 }
|
||||
setVimMode(.normal)
|
||||
@@ -192,11 +193,12 @@ extension NotesTextView {
|
||||
vimState.count = min(9999, vimState.count * 10 + digit)
|
||||
return true
|
||||
}
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
if vimState.mode == .normal, selectedRange().length > 0 {
|
||||
vimState.mode = .visual
|
||||
vimState.anchor = buffer.index(selectedRange().location)
|
||||
vimState.head = max(vimState.anchor!, buffer.index(NSMaxRange(selectedRange())) - 1)
|
||||
needsDisplay = true
|
||||
vimModeChanged?()
|
||||
}
|
||||
let origin = buffer.normal(vimState.head ?? buffer.index(selectedRange().location))
|
||||
@@ -213,7 +215,7 @@ extension NotesTextView {
|
||||
if key == "g" { vimState.awaitingG = true; return true }
|
||||
if "dcy".contains(key) {
|
||||
if visual {
|
||||
let range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
||||
let range = selectedRange()
|
||||
applyVimOperator(key, range: range, linewise: vimState.mode == .visualLine)
|
||||
} else if vimState.pending == key {
|
||||
var end = buffer.lineEnd(origin)
|
||||
@@ -305,22 +307,22 @@ extension NotesTextView {
|
||||
setVimCaret(target, buffer: buffer)
|
||||
insertNewline(nil)
|
||||
if key == "O" {
|
||||
let updated = VimBuffer(attributedString())
|
||||
let updated = VimBuffer(documentContents)
|
||||
let caret = updated.index(selectedRange().location)
|
||||
setVimCaret(updated.lineStart(max(0, caret - 1)), buffer: updated)
|
||||
}
|
||||
case "x":
|
||||
if visual { applyVimOperator("d", range: vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine), linewise: vimState.mode == .visualLine) }
|
||||
if visual { applyVimOperator("d", range: selectedRange(), linewise: vimState.mode == .visualLine) }
|
||||
else {
|
||||
let end = min(buffer.lineEnd(origin), origin + count)
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
if end > origin { applyVimOperator("d", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false) }
|
||||
if end > origin { applyVimOperator("d", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false) }
|
||||
}
|
||||
case "D", "C":
|
||||
var end = buffer.lineEnd(origin)
|
||||
for _ in 1..<count where buffer.newline(end) { end = buffer.lineEnd(end + 1) }
|
||||
let startOffset = buffer.offset(origin), endOffset = buffer.offset(end)
|
||||
applyVimOperator(key == "D" ? "d" : "c", range: vimSourceRange(for: NSRange(location: startOffset, length: endOffset - startOffset)), linewise: false)
|
||||
applyVimOperator(key == "D" ? "d" : "c", range: NSRange(location: startOffset, length: endOffset - startOffset), linewise: false)
|
||||
case "p", "P": pasteVimRegister(before: key == "P", count: count, origin: origin, buffer: buffer)
|
||||
case "u":
|
||||
setVimMode(.normal)
|
||||
@@ -348,7 +350,7 @@ extension NotesTextView {
|
||||
let high = min(buffer.count, max(origin, target) + (inclusive ? 1 : 0))
|
||||
let start = buffer.offset(low), end = buffer.offset(high)
|
||||
// A motion against a boundary is a no-op, not a character deletion.
|
||||
if end > start { applyVimOperator(operation, range: vimSourceRange(for: NSRange(location: start, length: end - start)), linewise: false) }
|
||||
if end > start { applyVimOperator(operation, range: NSRange(location: start, length: end - start), linewise: false) }
|
||||
else { clearVimCommand() }
|
||||
}
|
||||
} else {
|
||||
@@ -358,24 +360,21 @@ extension NotesTextView {
|
||||
}
|
||||
}
|
||||
|
||||
private func vimSourceRange(for rendered: NSRange, linewise: Bool = false) -> NSRange {
|
||||
let range = markdownRange(for: MarkdownHighlighting.completeInlineRange(rendered, in: attributedString()))
|
||||
return linewise ? (markdownString as NSString).lineRange(for: range) : range
|
||||
}
|
||||
|
||||
private func applyVimLineOperator(_ operation: Character, start: Int, end: Int, buffer: VimBuffer) {
|
||||
let source = markdownString as NSString
|
||||
let startOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineStart(start)), length: 0)).location
|
||||
let endOffset = markdownRange(for: NSRange(location: buffer.offset(buffer.lineEnd(end)), length: 0)).location
|
||||
let first = source.lineRange(for: NSRange(location: startOffset, length: 0)).location
|
||||
let last = NSMaxRange(source.lineRange(for: NSRange(location: endOffset, length: 0)))
|
||||
let first = buffer.offset(buffer.lineStart(start))
|
||||
let lastLineEnd = buffer.lineEnd(end)
|
||||
let last = buffer.offset(lastLineEnd + (buffer.newline(lastLineEnd) ? 1 : 0))
|
||||
applyVimOperator(operation, range: NSRange(location: first, length: last - first), linewise: true)
|
||||
}
|
||||
|
||||
private func applyVimOperator(_ operation: Character, range: NSRange, linewise: Bool) {
|
||||
let source = markdownContents
|
||||
let source = documentContents
|
||||
let register = NSMutableAttributedString(attributedString: source.attributedSubstring(from: range))
|
||||
if linewise && !register.string.hasSuffix("\n") { register.append(NSAttributedString(string: "\n")) }
|
||||
if linewise && !register.string.hasSuffix("\n") {
|
||||
var attributes = register.length > 0 ? register.attributes(at: register.length - 1, effectiveRange: nil) : [:]
|
||||
attributes[.attachment] = nil
|
||||
register.append(NSAttributedString(string: "\n", attributes: attributes))
|
||||
}
|
||||
vimState.register = register
|
||||
vimState.linewiseRegister = linewise
|
||||
let originalCaret = selectedRange().location
|
||||
@@ -386,12 +385,14 @@ extension NotesTextView {
|
||||
return
|
||||
}
|
||||
var deletion = range
|
||||
var replacement = ""
|
||||
var replacement = NSAttributedString(string: "")
|
||||
var attributes = range.location < source.length ? source.attributes(at: range.location, effectiveRange: nil) : typingAttributes
|
||||
attributes[.attachment] = nil
|
||||
if linewise {
|
||||
let text = source.string as NSString
|
||||
if operation == "c" {
|
||||
// cc keeps the following line separate while opening a blank line for insertion.
|
||||
if NSMaxRange(range) < text.length { replacement = "\n" }
|
||||
if NSMaxRange(range) < text.length { replacement = NSAttributedString(string: "\n", attributes: attributes) }
|
||||
} else if NSMaxRange(range) == text.length, range.location > 0 {
|
||||
let previous = text.rangeOfComposedCharacterSequence(at: range.location - 1)
|
||||
if text.substring(with: previous).allSatisfy(\.isNewline) {
|
||||
@@ -400,10 +401,9 @@ extension NotesTextView {
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceMarkdown(replacement, range: deletion)
|
||||
if operation == "c" {
|
||||
setSelectedRange(renderedRange(for: NSRange(location: deletion.location, length: 0)))
|
||||
} else { normalizeVimCaret() }
|
||||
replaceDocument(replacement, range: deletion, selection: NSRange(location: deletion.location, length: 0))
|
||||
if operation == "c" { typingAttributes = attributes }
|
||||
else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func pasteVimRegister(before: Bool, count: Int, origin: Int, buffer: VimBuffer) {
|
||||
@@ -412,11 +412,10 @@ extension NotesTextView {
|
||||
for _ in 0..<count { insertion.append(register) }
|
||||
var range: NSRange
|
||||
if vimState.mode == .visual || vimState.mode == .visualLine {
|
||||
range = vimSourceRange(for: selectedRange(), linewise: vimState.mode == .visualLine)
|
||||
range = selectedRange()
|
||||
} else if vimState.linewiseRegister {
|
||||
let source = markdownString as NSString
|
||||
let position = markdownRange(for: NSRange(location: buffer.offset(origin), length: 0)).location
|
||||
let line = source.lineRange(for: NSRange(location: position, length: 0))
|
||||
let source = documentContents.string as NSString
|
||||
let line = source.lineRange(for: NSRange(location: buffer.offset(origin), length: 0))
|
||||
let offset = before ? line.location : NSMaxRange(line)
|
||||
range = NSRange(location: offset, length: 0)
|
||||
if offset == source.length, offset == 0 || !source.substring(from: offset - 1).allSatisfy(\.isNewline) {
|
||||
@@ -427,20 +426,20 @@ extension NotesTextView {
|
||||
}
|
||||
} else {
|
||||
let index = before ? origin : min(buffer.lineEnd(origin), origin + 1)
|
||||
range = markdownInsertionRange(for: NSRange(location: buffer.offset(index), length: 0))
|
||||
range = NSRange(location: buffer.offset(index), length: 0)
|
||||
}
|
||||
setVimMode(.normal)
|
||||
replaceMarkdown(insertion, range: range)
|
||||
replaceDocument(insertion, range: range)
|
||||
let caret = vimState.linewiseRegister ? range.location + (insertion.string.hasPrefix("\n") ? 1 : 0) : range.location + insertion.length
|
||||
setSelectedRange(renderedRange(for: NSRange(location: caret, length: 0)))
|
||||
setSelectedRange(NSRange(location: caret, length: 0))
|
||||
if !vimState.linewiseRegister {
|
||||
let updated = VimBuffer(attributedString())
|
||||
let updated = VimBuffer(documentContents)
|
||||
setVimCaret(updated.normal(max(0, updated.index(selectedRange().location) - 1)), buffer: updated)
|
||||
} else { normalizeVimCaret() }
|
||||
}
|
||||
|
||||
private func normalizeVimCaret() {
|
||||
let buffer = VimBuffer(attributedString())
|
||||
let buffer = VimBuffer(documentContents)
|
||||
setVimCaret(buffer.normal(buffer.index(selectedRange().location)), buffer: buffer)
|
||||
vimState.anchor = nil
|
||||
vimState.head = nil
|
||||
|
||||
Reference in New Issue
Block a user