feat: render interactive markdown task checkboxes
This commit is contained in:
@@ -7,7 +7,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil)
|
||||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||||
private let actions = ApplicationActionsController()
|
||||
private let editor = NSTextView()
|
||||
private let editor = NotesTextView()
|
||||
private let status = NSTextField(labelWithString: "")
|
||||
private let defaults: UserDefaults
|
||||
private var store: NotesStore?
|
||||
@@ -116,6 +116,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
func textDidChange(_ notification: Notification) {
|
||||
isDirty = true
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
_ = save()
|
||||
}
|
||||
|
||||
@@ -261,6 +262,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
editor.isEditable = true
|
||||
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
|
||||
MarkdownHighlighting.apply(to: editor)
|
||||
editor.refreshTasks()
|
||||
status.stringValue = "Saved locally · Markdown · Paste or drop images"
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
}
|
||||
@@ -297,6 +299,88 @@ private final class NotesView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotesTextView: NSTextView {
|
||||
private struct Task {
|
||||
let marker: NSRange
|
||||
let state: NSRange
|
||||
let checked: Bool
|
||||
let title: String
|
||||
}
|
||||
private static let taskPattern = try! NSRegularExpression(
|
||||
pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX])\\])(?=[ \\t]|\\r?$)"
|
||||
)
|
||||
private var tasks: [Task] = []
|
||||
private var checkboxes: [NSButton] = []
|
||||
|
||||
// ponytail: scan the whole note on edits; incremental parsing if large task lists make typing slow.
|
||||
func refreshTasks() {
|
||||
guard let layoutManager else { return }
|
||||
let source = string as NSString
|
||||
let range = NSRange(location: 0, length: source.length)
|
||||
layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: range)
|
||||
let code = MarkdownHighlighting.codeBlocks(in: string)
|
||||
tasks = Self.taskPattern.matches(in: string, range: range).compactMap { match in
|
||||
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { return nil }
|
||||
let end = NSMaxRange(source.lineRange(for: match.range))
|
||||
let title = source.substring(with: NSRange(location: NSMaxRange(match.range), length: end - NSMaxRange(match.range)))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Task(marker: match.range(at: 1), state: match.range(at: 2),
|
||||
checked: source.substring(with: match.range(at: 2)) != " ", title: title)
|
||||
}
|
||||
while checkboxes.count > tasks.count {
|
||||
let checkbox = checkboxes.removeLast()
|
||||
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
|
||||
checkbox.removeFromSuperview()
|
||||
}
|
||||
while checkboxes.count < tasks.count {
|
||||
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
||||
checkbox.controlSize = .small
|
||||
checkbox.contentTintColor = SunsetPalette.accent
|
||||
checkbox.sizeToFit()
|
||||
checkboxes.append(checkbox)
|
||||
addSubview(checkbox)
|
||||
}
|
||||
for (index, task) in tasks.enumerated() {
|
||||
let checkbox = checkboxes[index]
|
||||
checkbox.tag = index
|
||||
checkbox.state = task.checked ? .on : .off
|
||||
checkbox.isEnabled = isEditable
|
||||
checkbox.setAccessibilityLabel(task.title.isEmpty ? "Task" : task.title)
|
||||
checkbox.toolTip = task.title
|
||||
layoutManager.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: task.marker)
|
||||
}
|
||||
positionCheckboxes()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
positionCheckboxes()
|
||||
}
|
||||
|
||||
private func positionCheckboxes() {
|
||||
guard let layoutManager, let textContainer else { return }
|
||||
layoutManager.ensureLayout(for: textContainer)
|
||||
for (index, task) in tasks.enumerated() {
|
||||
let glyphs = layoutManager.glyphRange(forCharacterRange: task.marker, actualCharacterRange: nil)
|
||||
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
||||
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
||||
let checkbox = checkboxes[index]
|
||||
checkbox.setFrameOrigin(NSPoint(x: rect.minX, y: rect.midY - checkbox.frame.height / 2))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toggleTask(_ sender: NSButton) {
|
||||
guard isEditable, tasks.indices.contains(sender.tag) else { return }
|
||||
let task = tasks[sender.tag]
|
||||
let selection = selectedRange()
|
||||
breakUndoCoalescing()
|
||||
insertText(task.checked ? " " : "x", replacementRange: task.state)
|
||||
breakUndoCoalescing()
|
||||
setSelectedRange(selection)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MarkdownHighlighting {
|
||||
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
|
||||
|
||||
Reference in New Issue
Block a user