627 lines
32 KiB
Swift
627 lines
32 KiB
Swift
import AppKit
|
|
|
|
private let literalTaskMarker = NSAttributedString.Key("Conjure.literalTaskMarker")
|
|
private let sourceTaskAttachment = NSAttributedString.Key("Conjure.sourceTaskAttachment")
|
|
private let sourceListParagraph = NSAttributedString.Key("Conjure.sourceListParagraph")
|
|
private let listWhitespace = NSAttributedString.Key("Conjure.listWhitespace")
|
|
|
|
private final class ListAttachment: NSTextAttachment {
|
|
let source: NSAttributedString
|
|
let isTask: Bool
|
|
var marker: String { source.string }
|
|
var checked: Bool { marker.hasSuffix("[x]") || marker.hasSuffix("[X]") }
|
|
|
|
@MainActor
|
|
init(source: NSAttributedString, isTask: Bool) {
|
|
self.source = source
|
|
self.isTask = isTask
|
|
super.init(data: nil, ofType: nil)
|
|
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
|
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
|
attributes: [.font: NotesAppearance.font, .foregroundColor: NotesAppearance.accent])
|
|
cell.alignment = .right
|
|
attachmentCell = cell
|
|
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("List attachments are serialized as Markdown") }
|
|
}
|
|
|
|
private final class ListAttachmentCell: NSTextAttachmentCell {
|
|
nonisolated(unsafe) var columnWidth: CGFloat = 22
|
|
nonisolated override func cellSize() -> NSSize { NSSize(width: columnWidth, height: 21) }
|
|
nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -4) }
|
|
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
|
guard !stringValue.isEmpty else { return }
|
|
let label = attributedStringValue
|
|
let size = label.size()
|
|
label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width,
|
|
y: cellFrame.midY - size.height / 2))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|
private static let listPattern = try! NSRegularExpression(
|
|
pattern: "(?m)^([ \\t]*)([-+*]|[0-9]+[.)])([ \\t]+)(?:(\\[([ xX]?)\\])(?=[ \\t]))?"
|
|
)
|
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
|
private var checkboxes: [NSButton] = []
|
|
private var horizontalSelection: (anchor: Int, head: Int)?
|
|
|
|
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
|
var markdownString: String { markdownContents.string }
|
|
|
|
func setMarkdownContents(_ contents: NSAttributedString) {
|
|
horizontalSelection = nil
|
|
textStorage?.setAttributedString(Self.render(contents))
|
|
refreshTasks()
|
|
}
|
|
|
|
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
|
let result = NSMutableAttributedString(attributedString: contents)
|
|
restoreListLayout(in: result)
|
|
var replacements: [(NSRange, ListAttachment)] = []
|
|
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
|
if let task = value as? ListAttachment { replacements.append((range, task)) }
|
|
}
|
|
for (range, task) in replacements.reversed() {
|
|
let marker = NSMutableAttributedString(attributedString: task.source)
|
|
let markerRange = NSRange(location: 0, length: marker.length)
|
|
marker.removeAttribute(literalTaskMarker, range: markerRange)
|
|
if keepingMetadata { marker.addAttribute(sourceTaskAttachment, value: task, range: markerRange) }
|
|
result.replaceCharacters(in: range, with: marker)
|
|
}
|
|
if !keepingMetadata {
|
|
let range = NSRange(location: 0, length: result.length)
|
|
result.removeAttribute(literalTaskMarker, range: range)
|
|
result.removeAttribute(sourceTaskAttachment, range: range)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func restoreListLayout(in contents: NSMutableAttributedString) {
|
|
let range = NSRange(location: 0, length: contents.length)
|
|
contents.enumerateAttribute(sourceListParagraph, in: range) { value, range, _ in
|
|
guard let value else { return }
|
|
if let style = value as? NSParagraphStyle {
|
|
contents.addAttribute(.paragraphStyle, value: style, range: range)
|
|
} else {
|
|
contents.removeAttribute(.paragraphStyle, range: range)
|
|
}
|
|
}
|
|
contents.removeAttribute(sourceListParagraph, range: range)
|
|
contents.removeAttribute(listWhitespace, range: range)
|
|
}
|
|
|
|
// ponytail: parse the whole note on edits; incremental parsing if large lists make typing slow.
|
|
private static func render(_ contents: NSAttributedString) -> NSAttributedString {
|
|
let source = contents.string as NSString
|
|
let range = NSRange(location: 0, length: source.length)
|
|
let code = MarkdownHighlighting.codeBlocks(in: contents.string)
|
|
let result = NSMutableAttributedString(attributedString: contents)
|
|
result.removeAttribute(sourceTaskAttachment, range: range)
|
|
// Explicit values stop AppKit inheriting display-only attributes from replaced list text.
|
|
result.addAttribute(listWhitespace, value: false, range: range)
|
|
contents.enumerateAttribute(.paragraphStyle, in: range) { style, run, _ in
|
|
result.addAttribute(sourceListParagraph, value: style ?? NSNull(), range: run)
|
|
}
|
|
result.addAttribute(literalTaskMarker, value: false, range: range)
|
|
contents.enumerateAttribute(literalTaskMarker, in: range) { value, range, _ in
|
|
if value as? Bool == true { result.addAttribute(literalTaskMarker, value: true, range: range) }
|
|
}
|
|
for match in listPattern.matches(in: contents.string, range: range).reversed() {
|
|
let isTask = match.range(at: 4).location != NSNotFound
|
|
var markerRange = match.range(at: 2)
|
|
if isTask { markerRange.length = NSMaxRange(match.range(at: 4)) - markerRange.location }
|
|
let line = source.lineRange(for: match.range)
|
|
if !isTask {
|
|
let body = source.substring(with: NSRange(location: NSMaxRange(match.range), length: NSMaxRange(line) - NSMaxRange(match.range)))
|
|
.trimmingCharacters(in: .newlines)
|
|
// Leave a task's bracket prefix editable until its closing bracket has a separator.
|
|
if ["[", "[ ", "[x", "[X"].contains(body)
|
|
|| ["[]", "[ ]", "[x]", "[X]"].contains(where: { body.hasPrefix($0) }) { continue }
|
|
let marker = source.substring(with: markerRange)
|
|
if marker == "-" || marker == "*" {
|
|
let characters = source.substring(with: line).filter { !$0.isWhitespace }
|
|
if characters.count >= 3, characters.allSatisfy({ String($0) == marker }) { continue }
|
|
}
|
|
}
|
|
guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
|
|
var literal = false
|
|
contents.enumerateAttribute(literalTaskMarker, in: markerRange) { value, _, stop in
|
|
if value as? Bool == true { literal = true; stop.pointee = true }
|
|
}
|
|
guard !literal else { continue }
|
|
let marker = source.substring(with: markerRange)
|
|
let existing = contents.attribute(sourceTaskAttachment, at: markerRange.location, effectiveRange: nil) as? ListAttachment
|
|
let attachment: ListAttachment
|
|
if let existing, existing.marker == marker {
|
|
attachment = existing
|
|
} else {
|
|
let markerSource = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: markerRange))
|
|
markerSource.removeAttribute(sourceTaskAttachment, range: NSRange(location: 0, length: markerSource.length))
|
|
attachment = ListAttachment(source: markerSource, isTask: isTask)
|
|
}
|
|
let token = NSMutableAttributedString(attachment: attachment)
|
|
var attributes = contents.attributes(at: markerRange.location, effectiveRange: nil)
|
|
attributes.removeValue(forKey: .attachment)
|
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
|
attributes[literalTaskMarker] = false
|
|
attributes[listWhitespace] = false
|
|
attributes[sourceListParagraph] = attributes[.paragraphStyle] ?? NSNull()
|
|
token.addAttributes(attributes, range: NSRange(location: 0, length: 1))
|
|
result.replaceCharacters(in: markerRange, with: token)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func markdownRange(for rendered: NSRange) -> NSRange {
|
|
func offset(_ position: Int) -> Int {
|
|
var result = min(position, string.utf16.count)
|
|
for task in tasks where NSMaxRange(task.range) <= position {
|
|
result += task.attachment.marker.utf16.count - 1
|
|
}
|
|
return result
|
|
}
|
|
let start = offset(rendered.location)
|
|
return NSRange(location: start, length: offset(NSMaxRange(rendered)) - start)
|
|
}
|
|
|
|
func renderedRange(for markdown: NSRange) -> NSRange {
|
|
func offset(_ position: Int, end: Bool) -> Int {
|
|
var expansion = 0
|
|
for task in tasks {
|
|
let start = task.range.location + expansion
|
|
let length = task.attachment.marker.utf16.count
|
|
if position < start { break }
|
|
if position < start + length {
|
|
return task.range.location + ((position > start && end) ? 1 : 0)
|
|
}
|
|
expansion += length - 1
|
|
}
|
|
return min(max(0, position - expansion), string.utf16.count)
|
|
}
|
|
let start = offset(markdown.location, end: markdown.length == 0)
|
|
return NSRange(location: start, length: offset(NSMaxRange(markdown), end: true) - start)
|
|
}
|
|
|
|
private var textTypingAttributes: [NSAttributedString.Key: Any] {
|
|
var attributes = typingAttributes
|
|
attributes.removeValue(forKey: .attachment)
|
|
attributes.removeValue(forKey: literalTaskMarker)
|
|
attributes.removeValue(forKey: sourceTaskAttachment)
|
|
if let original = attributes.removeValue(forKey: sourceListParagraph) {
|
|
attributes[.paragraphStyle] = original as? NSParagraphStyle
|
|
}
|
|
attributes.removeValue(forKey: listWhitespace)
|
|
return attributes
|
|
}
|
|
|
|
func replaceMarkdown(_ text: String, range: NSRange) {
|
|
replaceSource(NSAttributedString(string: text, attributes: textTypingAttributes), range: range, semantic: true)
|
|
}
|
|
|
|
func replaceMarkdown(_ contents: NSAttributedString, range: NSRange) {
|
|
replaceSource(contents, range: range, semantic: true)
|
|
}
|
|
|
|
func markdownInsertionRange(for rendered: NSRange) -> NSRange {
|
|
guard rendered.length == 0 else { return markdownRange(for: rendered) }
|
|
let body = listWhitespaceBoundary(from: rendered.location, forward: true)
|
|
return markdownRange(for: NSRange(location: body, length: 0))
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
if event.keyCode == 48,
|
|
event.modifierFlags.intersection([.command, .control, .option, .shift]) == .shift,
|
|
delegate?.textView?(self, doCommandBy: #selector(NSResponder.insertBacktab(_:))) == true {
|
|
return
|
|
}
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
|
let replacement: NSAttributedString
|
|
if let attributed = insertString as? NSAttributedString {
|
|
replacement = Self.serialize(attributed, keepingMetadata: true)
|
|
} else if let text = insertString as? String {
|
|
replacement = NSAttributedString(string: text, attributes: textTypingAttributes)
|
|
} else { return }
|
|
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
|
|
replaceSource(replacement, range: markdownInsertionRange(for: range), semantic: false,
|
|
typedInput: insertString is String)
|
|
}
|
|
|
|
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
|
|
guard isEditable else { return }
|
|
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
|
|
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)
|
|
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 before = attributedString()
|
|
let old = before.string as NSString, new = rendered.string as NSString
|
|
func equal(_ left: Int, _ right: Int) -> Bool {
|
|
guard old.character(at: left) == new.character(at: right) else { return false }
|
|
let lhs = before.attribute(.attachment, at: left, effectiveRange: nil) as? NSTextAttachment
|
|
let rhs = rendered.attribute(.attachment, at: right, effectiveRange: nil) as? NSTextAttachment
|
|
if let lhs = lhs as? ListAttachment, let rhs = rhs as? ListAttachment {
|
|
return lhs.marker == rhs.marker
|
|
}
|
|
guard lhs === rhs else { return false }
|
|
return (before.attribute(literalTaskMarker, at: left, effectiveRange: nil) as? Bool == true)
|
|
== (rendered.attribute(literalTaskMarker, at: right, effectiveRange: nil) as? Bool == true)
|
|
}
|
|
var prefix = 0
|
|
while prefix < min(old.length, new.length), equal(prefix, prefix) { prefix += 1 }
|
|
if prefix < old.length { prefix = min(prefix, old.rangeOfComposedCharacterSequence(at: prefix).location) }
|
|
if prefix < new.length { prefix = min(prefix, new.rangeOfComposedCharacterSequence(at: prefix).location) }
|
|
var suffix = 0
|
|
while suffix < min(old.length, new.length) - prefix,
|
|
equal(old.length - suffix - 1, new.length - suffix - 1) { suffix += 1 }
|
|
for text in [old, new] where suffix > 0 && suffix < text.length {
|
|
let boundary = text.length - suffix
|
|
let character = text.rangeOfComposedCharacterSequence(at: boundary)
|
|
if character.location < boundary { suffix = min(suffix, text.length - NSMaxRange(character)) }
|
|
}
|
|
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
|
|
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
|
|
if removed.length > 0 || inserted.length > 0 {
|
|
if semantic { breakUndoCoalescing() }
|
|
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
|
|
if semantic { breakUndoCoalescing() }
|
|
}
|
|
refreshTasks()
|
|
let caret = renderedRange(for: NSRange(location: range.location + replacement.length, length: 0))
|
|
setSelectedRange(NSRange(location: listWhitespaceBoundary(from: caret.location, forward: true), length: 0))
|
|
}
|
|
|
|
override func deleteBackward(_ sender: Any?) {
|
|
let selection = selectedRange()
|
|
let boundary = listWhitespaceBoundary(from: selection.location, forward: false)
|
|
if selection.length == 0, let task = tasks.first(where: { NSMaxRange($0.range) == boundary }) {
|
|
expand(task, backwards: true)
|
|
} else if selection.length > 0 {
|
|
replaceMarkdown("", range: markdownRange(for: selection))
|
|
} else if selection.location > 0 {
|
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location - 1)
|
|
replaceMarkdown("", range: markdownRange(for: range))
|
|
}
|
|
}
|
|
|
|
override func deleteForward(_ sender: Any?) {
|
|
let selection = selectedRange()
|
|
if selection.length == 0, let task = tasks.first(where: { $0.range.location == selection.location }) {
|
|
expand(task, backwards: false)
|
|
} else if selection.length > 0 {
|
|
replaceMarkdown("", range: markdownRange(for: selection))
|
|
} else if selection.location < string.utf16.count {
|
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
|
|
replaceMarkdown("", range: markdownRange(for: range))
|
|
}
|
|
}
|
|
|
|
private func expand(_ task: (range: NSRange, attachment: ListAttachment), backwards: Bool) {
|
|
let marker = task.attachment.source
|
|
let remaining = NSRange(location: backwards ? 0 : 1, length: marker.length - 1)
|
|
let text = NSMutableAttributedString(attributedString: marker.attributedSubstring(from: remaining))
|
|
text.addAttribute(literalTaskMarker, value: true, range: NSRange(location: 0, length: text.length))
|
|
let range = markdownRange(for: task.range)
|
|
replaceSource(text, range: range, semantic: true)
|
|
if !backwards { setSelectedRange(NSRange(location: task.range.location, length: 0)) }
|
|
}
|
|
|
|
// Only separator glyphs are null; indentation and body whitespace remain native stops.
|
|
private func listWhitespaceBoundary(from position: Int, forward: Bool) -> Int {
|
|
guard let storage = textStorage else { return position }
|
|
let index = forward ? position : position - 1
|
|
guard index >= 0, index < storage.length else { return position }
|
|
var range = NSRange()
|
|
guard storage.attribute(listWhitespace, at: index, effectiveRange: &range) as? Bool == true else {
|
|
return position
|
|
}
|
|
return forward ? NSMaxRange(range) : range.location
|
|
}
|
|
|
|
private func visualListBoundary(from position: Int, forward: Bool) -> Int? {
|
|
let boundary = listWhitespaceBoundary(from: position, forward: forward)
|
|
guard let task = tasks.first(where: {
|
|
forward ? $0.range.location == boundary : NSMaxRange($0.range) == boundary
|
|
}) else { return nil }
|
|
return forward ? listWhitespaceBoundary(from: NSMaxRange(task.range), forward: true) : task.range.location
|
|
}
|
|
|
|
override func setSelectedRange(_ charRange: NSRange) {
|
|
horizontalSelection = nil
|
|
super.setSelectedRange(charRange)
|
|
}
|
|
|
|
override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting: Bool) {
|
|
horizontalSelection = nil
|
|
super.setSelectedRange(charRange, affinity: affinity, stillSelecting: stillSelecting)
|
|
}
|
|
|
|
override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) {
|
|
horizontalSelection = nil
|
|
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting)
|
|
}
|
|
|
|
private func moveNatively(forward: Bool, selecting: Bool, sender: Any?) {
|
|
if selecting {
|
|
if forward { super.moveRightAndModifySelection(sender) }
|
|
else { super.moveLeftAndModifySelection(sender) }
|
|
} else {
|
|
if forward { super.moveRight(sender) }
|
|
else { super.moveLeft(sender) }
|
|
}
|
|
}
|
|
|
|
private func moveHorizontally(forward: Bool, selecting: Bool, sender: Any?) {
|
|
let selection = selectedRange()
|
|
let tracked = selecting ? horizontalSelection : nil
|
|
if !selecting { horizontalSelection = nil }
|
|
let anchor: Int
|
|
let head: Int
|
|
var nativeHead: Int?
|
|
if let tracked {
|
|
anchor = tracked.anchor
|
|
head = tracked.head
|
|
} else if selecting, selection.length > 0 {
|
|
// Let AppKit identify the active end of a mouse or otherwise native selection.
|
|
moveNatively(forward: forward, selecting: true, sender: sender)
|
|
let moved = selectedRange()
|
|
guard moved != selection else { return }
|
|
anchor = moved.location == selection.location ? selection.location : NSMaxRange(selection)
|
|
head = anchor == selection.location ? NSMaxRange(selection) : selection.location
|
|
nativeHead = moved.location == anchor ? NSMaxRange(moved) : moved.location
|
|
} else {
|
|
anchor = selection.location
|
|
head = selection.location
|
|
}
|
|
|
|
if (selecting || selection.length == 0),
|
|
let target = visualListBoundary(from: head, forward: forward) {
|
|
if selecting { setHorizontalSelection(anchor: anchor, head: target) }
|
|
else { setSelectedRange(NSRange(location: target, length: 0)) }
|
|
return
|
|
}
|
|
|
|
// Native movement supplies composed-character, bidi, and line-boundary behavior.
|
|
// Starting inside a null run already means "after the marker", so advance into the body.
|
|
let origin = listWhitespaceBoundary(from: head, forward: true)
|
|
if let nativeHead, origin == head {
|
|
let target = listWhitespaceBoundary(from: nativeHead, forward: true)
|
|
if target != nativeHead { setHorizontalSelection(anchor: anchor, head: target) }
|
|
return
|
|
}
|
|
if tracked != nil || nativeHead != nil || (selection.length == 0 && origin != head) {
|
|
setSelectedRange(NSRange(location: origin, length: 0))
|
|
}
|
|
moveNatively(forward: forward, selecting: selecting, sender: sender)
|
|
let moved = selectedRange()
|
|
let newHead = selecting && moved.location == origin ? NSMaxRange(moved) : moved.location
|
|
let target = listWhitespaceBoundary(from: newHead, forward: true)
|
|
if selecting {
|
|
if tracked != nil || nativeHead != nil || target != newHead || origin != head {
|
|
setHorizontalSelection(anchor: anchor, head: target)
|
|
}
|
|
} else if target != newHead { setSelectedRange(NSRange(location: target, length: 0)) }
|
|
}
|
|
|
|
private func setHorizontalSelection(anchor: Int, head: Int) {
|
|
setSelectedRange(NSRange(location: min(anchor, head), length: abs(head - anchor)),
|
|
affinity: selectionAffinity, stillSelecting: false)
|
|
horizontalSelection = (anchor, head)
|
|
}
|
|
|
|
override func moveRight(_ sender: Any?) {
|
|
moveHorizontally(forward: true, selecting: false, sender: sender)
|
|
}
|
|
|
|
override func moveLeft(_ sender: Any?) {
|
|
moveHorizontally(forward: false, selecting: false, sender: sender)
|
|
}
|
|
|
|
override func moveRightAndModifySelection(_ sender: Any?) {
|
|
moveHorizontally(forward: true, selecting: true, sender: sender)
|
|
}
|
|
|
|
override func moveLeftAndModifySelection(_ sender: Any?) {
|
|
moveHorizontally(forward: false, selecting: true, sender: sender)
|
|
}
|
|
|
|
private func markdownSelectionWriter() -> NSTextView {
|
|
let writer = NSTextView()
|
|
writer.isRichText = isRichText
|
|
writer.importsGraphics = importsGraphics
|
|
writer.textStorage?.setAttributedString(Self.serialize(attributedString().attributedSubstring(from: selectedRange())))
|
|
writer.setSelectedRange(NSRange(location: 0, length: writer.string.utf16.count))
|
|
return writer
|
|
}
|
|
|
|
override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
|
markdownSelectionWriter().writeSelection(to: pboard, type: type)
|
|
}
|
|
|
|
override func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool {
|
|
markdownSelectionWriter().writeSelection(to: pboard, types: types)
|
|
}
|
|
|
|
override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
|
guard isEditable else { return false }
|
|
// Let AppKit decode its supported rich text, file and image formats without touching this undo stack.
|
|
let reader = NSTextView()
|
|
reader.isRichText = isRichText
|
|
reader.importsGraphics = importsGraphics
|
|
guard reader.readSelection(from: pboard, type: type) else { return false }
|
|
replaceSource(Self.serialize(reader.attributedString(), keepingMetadata: true),
|
|
range: markdownInsertionRange(for: selectedRange()), semantic: true)
|
|
return true
|
|
}
|
|
|
|
override func cut(_ sender: Any?) {
|
|
guard isEditable, selectedRange().length > 0 else { return }
|
|
copy(sender)
|
|
replaceMarkdown("", range: markdownRange(for: selectedRange()))
|
|
}
|
|
|
|
override func didChangeText() {
|
|
horizontalSelection = nil
|
|
refreshTasks()
|
|
super.didChangeText()
|
|
}
|
|
|
|
func refreshTasks() {
|
|
tasks = []
|
|
let contents = attributedString()
|
|
contents.enumerateAttribute(.attachment, in: NSRange(location: 0, length: contents.length)) { value, range, _ in
|
|
if let attachment = value as? ListAttachment { tasks.append((range, attachment)) }
|
|
}
|
|
let taskIndices = tasks.indices.filter { tasks[$0].attachment.isTask }
|
|
while checkboxes.count > taskIndices.count {
|
|
let checkbox = checkboxes.removeLast()
|
|
if window?.firstResponder === checkbox { window?.makeFirstResponder(self) }
|
|
checkbox.removeFromSuperview()
|
|
}
|
|
while checkboxes.count < taskIndices.count {
|
|
let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
|
checkbox.controlSize = .small
|
|
checkbox.contentTintColor = NotesAppearance.accent
|
|
checkbox.sizeToFit()
|
|
checkboxes.append(checkbox)
|
|
addSubview(checkbox)
|
|
}
|
|
let source = string as NSString
|
|
for (index, taskIndex) in taskIndices.enumerated() {
|
|
let task = tasks[taskIndex]
|
|
let checkbox = checkboxes[index]
|
|
checkbox.tag = taskIndex
|
|
checkbox.state = task.attachment.checked ? .on : .off
|
|
checkbox.isEnabled = isEditable
|
|
let end = NSMaxRange(source.lineRange(for: task.range))
|
|
let title = source.substring(with: NSRange(location: NSMaxRange(task.range), length: end - NSMaxRange(task.range)))
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
checkbox.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
|
checkbox.toolTip = title
|
|
}
|
|
applyListLayout()
|
|
positionCheckboxes()
|
|
}
|
|
|
|
private func applyListLayout() {
|
|
guard let storage = textStorage, let layoutManager else { return }
|
|
let range = NSRange(location: 0, length: storage.length)
|
|
layoutManager.delegate = self
|
|
storage.beginEditing()
|
|
Self.restoreListLayout(in: storage)
|
|
let markerFont = NotesAppearance.font
|
|
let column = ceil(tasks.reduce(CGFloat(22)) { width, task in
|
|
guard !task.attachment.isTask else { return width }
|
|
return max(width, (task.attachment.marker as NSString).size(withAttributes: [.font: markerFont]).width + 4)
|
|
})
|
|
let space = (" " as NSString).size(withAttributes: [.font: markerFont]).width
|
|
let source = storage.string as NSString
|
|
for task in tasks {
|
|
let paragraph = source.paragraphRange(for: task.range)
|
|
let indentation = source.substring(with: NSRange(location: paragraph.location, length: task.range.location - paragraph.location))
|
|
let depth = indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
|
|
let style = (storage.attribute(.paragraphStyle, at: task.range.location, effectiveRange: nil) as? NSParagraphStyle
|
|
?? NSParagraphStyle.default).mutableCopy() as! NSMutableParagraphStyle
|
|
storage.enumerateAttribute(.paragraphStyle, in: paragraph) { original, run, _ in
|
|
storage.addAttribute(sourceListParagraph, value: original ?? NSNull(), range: run)
|
|
}
|
|
// Keep indentation as native whitespace; null paragraph-leading glyphs lose first-line layout.
|
|
style.firstLineHeadIndent = 0
|
|
style.lineSpacing = NotesAppearance.paragraph.lineSpacing
|
|
style.paragraphSpacing = NotesAppearance.paragraph.paragraphSpacing
|
|
style.headIndent = CGFloat(depth) * space + column
|
|
style.tabStops = []
|
|
style.defaultTabInterval = 4 * space
|
|
storage.addAttribute(.paragraphStyle, value: style, range: paragraph)
|
|
let separator = NSMaxRange(task.range)
|
|
if separator < NSMaxRange(paragraph), source.character(at: separator) == 32 || source.character(at: separator) == 9 {
|
|
storage.addAttribute(listWhitespace, value: true,
|
|
range: NSRange(location: separator, length: 1))
|
|
}
|
|
(task.attachment.attachmentCell as? ListAttachmentCell)?.columnWidth = column
|
|
task.attachment.bounds.size.width = column
|
|
}
|
|
storage.endEditing()
|
|
// Only the required separator is hidden; additional whitespace is visible item-body text.
|
|
layoutManager.invalidateGlyphs(forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
|
|
}
|
|
|
|
func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>,
|
|
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
|
|
characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont,
|
|
forGlyphRange glyphRange: NSRange) -> Int {
|
|
guard let storage = layoutManager.textStorage,
|
|
(0..<glyphRange.length).contains(where: {
|
|
storage.attribute(listWhitespace, at: charIndexes[$0], effectiveRange: nil) as? Bool == true
|
|
}) else { return 0 }
|
|
var properties = Array(UnsafeBufferPointer(start: props, count: glyphRange.length))
|
|
for index in properties.indices {
|
|
if storage.attribute(listWhitespace, at: charIndexes[index], effectiveRange: nil) as? Bool == true {
|
|
properties[index] = .null
|
|
}
|
|
}
|
|
layoutManager.setGlyphs(glyphs, properties: &properties, characterIndexes: charIndexes,
|
|
font: aFont, forGlyphRange: glyphRange)
|
|
return glyphRange.length
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
positionCheckboxes()
|
|
}
|
|
|
|
private func positionCheckboxes() {
|
|
guard !checkboxes.isEmpty, let layoutManager, let textContainer else { return }
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
for checkbox in checkboxes {
|
|
let task = tasks[checkbox.tag]
|
|
let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
|
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
|
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
|
checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2))
|
|
}
|
|
}
|
|
|
|
@objc private func toggleTask(_ sender: NSButton) {
|
|
guard isEditable, tasks.indices.contains(sender.tag), tasks[sender.tag].attachment.isTask else { return }
|
|
let task = tasks[sender.tag]
|
|
let selection = selectedRange()
|
|
let marker = task.attachment.marker as NSString
|
|
let range = markdownRange(for: task.range)
|
|
let opening = marker.range(of: "[", options: .backwards).location
|
|
replaceMarkdown(task.attachment.checked ? " " : "x",
|
|
range: NSRange(location: range.location + opening + 1,
|
|
length: marker.length - opening - 2))
|
|
setSelectedRange(selection)
|
|
}
|
|
}
|