fix: edit note headings by visible paragraph semantics
This commit is contained in:
@@ -167,6 +167,8 @@ 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.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
Checkbox conversion waits for a space or tab after the closing bracket: type `- [ ] `, `- [] `, or `- [x] `. A closing bracket alone remains literal, so typing directly after it does not briefly create and then remove a checkbox. Both `[]` and `[ ]` mean unchecked; toggling a compact `[]` task on produces `[x]`, and toggling it off produces `[ ]`.
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
private static let listPattern = try! NSRegularExpression(
|
private static let listPattern = try! NSRegularExpression(
|
||||||
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
||||||
)
|
)
|
||||||
|
private static let headingPrefix = try! NSRegularExpression(pattern: "^#{1,6} ")
|
||||||
|
|
||||||
|
private static func heading(in source: NSString, at position: Int) -> NSRange? {
|
||||||
|
let line = source.lineRange(for: NSRange(location: position, length: 0))
|
||||||
|
guard let match = headingPrefix.firstMatch(in: source.substring(with: line),
|
||||||
|
range: NSRange(location: 0, length: line.length)),
|
||||||
|
!MarkdownHighlighting.codeBlocks(in: source as String).contains(where: {
|
||||||
|
NSIntersectionRange($0.range, line).length > 0
|
||||||
|
}) else { return nil }
|
||||||
|
return NSRange(location: line.location, length: match.range.length)
|
||||||
|
}
|
||||||
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
||||||
private var checkboxes: [NSButton] = []
|
private var checkboxes: [NSButton] = []
|
||||||
private var horizontalSelection: (anchor: Int, head: Int)?
|
private var horizontalSelection: (anchor: Int, head: Int)?
|
||||||
@@ -241,6 +252,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
guard isEditable else { return }
|
guard isEditable else { return }
|
||||||
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
||||||
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
|
||||||
|
let oldHeading = Self.heading(in: source.string as NSString, at: range.location)
|
||||||
|
var caretPosition = range.location + replacement.length
|
||||||
var existingWhitespace = false
|
var existingWhitespace = false
|
||||||
if typedInput, range.length == 0, replacement.length > 0 {
|
if typedInput, range.length == 0, replacement.length > 0 {
|
||||||
var end = 0
|
var end = 0
|
||||||
@@ -250,7 +263,22 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
|
existingWhitespace = !tail.isEmpty && tail.allSatisfy { $0 == " " || $0 == "\t" }
|
||||||
}
|
}
|
||||||
source.replaceCharacters(in: range, with: replacement)
|
source.replaceCharacters(in: range, with: replacement)
|
||||||
if typedInput, range.length == 0, replacement.length > 0 {
|
// A newly typed prefix at the visible start replaces the paragraph's existing level.
|
||||||
|
if typedInput, range.length == 0, let heading = oldHeading,
|
||||||
|
range.location >= NSMaxRange(heading) {
|
||||||
|
let bodyStart = NSMaxRange(heading)
|
||||||
|
let commandLength = caretPosition - bodyStart
|
||||||
|
if commandLength > 0,
|
||||||
|
let command = Self.headingPrefix.firstMatch(in: (source.string as NSString).substring(
|
||||||
|
with: NSRange(location: bodyStart, length: commandLength)),
|
||||||
|
range: NSRange(location: 0, length: commandLength)),
|
||||||
|
command.range.length == commandLength {
|
||||||
|
source.deleteCharacters(in: heading)
|
||||||
|
caretPosition -= heading.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if typedInput, range.length == 0, replacement.length > 0,
|
||||||
|
caretPosition == range.location + replacement.length {
|
||||||
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
|
let line = (source.string as NSString).lineRange(for: NSRange(location: range.location, length: 0))
|
||||||
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
|
if let match = Self.listPattern.firstMatch(in: source.string, range: line) {
|
||||||
var marker = match.range(at: 2)
|
var marker = match.range(at: 2)
|
||||||
@@ -294,17 +322,30 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
}
|
}
|
||||||
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
||||||
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
||||||
|
let structural = semantic || caretPosition != range.location + replacement.length
|
||||||
if removed.length > 0 || inserted.length > 0 {
|
if removed.length > 0 || inserted.length > 0 {
|
||||||
if semantic { breakUndoCoalescing() }
|
if structural { breakUndoCoalescing() }
|
||||||
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
||||||
if semantic { breakUndoCoalescing() }
|
if structural { breakUndoCoalescing() }
|
||||||
}
|
}
|
||||||
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
let caret = renderedRange(for: NSRange(location: caretPosition, length: 0))
|
||||||
setSelectedRange(NSRange(location: hiddenBoundary(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()
|
||||||
|
if selection.length == 0 {
|
||||||
|
let source = markdownString as NSString
|
||||||
|
let position = markdownInsertionRange(for: selection).location
|
||||||
|
if let heading = Self.heading(in: source, at: position), position == NSMaxRange(heading) {
|
||||||
|
guard heading.location > 0 else { return }
|
||||||
|
let previous = source.lineRange(for: NSRange(location: heading.location - 1, length: 0))
|
||||||
|
let empty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty
|
||||||
|
replaceMarkdown("", range: NSRange(location: heading.location - 1,
|
||||||
|
length: 1 + (empty ? 0 : heading.length)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
let boundary = hiddenBoundary(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)
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
- [x] add action to delete a note (currently selected note)
|
- [x] add action to delete a note (currently selected note)
|
||||||
- [x] make images have rounded corners
|
- [x] make images have rounded corners
|
||||||
- [x] bug: text flashes when inserting characters, which is visually disturbing.
|
- [x] bug: text flashes when inserting characters, which is visually disturbing.
|
||||||
- [ ] bug: deleting at line start on a heading removes the leading `#` symbols and changes formatting, and writing e.g. `#<space>` before the heading on that line doesn't change the heading level accordingly. we should instead:
|
- [x] fix heading editing using paragraph semantics:
|
||||||
- modify heading level based on leading `#.<space>` (interpret as regex)
|
- typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal.
|
||||||
- when deleting (backspace) at line start, interpret as deleting newline and just move heading up.
|
- Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing.
|
||||||
- maybe this warrants compressing elements into a semantic representation such that the underlying text that's being edited isn't necessarily one-to-one normal markdown, but instead something that's easier and less complex to edit and navigate that also fits the visual presentation more naturally.
|
- joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading.
|
||||||
- this should still be converted to normal markdown when saved to the persisted files and the content of those files should then also be converted to the representation discussed when editing.
|
- keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite.
|
||||||
|
- [ ] 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.
|
||||||
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
|
||||||
- track down the cause and fix it.
|
- track down the cause and fix it.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
@testable import Conjure
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesHeadings.\(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: 600, height: 500),
|
||||||
|
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)
|
||||||
|
func reset(_ source: String) {
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: source))
|
||||||
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
editor.refreshTasks()
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
let title = (editor.string as NSString).range(of: "Title")
|
||||||
|
editor.setSelectedRange(NSRange(location: title.location, length: 0))
|
||||||
|
}
|
||||||
|
for (before, after) in [
|
||||||
|
("## Title", "## Title"),
|
||||||
|
("\n## Title", "## Title"),
|
||||||
|
("Text\n## Title", "TextTitle"),
|
||||||
|
("# Text\n## Title", "# TextTitle"),
|
||||||
|
("Text\n\n## Title", "Text\n## Title")
|
||||||
|
] {
|
||||||
|
reset(before)
|
||||||
|
editor.undoManager?.beginUndoGrouping()
|
||||||
|
editor.deleteBackward(nil)
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
if before != after {
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == before)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for level in 1...6 {
|
||||||
|
reset("### Title")
|
||||||
|
editor.undoManager?.beginUndoGrouping()
|
||||||
|
for character in String(repeating: "#", count: level) + " " {
|
||||||
|
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||||
|
}
|
||||||
|
editor.undoManager?.endUndoGrouping()
|
||||||
|
let expected = String(repeating: "#", count: level) + " Title"
|
||||||
|
#expect(editor.markdownString == expected)
|
||||||
|
let title = (editor.string as NSString).range(of: "Title")
|
||||||
|
#expect(editor.selectedRange().location == title.location)
|
||||||
|
let sizes: [CGFloat] = [26, 23, 21, 19, 17, 15]
|
||||||
|
#expect((editor.textStorage?.attribute(.font, at: title.location, effectiveRange: nil) as? NSFont)?.pointSize == sizes[level - 1])
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.markdownString == "### Title")
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.markdownString == expected)
|
||||||
|
#expect(controller.save())
|
||||||
|
let reloaded = try #require(NotesStore(directory: directory).notes.first)
|
||||||
|
#expect(reloaded.contents.string == expected)
|
||||||
|
}
|
||||||
|
reset("```text\n## Title\n```")
|
||||||
|
editor.insertText("# ", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.markdownString == "```text\n## # Title\n```")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user