feat: add note formatting keyboard shortcuts
This commit is contained in:
@@ -145,6 +145,22 @@ 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.
|
||||
|
||||
Formatting shortcuts use the same actions as the bottom strip and work while it is hidden. They apply to the editor’s current selection or caret, never to text in an open picker. Hover over a formatting button to see its shortcut. Command-K remains Actions.
|
||||
|
||||
| Formatting | Shortcut |
|
||||
| --- | --- |
|
||||
| Bold | Command-B |
|
||||
| Italic | Command-I |
|
||||
| Inline code | Command-E |
|
||||
| Strikethrough | Command-Shift-X |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1245,7 +1245,7 @@ final class LauncherWindow: NSWindow {
|
||||
var numberHintsChanged: (() -> Void)?
|
||||
var launchNumber: ((Int) -> Void)?
|
||||
var commandNavigation: ((String) -> Bool)?
|
||||
var commandShortcut: ((String) -> Bool)?
|
||||
var commandShortcut: ((NSEvent) -> Bool)?
|
||||
var usesTextNavigation: (() -> Bool)?
|
||||
private var numberShortcuts = CommandNumberShortcuts()
|
||||
private var commandHoldTask: Task<Void, Never>?
|
||||
@@ -1373,9 +1373,7 @@ final class LauncherWindow: NSWindow {
|
||||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
if attachedSheet == nil,
|
||||
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
||||
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
||||
if attachedSheet == nil, commandShortcut?(event) == true {
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
|
||||
@@ -22,6 +22,12 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private let status = NSTextField(labelWithString: "")
|
||||
private let noteTitle = NSTextField(labelWithString: "Untitled Note")
|
||||
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]),
|
||||
("e", [.command, .shift]), ("b", [.command, .shift]),
|
||||
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.command, .shift]),
|
||||
]
|
||||
private let defaults: UserDefaults
|
||||
private var store: NotesStore?
|
||||
private var selectedID: UUID?
|
||||
@@ -86,6 +92,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
for (index, tool) in tools.enumerated() {
|
||||
let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:)))
|
||||
button.tag = index
|
||||
let shortcut = Self.formattingKeys[index]
|
||||
button.toolTip = "\(tool.1) (⌘\(shortcut.modifiers.contains(.shift) ? "⇧" : "")\(shortcut.key.uppercased()))"
|
||||
formatting.addArrangedSubview(button)
|
||||
}
|
||||
formatting.spacing = 8
|
||||
@@ -210,9 +218,16 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
if let (open, close) = wrappers[sender.tag] {
|
||||
let before = selection.location - open.utf16.count
|
||||
let after = NSMaxRange(selection)
|
||||
var stars = 0
|
||||
if open == "*" {
|
||||
while selection.location > stars, after + stars < source.length,
|
||||
source.character(at: selection.location - stars - 1) == 42,
|
||||
source.character(at: after + stars) == 42 { stars += 1 }
|
||||
}
|
||||
if before >= 0, after + close.utf16.count <= source.length,
|
||||
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
|
||||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close {
|
||||
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
|
||||
open != "*" || stars % 2 == 1 {
|
||||
let body = contents.attributedSubstring(from: selection)
|
||||
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
|
||||
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
|
||||
@@ -479,8 +494,17 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func handleCommandKey(_ key: String) -> Bool {
|
||||
guard store != nil else { return false }
|
||||
func handleCommandKey(_ event: NSEvent) -> Bool {
|
||||
guard store != nil, view.window?.attachedSheet == nil else { return false }
|
||||
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||
if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) {
|
||||
guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor,
|
||||
let button = formatting.arrangedSubviews[index] as? NSButton else { return false }
|
||||
formatNote(button)
|
||||
return true
|
||||
}
|
||||
guard modifiers == .command else { return false }
|
||||
switch key {
|
||||
case "p": browseNotes()
|
||||
case "k": showActions()
|
||||
@@ -583,11 +607,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
|
||||
@MainActor
|
||||
private final class NotesView: NSView {
|
||||
var commandShortcut: ((String) -> Bool)?
|
||||
var commandShortcut: ((NSEvent) -> Bool)?
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
||||
commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true {
|
||||
if commandShortcut?(event) == true {
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
@@ -682,6 +705,11 @@ enum MarkdownHighlighting {
|
||||
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)
|
||||
if match.range(withName: "marker").length == 3,
|
||||
let font = attributes[.font] as? NSFont {
|
||||
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
|
||||
range: match.range)
|
||||
}
|
||||
hideSyntax(match)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() t
|
||||
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" })
|
||||
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
|
||||
// Selecting the body and toggling formatting must remove its hidden delimiters.
|
||||
editor.setSelectedRange(word)
|
||||
italic.performClick(nil)
|
||||
@@ -88,3 +88,76 @@ func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() t
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "NotesFormattingKeys.\(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 launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults, notesDirectory: directory)
|
||||
let window = try #require(launcher.window)
|
||||
defer { launcher.dismiss(restoringFocus: false) }
|
||||
launcher.present(command: .notes)
|
||||
let content = try #require(window.contentView)
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||
let controller = try #require(editor.delegate as? NotesController)
|
||||
func shortcut(_ key: String, shift: Bool = false, option: Bool = false) throws -> Bool {
|
||||
var flags: NSEvent.ModifierFlags = .command
|
||||
if shift { flags.insert(.shift) }
|
||||
if option { flags.insert(.option) }
|
||||
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags,
|
||||
timestamp: 1, windowNumber: window.windowNumber, context: nil,
|
||||
characters: shift ? key.uppercased() : key, charactersIgnoringModifiers: shift ? key.uppercased() : key,
|
||||
isARepeat: false, keyCode: 0))
|
||||
return window.performKeyEquivalent(with: event)
|
||||
}
|
||||
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
|
||||
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "***fox 🦊***")
|
||||
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
|
||||
effectiveRange: nil) as? NSFont)
|
||||
let traits = NSFontManager.shared.traits(of: font)
|
||||
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
|
||||
#expect(try shortcut("i"))
|
||||
#expect(editor.markdownString == "**fox 🦊**")
|
||||
#expect(try shortcut("b"))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
let hide = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.toolTip == "Hide formatting" })
|
||||
hide.performClick(nil)
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||
#expect(try shortcut("x", shift: true))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(!(try shortcut("b", option: true)))
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
#expect(try shortcut("e"))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == "`fox 🦊`")
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(try shortcut("k"))
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
|
||||
#expect(window.firstResponder !== editor)
|
||||
_ = try shortcut("b")
|
||||
#expect(editor.markdownString == "fox 🦊")
|
||||
#expect(search.stringValue.isEmpty)
|
||||
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user