feat: improve note formatting shortcuts and code styling
This commit is contained in:
@@ -149,7 +149,7 @@ Notes actions are ordered Browse Notes, New Note, Copy as Markdown, Export as Ma
|
||||
|
||||
Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
|
||||
|
||||
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 formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
|
||||
The bottom strip applies Markdown headings, bold, italic, underline, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Underline uses inline HTML `<u>…</u>` so it survives Markdown export and RTFD autosaves. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
|
||||
|
||||
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
|
||||
|
||||
@@ -162,16 +162,17 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
|
||||
| Bold | Command-B |
|
||||
| Italic | Command-I |
|
||||
| Inline code | Command-E |
|
||||
| Strikethrough | Command-Shift-X |
|
||||
| Strikethrough | Command-Shift-S |
|
||||
| Underline | Command-Shift-U |
|
||||
| Link | Command-Shift-K |
|
||||
| Code block | Command-Shift-E |
|
||||
| Heading | Command-Shift-H |
|
||||
| Quote | Command-Shift-B |
|
||||
| Numbered list | Command-Shift-O |
|
||||
| Bullet list | Command-Shift-U |
|
||||
| Bullet list | Command-Shift-L |
|
||||
| Checklist | Command-Shift-T |
|
||||
|
||||
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.
|
||||
Edit Markdown directly: headings, bold, italic, underline, strikethrough, links, quotes, and inline code render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, underline tags, link wrappers and destinations, and quote prefixes are hidden rather than deleted. Code-block fences and their language labels stay visible, with syntax highlighting inside the block. Inline code uses Conjure’s orange accent text on a lightly tinted inline container; this background is display-only and stays out of saved contents. Unmatched or escaped punctuation and literal code remain visible. 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.
|
||||
|
||||
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private let formatting = NSStackView()
|
||||
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
|
||||
("h", [.command, .shift]), ("b", .command), ("i", .command),
|
||||
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||||
("s", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
|
||||
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||||
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.command, .shift]),
|
||||
("o", [.command, .shift]), ("l", [.command, .shift]), ("t", [.command, .shift]),
|
||||
("u", [.command, .shift]),
|
||||
]
|
||||
private let defaults: UserDefaults
|
||||
private var store: NotesStore?
|
||||
@@ -85,6 +86,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||||
("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"),
|
||||
("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"),
|
||||
("underline", "Underline"),
|
||||
]
|
||||
for (index, tool) in tools.enumerated() {
|
||||
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
|
||||
@@ -219,7 +221,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
|
||||
}
|
||||
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
|
||||
5: ("[", "](url)"), 6: ("```\n", "\n```")]
|
||||
5: ("[", "](url)"), 6: ("```\n", "\n```"), 11: ("<u>", "</u>")]
|
||||
if let (open, close) = wrappers[sender.tag] {
|
||||
let before = selection.location - open.utf16.count
|
||||
let after = NSMaxRange(selection)
|
||||
@@ -677,12 +679,14 @@ private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
|
||||
enum MarkdownHighlighting {
|
||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||||
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
|
||||
static let codeBackground = NSAttributedString.Key("Conjure.codeBackground")
|
||||
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
|
||||
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
|
||||
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, 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: "(?<!\\\\)<u>(?<body>[^\\n]+?)</u>"), [.underlineStyle: NSUnderlineStyle.single.rawValue]),
|
||||
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
|
||||
]
|
||||
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
|
||||
@@ -717,6 +721,14 @@ enum MarkdownHighlighting {
|
||||
return blocks
|
||||
}
|
||||
|
||||
static func removeCodeBackground(from contents: NSMutableAttributedString) {
|
||||
let range = NSRange(location: 0, length: contents.length)
|
||||
contents.enumerateAttribute(codeBackground, in: range) { value, run, _ in
|
||||
if value as? Bool == true { contents.removeAttribute(.backgroundColor, range: run) }
|
||||
}
|
||||
contents.removeAttribute(codeBackground, range: range)
|
||||
}
|
||||
|
||||
static func apply(to editor: NSTextView) {
|
||||
guard let storage = editor.textStorage else { return }
|
||||
let text = editor.string
|
||||
@@ -735,6 +747,9 @@ enum MarkdownHighlighting {
|
||||
}
|
||||
}
|
||||
storage.removeAttribute(.strikethroughStyle, range: range)
|
||||
storage.removeAttribute(.underlineStyle, range: range)
|
||||
removeCodeBackground(from: storage)
|
||||
storage.addAttribute(codeBackground, value: false, range: range)
|
||||
storage.addAttribute(hiddenMarkup, value: false, range: range)
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
|
||||
.paragraphStyle: NotesAppearance.paragraph], range: range)
|
||||
@@ -755,7 +770,9 @@ enum MarkdownHighlighting {
|
||||
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
|
||||
}
|
||||
for match in codeSpans {
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
|
||||
let body = match.range(withName: "body")
|
||||
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent,
|
||||
.backgroundColor: SunsetPalette.selectionFill, codeBackground: true], range: body)
|
||||
hideSyntax(match)
|
||||
}
|
||||
for (expression, attributes) in rules {
|
||||
@@ -781,15 +798,6 @@ 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: NotesAppearance.font,
|
||||
.foregroundColor: SunsetPalette.secondaryText], range: block.range)
|
||||
storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body)
|
||||
|
||||
@@ -74,6 +74,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)
|
||||
MarkdownHighlighting.removeCodeBackground(from: 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
|
||||
@@ -210,6 +211,9 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||
}
|
||||
attributes.removeValue(forKey: listWhitespace)
|
||||
attributes.removeValue(forKey: MarkdownHighlighting.hiddenMarkup)
|
||||
if attributes.removeValue(forKey: MarkdownHighlighting.codeBackground) as? Bool == true {
|
||||
attributes.removeValue(forKey: .backgroundColor)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- exports leave the original note and RTFD autosaves unchanged.
|
||||
- [x] bug: pressing backspace at the start of a heading will move it back a line (as expected), but a subsequent return will cause the heading `#`s to appear on the first and the heading text to move down a line.
|
||||
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
|
||||
- [ ] improve:
|
||||
- [x] improve:
|
||||
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
|
||||
- don't hide the backticks surrounding a codeblock
|
||||
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
|
||||
|
||||
@@ -7,7 +7,7 @@ import Testing
|
||||
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*"
|
||||
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*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()
|
||||
@@ -25,7 +25,7 @@ func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
||||
}
|
||||
return result
|
||||
}
|
||||
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone *literal*\nQuote\nLabel\n\n# Code *literal*\n\nUnmatched * and \\*escaped*")
|
||||
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\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()
|
||||
@@ -247,9 +247,32 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(try shortcut("s", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
#expect(try shortcut("u", shift: true))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "<u>fox 🦊</u>")
|
||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>")
|
||||
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>")
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(try shortcut("u", shift: true))
|
||||
#expect(try shortcut("u", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.markdownString == "- fox 🦊")
|
||||
#expect(try shortcut("l", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(!(try shortcut("b", option: true)))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
@@ -272,3 +295,32 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
|
||||
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
let storage = try #require(editor.textStorage)
|
||||
let layout = try #require(editor.layoutManager)
|
||||
layout.ensureLayout(for: try #require(editor.textContainer))
|
||||
let body = (source as NSString).range(of: "some *literal* code 🦊")
|
||||
for index in body.location..<NSMaxRange(body) {
|
||||
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
|
||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
|
||||
}
|
||||
for token in ["Before", "after", "not inline"] {
|
||||
let index = (source as NSString).range(of: token).location
|
||||
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil)
|
||||
}
|
||||
let literalUnderline = (source as NSString).range(of: "<u>literal</u>")
|
||||
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
|
||||
#expect(editor.markdownString == source)
|
||||
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
|
||||
editor.replaceMarkdown("some *literal* code 🦊",
|
||||
range: NSRange(location: body.location - 1, length: body.length + 2))
|
||||
let plain = (editor.string as NSString).range(of: "some")
|
||||
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil)
|
||||
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user