fix: edit note headings by visible paragraph semantics

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:39:29 +02:00
parent 3dfd9f58a4
commit bfdc1ced7d
4 changed files with 132 additions and 9 deletions
+45 -4
View File
@@ -47,6 +47,17 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
private static let listPattern = try! NSRegularExpression(
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
)
private static let headingPrefix = try! NSRegularExpression(pattern: "^#{1,6} ")
private static func heading(in source: NSString, at position: Int) -> NSRange? {
let line = source.lineRange(for: NSRange(location: position, length: 0))
guard let match = headingPrefix.firstMatch(in: source.substring(with: line),
range: NSRange(location: 0, length: line.length)),
!MarkdownHighlighting.codeBlocks(in: source as String).contains(where: {
NSIntersectionRange($0.range, line).length > 0
}) else { return nil }
return NSRange(location: line.location, length: match.range.length)
}
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
private var checkboxes: [NSButton] = []
private var horizontalSelection: (anchor: Int, head: Int)?
@@ -241,6 +252,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
guard isEditable else { return }
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
let oldHeading = Self.heading(in: source.string as NSString, at: range.location)
var caretPosition = range.location + replacement.length
var existingWhitespace = false
if typedInput, range.length == 0, replacement.length > 0 {
var end = 0
@@ -250,7 +263,22 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
}
source.replaceCharacters(in: range, with: replacement)
if typedInput, range.length == 0, replacement.length > 0 {
// A newly typed prefix at the visible start replaces the paragraph's existing level.
if typedInput, range.length == 0, let heading = oldHeading,
range.location >= NSMaxRange(heading) {
let bodyStart = NSMaxRange(heading)
let commandLength = caretPosition - bodyStart
if commandLength > 0,
let command = Self.headingPrefix.firstMatch(in: (source.string as NSString).substring(
with: NSRange(location: bodyStart, length: commandLength)),
range: NSRange(location: 0, length: commandLength)),
command.range.length == commandLength {
source.deleteCharacters(in: heading)
caretPosition -= heading.length
}
}
if typedInput, range.length == 0, replacement.length > 0,
caretPosition == range.location + replacement.length {
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
var marker = match.range(at: 2)
@@ -294,17 +322,30 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
let structural = semantic || caretPosition != range.location + replacement.length
if removed.length > 0 || inserted.length > 0 {
if semantic { breakUndoCoalescing() }
if structural { breakUndoCoalescing() }
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
if semantic { breakUndoCoalescing() }
if structural { breakUndoCoalescing() }
}
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
let caret = renderedRange(for: NSRange(location: caretPosition, length: 0))
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
}
override func deleteBackward(_ sender: Any?) {
let selection = selectedRange()
if selection.length == 0 {
let source = markdownString as NSString
let position = markdownInsertionRange(for: selection).location
if let heading = Self.heading(in: source, at: position), position == NSMaxRange(heading) {
guard heading.location > 0 else { return }
let previous = source.lineRange(for: NSRange(location: heading.location - 1, length: 0))
let empty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty
replaceMarkdown("", range: NSRange(location: heading.location - 1,
length: 1 + (empty ? 0 : heading.length)))
return
}
}
let boundary = hiddenBoundary(from: selection.location, forward: false)
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
expand(task, backwards: true)