feat: hide rendered markdown formatting markers
This commit is contained in:
@@ -145,7 +145,7 @@ Actions (Command-K) opens the same searchable popup style used throughout Conjur
|
|||||||
|
|
||||||
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The strip’s × hides formatting controls; the text-format icon restores them. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
|
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The strip’s × hides formatting controls; the text-format icon restores them. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
|
||||||
|
|
||||||
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
|
||||||
|
|
||||||
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
|
||||||
|
|
||||||
|
|||||||
@@ -598,14 +598,16 @@ private final class NotesView: NSView {
|
|||||||
@MainActor
|
@MainActor
|
||||||
enum MarkdownHighlighting {
|
enum MarkdownHighlighting {
|
||||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
// 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])] = [
|
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
||||||
(try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
|
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .bold)]),
|
||||||
(try! NSRegularExpression(pattern: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
|
||||||
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
|
||||||
(try! NSRegularExpression(pattern: "~~[^\\n]+?~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
|
||||||
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular), .foregroundColor: NSColor.systemGreen]),
|
(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 {
|
private static let headingFonts = [28.0, 25, 23, 21, 19, 17].map {
|
||||||
NSFont.monospacedSystemFont(ofSize: $0, weight: .semibold)
|
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) }
|
if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) }
|
||||||
}
|
}
|
||||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
storage.removeAttribute(.strikethroughStyle, range: range)
|
||||||
|
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
||||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||||||
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
||||||
let blocks = codeBlocks(in: text)
|
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 (expression, attributes) in rules {
|
||||||
for match in expression.matches(in: text, range: range) {
|
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)
|
storage.addAttributes(attributes, range: match.range)
|
||||||
|
hideSyntax(match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for match in heading.matches(in: text, range: range) {
|
for match in heading.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 }) else { continue }
|
||||||
|
hideSyntax(match)
|
||||||
let font = headingFonts[match.range(at: 1).length - 1]
|
let font = headingFonts[match.range(at: 1).length - 1]
|
||||||
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
|
||||||
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
||||||
@@ -670,6 +695,15 @@ enum MarkdownHighlighting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for block in blocks {
|
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),
|
storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 17, weight: .regular),
|
||||||
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
||||||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
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 {
|
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
||||||
let result = NSMutableAttributedString(attributedString: contents)
|
let result = NSMutableAttributedString(attributedString: contents)
|
||||||
restoreListLayout(in: result)
|
restoreListLayout(in: result)
|
||||||
|
result.removeAttribute(MarkdownHighlighting.hiddenMarkup, range: NSRange(location: 0, length: result.length))
|
||||||
var replacements: [(NSRange, ListAttachment)] = []
|
var replacements: [(NSRange, ListAttachment)] = []
|
||||||
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
||||||
if let task = value as? ListAttachment { replacements.append((range, task)) }
|
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[.paragraphStyle] = original as? NSParagraphStyle
|
||||||
}
|
}
|
||||||
attributes.removeValue(forKey: listWhitespace)
|
attributes.removeValue(forKey: listWhitespace)
|
||||||
|
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
|
|
||||||
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
|
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
|
||||||
guard rendered.length == 0 else { return markdownRange(for: rendered) }
|
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))
|
return markdownRange(for: NSRange(location: body, length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,30 +299,35 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
refreshTasks()
|
refreshTasks()
|
||||||
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
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?) {
|
override func deleteBackward(_ sender: Any?) {
|
||||||
let selection = selectedRange()
|
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 }) {
|
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
||||||
expand(task, backwards: true)
|
expand(task, backwards: true)
|
||||||
} else if selection.length > 0 {
|
} else if selection.length > 0 {
|
||||||
replaceMarkdown("", range: markdownRange(for: selection))
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
} else if selection.location > 0 {
|
} 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))
|
replaceMarkdown("", range: markdownRange(for: range))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func deleteForward(_ sender: Any?) {
|
override func deleteForward(_ sender: Any?) {
|
||||||
let selection = selectedRange()
|
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 }) {
|
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
|
||||||
expand(task, backwards: false)
|
expand(task, backwards: false)
|
||||||
} else if selection.length > 0 {
|
} else if selection.length > 0 {
|
||||||
replaceMarkdown("", range: markdownRange(for: selection))
|
replaceMarkdown("", range: markdownRange(for: selection))
|
||||||
} else if selection.location < string.utf16.count {
|
} else if boundary < string.utf16.count {
|
||||||
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: boundary)
|
||||||
replaceMarkdown("", range: markdownRange(for: range))
|
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)) }
|
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only separator glyphs are null; indentation and body whitespace remain native stops.
|
// Skip display-only syntax without changing the source offsets used by copy and save.
|
||||||
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
|
private func hiddenBoundary(from position: Int, forward: Bool) -> Int {
|
||||||
guard let storage = textStorage else { return position }
|
guard let storage = textStorage else { return position }
|
||||||
let index = forward ? position : position - 1
|
let index = forward ? position : position - 1
|
||||||
guard index >= 0, index < storage.length else { return position }
|
guard index >= 0, index < storage.length else { return position }
|
||||||
var range = NSRange()
|
func boundary(for key: NSAttributedString.Key) -> Int? {
|
||||||
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
|
var range = NSRange()
|
||||||
return position
|
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? {
|
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: {
|
guard let task = tasks.first(where: {
|
||||||
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
||||||
}) else { return nil }
|
}) 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) {
|
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.
|
// 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.
|
// 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 {
|
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) }
|
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -424,7 +432,7 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
||||||
let moved = selectedRange()
|
let moved = selectedRange()
|
||||||
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
|
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 selecting {
|
||||||
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
||||||
setHorizontalSelection(anchor: anchor, head: target)
|
setHorizontalSelection(anchor: anchor, head: target)
|
||||||
@@ -579,13 +587,15 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
||||||
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
||||||
forGlyphRange glyphRange: NSRange) -> Int {
|
forGlyphRange glyphRange: NSRange) -> Int {
|
||||||
guard let storage = layoutManager.textStorage,
|
guard let storage = layoutManager.textStorage else { return 0 }
|
||||||
(0..<glyphRange.length).contains(where: {
|
func hidden(_ index: Int) -> Bool {
|
||||||
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
|
storage.attribute(listWhitespace, at: index, effectiveRange: nil) as? Bool == true
|
||||||
}) else { return 0 }
|
|| 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))
|
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
||||||
for index in properties.indices {
|
for index in properties.indices {
|
||||||
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
|
if hidden(charIndexes[index]) {
|
||||||
properties[index] = .null
|
properties[index] = .null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
@testable import Conjure
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
|
||||||
|
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||||
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
editor.refreshTasks()
|
||||||
|
let layout = try #require(editor.layoutManager)
|
||||||
|
let container = try #require(editor.textContainer)
|
||||||
|
layout.ensureLayout(for: container)
|
||||||
|
func visibleText() -> String {
|
||||||
|
var result = ""
|
||||||
|
let text = editor.string as NSString
|
||||||
|
for index in 0..<text.length {
|
||||||
|
let glyph = layout.glyphIndexForCharacter(at: index)
|
||||||
|
if !layout.propertyForGlyph(at: glyph).contains(.null) {
|
||||||
|
result += text.substring(with: NSRange(location: index, length: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone *literal*\nQuote\nLabel\n\n# Code *literal*\n\nUnmatched * and \\*escaped*")
|
||||||
|
#expect(editor.markdownString == source)
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||||
|
let pasteboard = NSPasteboard.withUniqueName()
|
||||||
|
defer { pasteboard.releaseGlobally() }
|
||||||
|
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
|
||||||
|
#expect(pasteboard.string(forType: .string) == source)
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let store = try NotesStore(directory: directory)
|
||||||
|
let id = try store.create()
|
||||||
|
try store.update(id: id, contents: editor.markdownContents)
|
||||||
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
|
#expect(reloaded.contents.string == source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesMarkup.\(UUID().uuidString)"
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
try? FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
let controller = NotesController(defaults: defaults, directory: directory)
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||||
|
styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentViewController = controller
|
||||||
|
defer { _ = controller.close(); window.orderOut(nil) }
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
editor.insertText("A", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
editor.undoManager?.beginUndoGrouping()
|
||||||
|
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
|
editor.deleteBackward(nil)
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
#expect(editor.markdownString == "# ATitle\n*wor*")
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == "# ATitle\n*word*")
|
||||||
|
let word = (editor.string as NSString).range(of: "word")
|
||||||
|
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||||
|
editor.moveRight(nil)
|
||||||
|
#expect(editor.selectedRange().location == word.location + 1)
|
||||||
|
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
|
||||||
|
editor.moveRight(nil)
|
||||||
|
#expect(editor.selectedRange().location == editor.string.utf16.count)
|
||||||
|
editor.setSelectedRange(NSRange(location: word.location, length: 0))
|
||||||
|
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.toolTip == "Italic" })
|
||||||
|
// Selecting the body and toggling formatting must remove its hidden delimiters.
|
||||||
|
editor.setSelectedRange(word)
|
||||||
|
italic.performClick(nil)
|
||||||
|
#expect(editor.markdownString == "# ATitle\nword")
|
||||||
|
#expect(controller.save())
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user