feat: hide rendered markdown formatting markers

This commit is contained in:
2026-10-19 20:22:17 +02:00
parent 6f37910797
commit 04bfd785e7
4 changed files with 164 additions and 30 deletions
+41 -7
View File
@@ -598,14 +598,16 @@ private final class NotesView: NSView {
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]),
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: 17, 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: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) .+$")
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
private static let headingFonts = [28.0, 25, 23, 21, 19, 17].map {
NSFont.monospacedSystemFont(ofSize: $0, weight: .semibold)
}
@@ -652,17 +654,40 @@ enum MarkdownHighlighting {
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
}
storage.removeAttribute(.strikethroughStyle, 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 {
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
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 }) else { continue }
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)
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) } ?? []
@@ -670,6 +695,15 @@ enum MarkdownHighlighting {
}
}
for block in blocks {
let source = text as NSString
let opening = source.lineRange(for: NSRange(location: block.range.location, length: 0))
let openingText = source.substring(with: opening).trimmingCharacters(in: .newlines)
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: opening.location, length: openingText.utf16.count))
if NSMaxRange(block.body) < NSMaxRange(block.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(block.body), length: NSMaxRange(block.range) - NSMaxRange(block.body)))
}
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
+32 -22
View File
@@ -61,6 +61,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: contents)
restoreListLayout(in: result)
result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length))
var replacements: [(NSRange, ListAttachment)] = []
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
if let task = value as? ListAttachment { replacements.append((range, task)) }
@@ -195,6 +196,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
attributes[.paragraphStyle] = original as? NSParagraphStyle
}
attributes.removeValue(forKey: listWhitespace)
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
return attributes
}
@@ -208,7 +210,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
guard rendered.length == 0 else { return markdownRange(for: rendered) }
let body = listWhitespaceBoundary(from: rendered.location, forward: true)
let body = hiddenBoundary(from: rendered.location, forward: true)
return markdownRange(for: NSRange(location: body, length: 0))
}
@@ -297,30 +299,35 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
refreshTasks()
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0))
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
}
override func deleteBackward(_ sender: Any?) {
let selection = selectedRange()
let boundary = listWhitespaceBoundary(from: selection.location, forward: false)
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)
} else if selection.length > 0 {
replaceMarkdown("", range: markdownRange(for: selection))
} else if selection.location > 0 {
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location - 1)
let source = string as NSString
let atLineStart = boundary == 0 || source.character(at: boundary - 1) == 10
let range = boundary < selection.location && atLineStart
? NSRange(location: boundary, length: selection.location - boundary)
: source.rangeOfComposedCharacterSequence(at: max(0, boundary - 1))
replaceMarkdown("", range: markdownRange(for: range))
}
}
override func deleteForward(_ sender: Any?) {
let selection = selectedRange()
let boundary = hiddenBoundary(from: selection.location, forward: true)
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
expand(task, backwards: false)
} else if selection.length > 0 {
replaceMarkdown("", range: markdownRange(for: selection))
} else if selection.location < string.utf16.count {
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
} else if boundary < string.utf16.count {
let range = (string as NSString).rangeOfComposedCharacterSequence(at: boundary)
replaceMarkdown("", range: markdownRange(for: range))
}
}
@@ -335,24 +342,25 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
}
// Only separator glyphs are null; indentation and body whitespace remain native stops.
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
// Skip display-only syntax without changing the source offsets used by copy and save.
private func hiddenBoundary(from position: Int, forward: Bool) -> Int {
guard let storage = textStorage else { return position }
let index = forward ? position : position - 1
guard index >= 0, index < storage.length else { return position }
var range = NSRange()
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
return position
func boundary(for key: NSAttributedString.Key) -> Int? {
var range = NSRange()
guard storage.attribute(key, at: index, effectiveRange: &range) as? Bool == true else { return nil }
return forward ? NSMaxRange(range) : range.location
}
return forward ? NSMaxRange(range) : range.location
return boundary(for: listWhitespace) ?? boundary(for: MarkdownHighlighting.hiddenMarkup) ?? position
}
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
let boundary = listWhitespaceBoundary(from: position, forward: forward)
let boundary = hiddenBoundary(from: position, forward: forward)
guard let task = tasks.first(where: {
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
}) else { return nil }
return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
return forward ? hiddenBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
}
override func setSelectedRange(_ charRange: NSRange) {
@@ -412,9 +420,9 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
// Native movement supplies composed-character, bidi, and line-boundary behavior.
// Starting inside a null run already means "after the marker", so advance into the body.
let origin = listWhitespaceBoundary(from: head, forward: true)
let origin = hiddenBoundary(from: head, forward: true)
if let nativeHead, origin == head {
let target = listWhitespaceBoundary(from: nativeHead, forward: true)
let target = hiddenBoundary(from: nativeHead, forward: true)
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
return
}
@@ -424,7 +432,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
moveNatively(forward: forward, selecting: selecting, sender: sender)
let moved = selectedRange()
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
let target = listWhitespaceBoundary(from: newHead, forward: true)
let target = hiddenBoundary(from: newHead, forward: true)
if selecting {
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
setHorizontalSelection(anchor: anchor, head: target)
@@ -579,13 +587,15 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
forGlyphRange glyphRange: NSRange) -> Int {
guard let storage = layoutManager.textStorage,
(0..<glyphRange.length).contains(where: {
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
}) else { return 0 }
guard let storage = layoutManager.textStorage else { return 0 }
func hidden(_ index: Int) -> Bool {
storage.attribute(listWhitespace, at: index, effectiveRange: nil) as? Bool == true
|| storage.attribute(MarkdownHighlighting.hiddenMarkup, at: index, effectiveRange: nil) as? Bool == true
}
guard (0..<glyphRange.length).contains(where: { hidden(charIndexes[$0]) }) else { return 0 }
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
for index in properties.indices {
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
if hidden(charIndexes[index]) {
properties[index] = .null
}
}