fix: batch note formatting to prevent typing flashes
This commit is contained in:
@@ -145,6 +145,8 @@ 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 formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
|
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, or deletion 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.
|
||||||
|
|
||||||
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
|
Note text fades over 16 points at the editor’s scroll edges rather than clipping sharply above the formatting strip. Matching 16-point scroll insets keep the first and last lines clear of the fade when scrolled to either end. The 36-point title header and footer meet the editor without extra vertical gaps.
|
||||||
|
|
||||||
Formatting shortcuts use the same actions as the bottom strip. 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 shortcuts use the same actions as the bottom strip. 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.
|
||||||
|
|||||||
@@ -274,8 +274,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
func textDidChange(_ notification: Notification) {
|
||||||
isDirty = true
|
isDirty = true
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
editor.refreshTasks()
|
|
||||||
_ = save()
|
_ = save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,8 +579,6 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
|||||||
editor.undoManager?.removeAllActions()
|
editor.undoManager?.removeAllActions()
|
||||||
editor.isEditable = true
|
editor.isEditable = true
|
||||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
MarkdownHighlighting.apply(to: editor)
|
|
||||||
editor.refreshTasks()
|
|
||||||
noteTitle.stringValue = note.title
|
noteTitle.stringValue = note.title
|
||||||
status.isHidden = true
|
status.isHidden = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
||||||
if semantic { breakUndoCoalescing() }
|
if semantic { breakUndoCoalescing() }
|
||||||
}
|
}
|
||||||
refreshTasks()
|
|
||||||
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
||||||
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
|
||||||
}
|
}
|
||||||
@@ -538,16 +537,18 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
||||||
checkbox.toolTip = title
|
checkbox.toolTip = title
|
||||||
}
|
}
|
||||||
applyListLayout()
|
applyFormatting()
|
||||||
positionCheckboxes()
|
positionCheckboxes()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyListLayout() {
|
private func applyFormatting() {
|
||||||
guard let storage = textStorage, let layoutManager else { return }
|
guard let storage = textStorage, let layoutManager else { return }
|
||||||
let range = NSRange(location: 0, length: storage.length)
|
let range = NSRange(location: 0, length: storage.length)
|
||||||
layoutManager.delegate = self
|
layoutManager.delegate = self
|
||||||
storage.beginEditing()
|
storage.beginEditing()
|
||||||
Self.restoreListLayout(in: storage)
|
Self.restoreListLayout(in: storage)
|
||||||
|
// Publish Markdown and list styles together, never the unindented reset between them.
|
||||||
|
MarkdownHighlighting.apply(to: self)
|
||||||
let markerFont = NotesAppearance.font
|
let markerFont = NotesAppearance.font
|
||||||
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
|
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
|
||||||
guard !task.attachment.isTask else { return width }
|
guard !task.attachment.isTask else { return width }
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
- [x] conjure notes:
|
- [x] conjure notes:
|
||||||
- [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
|
||||||
- [ ] 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:
|
||||||
|
- modify heading level based on leading `#.<space>` (interpret as regex)
|
||||||
|
- when deleting (backspace) at line start, interpret as deleting newline and just move heading up.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- [ ] 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.
|
||||||
@@ -41,6 +41,57 @@ func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
|
|||||||
#expect(reloaded.contents.string == source)
|
#expect(reloaded.contents.string == source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class NotesLayoutProbe: NSLayoutManager {
|
||||||
|
var observeFormatting: ((NSTextStorage) -> Void)?
|
||||||
|
|
||||||
|
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
|
||||||
|
range newCharRange: NSRange, changeInLength delta: Int,
|
||||||
|
invalidatedRange invalidatedCharRange: NSRange) {
|
||||||
|
observeFormatting?(textStorage)
|
||||||
|
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
|
||||||
|
changeInLength: delta, invalidatedRange: invalidatedCharRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "NotesFlashing.\(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 store = try NotesStore(directory: directory)
|
||||||
|
let id = try store.create()
|
||||||
|
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
|
||||||
|
defaults.set(id.uuidString, forKey: "selectedNote")
|
||||||
|
let controller = NotesController(defaults: defaults, directory: directory)
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
|
||||||
|
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)
|
||||||
|
let layout = NotesLayoutProbe()
|
||||||
|
try #require(editor.textContainer).replaceLayoutManager(layout)
|
||||||
|
editor.refreshTasks()
|
||||||
|
window.makeFirstResponder(editor)
|
||||||
|
layout.observeFormatting = { storage in
|
||||||
|
let text = storage.string as NSString
|
||||||
|
let heading = text.range(of: "Heading")
|
||||||
|
let task = text.range(of: "Task")
|
||||||
|
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
|
||||||
|
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
|
||||||
|
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
|
||||||
|
}
|
||||||
|
defer { layout.observeFormatting = nil }
|
||||||
|
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
|
||||||
|
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
|
||||||
|
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
@@ -196,8 +247,6 @@ func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
|
|||||||
#expect(try shortcut("b"))
|
#expect(try shortcut("b"))
|
||||||
#expect(editor.markdownString == "fox 🦊")
|
#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(try shortcut("x", shift: true))
|
||||||
#expect(editor.markdownString == "~~fox 🦊~~")
|
#expect(editor.markdownString == "~~fox 🦊~~")
|
||||||
#expect(try shortcut("x", shift: true))
|
#expect(try shortcut("x", shift: true))
|
||||||
|
|||||||
Reference in New Issue
Block a user