fix: wait for typed separator before completing list markers
This commit is contained in:
@@ -143,6 +143,8 @@ Edit Markdown directly: headings, bold, italic, lists, links, inline code, and f
|
|||||||
|
|
||||||
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 `[ ]`.
|
||||||
|
|
||||||
|
When typing a list marker before existing whitespace on an otherwise blank line, the marker stays literal until you explicitly type Space or Tab immediately after it. Existing whitespace does not complete the marker for you. Loading or pasting valid list Markdown still renders it directly.
|
||||||
|
|
||||||
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal.
|
||||||
|
|
||||||
Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling.
|
Unordered markers display as `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling.
|
||||||
|
|||||||
@@ -216,14 +216,39 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
replacement = NSAttributedString(string: text, attributes: textTypingAttributes)
|
replacement = NSAttributedString(string: text, attributes: textTypingAttributes)
|
||||||
} else { return }
|
} else { return }
|
||||||
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
|
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
|
||||||
replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false)
|
replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false,
|
||||||
|
typedInput: insertString is String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool) {
|
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
|
||||||
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 }
|
||||||
|
var existingWhitespace = false
|
||||||
|
if typedInput, range.length == 0, replacement.length > 0 {
|
||||||
|
var end = 0
|
||||||
|
(source.string as NSString).getLineStart(nil, end: nil, contentsEnd: &end,
|
||||||
|
for: NSRange(location: range.location, length: 0))
|
||||||
|
let tail = (source.string as NSString).substring(with: NSRange(location: range.location, length: max(0, end - range.location)))
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
var marker = match.range(at: 2)
|
||||||
|
if match.range(at: 4).location != NSNotFound {
|
||||||
|
marker.length = NSMaxRange(match.range(at: 4)) - marker.location
|
||||||
|
}
|
||||||
|
if range.location == NSMaxRange(marker),
|
||||||
|
replacement.string.allSatisfy({ $0 == " " || $0 == "\t" }) {
|
||||||
|
source.removeAttribute(literalTaskMarker, range: marker)
|
||||||
|
} else if existingWhitespace, range.location >= marker.location,
|
||||||
|
range.location + replacement.length == NSMaxRange(marker) {
|
||||||
|
source.addAttribute(literalTaskMarker, value: true, range: marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let rendered = Self.render(source)
|
let rendered = Self.render(source)
|
||||||
let before = attributedString()
|
let before = attributedString()
|
||||||
let old = before.string as NSString, new = rendered.string as NSString
|
let old = before.string as NSString, new = rendered.string as NSString
|
||||||
|
|||||||
@@ -1095,3 +1095,48 @@ func extraListBodyWhitespaceIsVisibleAndHasNormalCursorStops() throws {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typingMarkersBeforeExistingWhitespaceWaitsForAnExplicitSeparator() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 520, height: 200))
|
||||||
|
editor.isRichText = true
|
||||||
|
editor.allowsUndo = true
|
||||||
|
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
window.contentView = editor
|
||||||
|
defer { window.orderOut(nil) }
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
for marker in ["-", "*", "+", "12.", "9)", "- []", "- [x]"] {
|
||||||
|
for completion in [" ", "\t"] {
|
||||||
|
let prefix = "prose\n \t"
|
||||||
|
let tail = " \t"
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: prefix + tail))
|
||||||
|
editor.setSelectedRange(NSRange(location: prefix.utf16.count, length: 0))
|
||||||
|
for character in marker {
|
||||||
|
editor.insertText(String(character), replacementRange: editor.selectedRange())
|
||||||
|
}
|
||||||
|
#expect(editor.string == prefix + marker + tail)
|
||||||
|
#expect(editor.markdownString == prefix + marker + tail)
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.removeAllActions()
|
||||||
|
editor.insertText(completion, replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
|
||||||
|
#expect(editor.markdownString == prefix + marker + completion + tail)
|
||||||
|
editor.breakUndoCoalescing()
|
||||||
|
editor.undoManager?.undo()
|
||||||
|
#expect(editor.string == prefix + marker + tail)
|
||||||
|
editor.undoManager?.redo()
|
||||||
|
#expect(editor.string == prefix + "\u{fffc}" + completion + tail)
|
||||||
|
editor.insertText("text", replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.markdownString == prefix + marker + completion + "text" + tail)
|
||||||
|
// Existing Markdown still renders on loading or pasting, without a typing gate.
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: marker + tail))
|
||||||
|
#expect(editor.string == "\u{fffc}" + tail)
|
||||||
|
editor.setMarkdownContents(NSAttributedString(string: tail))
|
||||||
|
editor.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
editor.insertText(NSAttributedString(string: marker + " pasted"), replacementRange: editor.selectedRange())
|
||||||
|
#expect(editor.string == "\u{fffc} pasted" + tail)
|
||||||
|
#expect(editor.markdownString == marker + " pasted" + tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user