Compare commits
3 Commits
a3afbdc286
...
5065ba59bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 5065ba59bd | |||
| 489fa84b3c | |||
| b5a0edd5bf |
@@ -70,6 +70,8 @@ Action and selection popups paint a popup-sized background using the launcher wi
|
||||
|
||||
The launcher, emoji picker, Translate, and Notes share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes places Browse Notes (Command-P) and Actions (Command-K) at the bottom right, with both popups opening upwards; New Note remains in Actions and on Command-N. Its formatting controls scroll horizontally when the remaining footer space is too narrow. Controllers supply the buttons and retain their targets, enabled state, and popup behavior.
|
||||
|
||||
Notes keeps its 36-point footer pill 8 points from the bottom and right window edges, matching the other views even at the minimum window size.
|
||||
|
||||
The header’s trailing edge is constrained to the footer’s trailing edge. The translation provider pill and footer pill therefore share the same 8-point right inset and 36-point height.
|
||||
|
||||
### Global shortcuts and launch at login
|
||||
@@ -171,7 +173,7 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
|
||||
|
||||
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.
|
||||
|
||||
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`). These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
body.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||||
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
|
||||
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
|
||||
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -8),
|
||||
|
||||
@@ -248,11 +248,17 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||
typedInput: insertString is String)
|
||||
}
|
||||
|
||||
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
|
||||
private func replaceSource(_ replacement: NSAttributedString, range originalRange: NSRange, semantic: Bool, typedInput: Bool = false) {
|
||||
guard isEditable else { return }
|
||||
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
||||
var range = originalRange
|
||||
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
||||
let oldHeading = Self.heading(in: source.string as NSString, at: range.location)
|
||||
// Return at the visible paragraph start keeps the heading prefix with its body.
|
||||
if replacement.string == "\n", range.length == 0, let heading = oldHeading,
|
||||
range.location == NSMaxRange(heading) {
|
||||
range.location = heading.location
|
||||
}
|
||||
var caretPosition = range.location + replacement.length
|
||||
var existingWhitespace = false
|
||||
if typedInput, range.length == 0, replacement.length > 0 {
|
||||
@@ -322,14 +328,16 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
||||
}
|
||||
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
||||
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
||||
let structural = semantic || caretPosition != range.location + replacement.length
|
||||
let structural = semantic || caretPosition != originalRange.location + replacement.length
|
||||
if removed.length > 0 || inserted.length > 0 {
|
||||
if structural { breakUndoCoalescing() }
|
||||
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
||||
if structural { breakUndoCoalescing() }
|
||||
}
|
||||
let caret = renderedRange(for: NSRange(location: caretPosition, length: 0))
|
||||
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
||||
// Upstream affinity places the caret above a paragraph's hidden leading glyphs.
|
||||
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0),
|
||||
affinity: .downstream, stillSelecting: false)
|
||||
}
|
||||
|
||||
override func deleteBackward(_ sender: Any?) {
|
||||
|
||||
@@ -16,4 +16,11 @@
|
||||
- [x] add whole-note Markdown export actions to the Notes Actions menu:
|
||||
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
|
||||
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
|
||||
- exports leave the original note and RTFD autosaves unchanged.
|
||||
- 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:
|
||||
- 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
|
||||
- [ ] feature: vim motions
|
||||
@@ -3,6 +3,33 @@ import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func notesActionPillKeepsWindowInsetsWhenResized() throws {
|
||||
_ = NSApplication.shared
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let suite = "NotesLayout.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
}
|
||||
let controller = NotesController(defaults: defaults, directory: directory)
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
|
||||
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 pill = try #require(descendants(controller.view).first { $0 is ActionPill })
|
||||
for size in [NSSize(width: 750, height: 390), NSSize(width: 570, height: 260)] {
|
||||
window.setContentSize(size)
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
let frame = pill.convert(pill.bounds, to: controller.view)
|
||||
#expect(abs(frame.minY - controller.view.bounds.minY - 8) < 0.5)
|
||||
#expect(abs(controller.view.bounds.maxX - frame.maxX - 8) < 0.5)
|
||||
#expect(abs(frame.height - 36) < 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws {
|
||||
_ = NSApplication.shared
|
||||
|
||||
@@ -49,6 +49,38 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||
#expect(editor.markdownString == after)
|
||||
}
|
||||
}
|
||||
for before in ["\n# Title", "\n## Title", "Text\n\n###### Title", "- [ ] 🦊\n\n## Title"] {
|
||||
reset(before)
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
editor.doCommand(by: #selector(NSResponder.deleteBackward(_:)))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
let joined = editor.markdownString
|
||||
editor.breakUndoCoalescing()
|
||||
editor.undoManager?.removeAllActions()
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
|
||||
editor.undoManager?.endUndoGrouping()
|
||||
#expect(editor.markdownString == before)
|
||||
#expect(editor.selectedRange() == NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.markdownString == joined)
|
||||
editor.undoManager?.redo()
|
||||
#expect(editor.markdownString == before)
|
||||
#expect(controller.save())
|
||||
#expect(try NotesStore(directory: directory).notes.first?.contents.string == before)
|
||||
}
|
||||
for (before, position, after) in [
|
||||
("## Title", 3, "\n## Title"),
|
||||
("## Title", 5, "## Ti\ntle"),
|
||||
("## Title", 8, "## Title\n"),
|
||||
("Text\n## Title", 4, "Text\n\n## Title"),
|
||||
("```text\n## Title\n```", 11, "```text\n## \nTitle\n```")
|
||||
] {
|
||||
reset(before)
|
||||
editor.setSelectedRange(NSRange(location: position, length: 0))
|
||||
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
|
||||
#expect(editor.markdownString == after)
|
||||
}
|
||||
for level in 1...6 {
|
||||
reset("### Title")
|
||||
editor.undoManager?.beginUndoGrouping()
|
||||
@@ -74,3 +106,28 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||
editor.insertText("# ", replacementRange: editor.selectedRange())
|
||||
#expect(editor.markdownString == "```text\n## # Title\n```")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
|
||||
_ = NSApplication.shared
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
|
||||
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.contentView = editor
|
||||
window.makeFirstResponder(editor)
|
||||
defer { window.orderOut(nil) }
|
||||
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
|
||||
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
|
||||
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
|
||||
editor.doCommand(by: command)
|
||||
let title = (editor.string as NSString).range(of: "Title")
|
||||
#expect(editor.selectedRange() == NSRange(location: title.location, length: 0))
|
||||
let caretRect = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil)
|
||||
let titleRect = editor.firstRect(forCharacterRange: NSRange(location: title.location, length: 1), actualRange: nil)
|
||||
try #require(titleRect.height > 0)
|
||||
#expect(caretRect.origin == titleRect.origin)
|
||||
#expect(caretRect.height == titleRect.height)
|
||||
}
|
||||
#expect(editor.markdownString == source)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user