feat: hide rendered markdown formatting markers
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user