682 lines
38 KiB
Swift
682 lines
38 KiB
Swift
import AppKit
|
|
import ConjureKit
|
|
import Highlighter
|
|
|
|
@MainActor
|
|
final class NotesTextView: NSTextView {
|
|
private let edits: UndoManager = {
|
|
let manager = UndoManager()
|
|
manager.groupsByEvent = false
|
|
return manager
|
|
}()
|
|
private var insertionSession = false
|
|
private var styling = false
|
|
private var emptyTypingAttributes: [NSAttributedString.Key: Any]?
|
|
private var operationDepth = 0
|
|
private var documentChanged = false
|
|
private var markerColumn: CGFloat = 22
|
|
private var checkboxes: [NSButton] = []
|
|
private var listParagraphs: [(range: NSRange, kind: String, depth: Int, number: Int, checked: Bool)] = []
|
|
private var themeObservation: ThemeObservation?
|
|
|
|
override init(frame frameRect: NSRect = .zero) {
|
|
super.init(frame: frameRect)
|
|
}
|
|
|
|
override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
|
|
super.init(frame: frameRect, textContainer: container)
|
|
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("Conjure creates note editors programmatically") }
|
|
|
|
/// Only visual attributes change here; semantic marks, selection and undo sessions stay intact.
|
|
private func applyTheme() {
|
|
insertionPointColor = ThemePalette.accent
|
|
selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill]
|
|
let typing = currentTypingAttributes
|
|
if let storage = textStorage {
|
|
let full = NSRange(location: 0, length: storage.length)
|
|
edits.disableUndoRegistration()
|
|
storage.beginEditing()
|
|
storage.enumerateAttributes(in: full) { attributes, range, _ in
|
|
let codeBlock = attributes[NoteAttributes.block] as? String == "code"
|
|
let inlineCode = attributes[NoteAttributes.inlineCode] as? Bool == true
|
|
storage.addAttribute(.foregroundColor,
|
|
value: inlineCode || attributes[.link] != nil ? ThemePalette.accent : ThemePalette.text,
|
|
range: range)
|
|
if codeBlock || inlineCode {
|
|
storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range)
|
|
}
|
|
}
|
|
applyCodeColors(to: storage)
|
|
storage.endEditing()
|
|
edits.enableUndoRegistration()
|
|
}
|
|
setTypingAttributes(typing)
|
|
for button in checkboxes { button.contentTintColor = ThemePalette.accent }
|
|
needsDisplay = true
|
|
}
|
|
|
|
private func applyCodeColors(to storage: NSTextStorage) {
|
|
let source = storage.string as NSString
|
|
storage.enumerateAttribute(NoteAttributes.block, in: NSRange(location: 0, length: storage.length)) { value, range, _ in
|
|
guard value as? String == "code" else { return }
|
|
let code = source.substring(with: range)
|
|
let language = storage.attribute(NoteAttributes.language, at: range.location, effectiveRange: nil) as? String
|
|
guard let highlighted = Self.highlighter.highlight(code, as: language), highlighted.string == code else { return }
|
|
highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { value, run, _ in
|
|
if let value { storage.addAttribute(.foregroundColor, value: value,
|
|
range: NSRange(location: range.location + run.location, length: run.length)) }
|
|
}
|
|
}
|
|
}
|
|
var vimState = NotesVimState() {
|
|
didSet { if oldValue.mode != vimState.mode { needsDisplay = true } }
|
|
}
|
|
var vimModeChanged: (() -> Void)?
|
|
var vimEnabled = false {
|
|
didSet { endInsertionSession(); resetVim(); needsDisplay = true }
|
|
}
|
|
override var undoManager: UndoManager? { edits }
|
|
var documentContents: NSAttributedString { attributedString() }
|
|
|
|
func setDocumentContents(_ contents: NSAttributedString) {
|
|
endInsertionSession()
|
|
resetVim()
|
|
textStorage?.setAttributedString(contents)
|
|
applyDocumentStyle()
|
|
setSelectedRange(NSRange(location: 0, length: 0))
|
|
setTypingAttributes(attributes(at: 0))
|
|
}
|
|
|
|
func beginInsertionSession() {
|
|
guard !insertionSession else { return }
|
|
breakUndoCoalescing()
|
|
edits.beginUndoGrouping()
|
|
insertionSession = true
|
|
}
|
|
|
|
func endInsertionSession() {
|
|
guard insertionSession else { return }
|
|
breakUndoCoalescing()
|
|
insertionSession = false
|
|
edits.endUndoGrouping()
|
|
}
|
|
|
|
private func grouped(_ action: () -> Void) {
|
|
let ownsGroup = edits.groupingLevel == 0
|
|
if ownsGroup { edits.beginUndoGrouping() }
|
|
operationDepth += 1
|
|
action()
|
|
operationDepth -= 1
|
|
if operationDepth == 0, documentChanged {
|
|
documentChanged = false
|
|
let typing = currentTypingAttributes
|
|
applyDocumentStyle()
|
|
super.didChangeText()
|
|
setTypingAttributes(typing)
|
|
}
|
|
if ownsGroup { edits.endUndoGrouping() }
|
|
}
|
|
|
|
|
|
func replaceDocument(_ replacement: NSAttributedString, range: NSRange, selection: NSRange? = nil) {
|
|
guard isEditable, let storage = textStorage, range.location >= 0, range.location <= storage.length,
|
|
range.length >= 0, range.length <= storage.length - range.location else { return }
|
|
grouped {
|
|
// Preflight normally captures AppKit undo; this transaction owns the attributed inverse.
|
|
edits.disableUndoRegistration()
|
|
let allowed = shouldChangeText(in: range, replacementString: replacement.string)
|
|
edits.enableUndoRegistration()
|
|
guard allowed else { return }
|
|
let previous = storage.attributedSubstring(from: range)
|
|
let previousSelection = selectedRange()
|
|
let previousTyping = currentTypingAttributes
|
|
edits.registerUndo(withTarget: self) { editor in
|
|
editor.replaceDocument(previous,
|
|
range: NSRange(location: range.location, length: replacement.length), selection: previousSelection)
|
|
}
|
|
storage.replaceCharacters(in: range, with: replacement)
|
|
setSelectedRange(selection ?? NSRange(location: range.location + replacement.length, length: 0))
|
|
let typing = replacement.length > 0 ? replacement.attributes(at: replacement.length - 1, effectiveRange: nil)
|
|
: paragraph(at: selectedRange().location).length == 0 ? previousTyping : attributes(at: selectedRange().location)
|
|
setTypingAttributes(typing)
|
|
didChangeText()
|
|
}
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
if handleVimKey(event) { return }
|
|
if vimEnabled, vimMode == .insert, !event.modifierFlags.contains(.command) { beginInsertionSession() }
|
|
grouped { super.keyDown(with: event) }
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
let resigned = super.resignFirstResponder()
|
|
if resigned { endInsertionSession(); clearVimCommand() }
|
|
return resigned
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
endInsertionSession()
|
|
if vimEnabled && vimMode != .insert { resetVim() }
|
|
else { clearVimCommand() }
|
|
super.mouseDown(with: event)
|
|
}
|
|
|
|
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
|
if vimEnabled, vimMode == .insert { beginInsertionSession() }
|
|
let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange
|
|
let replacement: NSAttributedString
|
|
if let value = insertString as? NSAttributedString { replacement = value }
|
|
else if let value = insertString as? String {
|
|
var typing = currentTypingAttributes
|
|
if range.location < string.utf16.count {
|
|
let context = attributes(at: range.location)
|
|
for key in Self.blockKeys {
|
|
typing.removeValue(forKey: key)
|
|
if let value = context[key] { typing[key] = value }
|
|
}
|
|
}
|
|
typing.removeValue(forKey: .attachment)
|
|
replacement = NSAttributedString(string: value, attributes: typing)
|
|
} else { return }
|
|
grouped {
|
|
replaceDocument(replacement, range: range)
|
|
if insertString is String, !hasMarkedText(), replacement.length > 0 { consumeShortcut() }
|
|
}
|
|
}
|
|
|
|
private func attributes(at position: Int) -> [NSAttributedString.Key: Any] {
|
|
guard let storage = textStorage, storage.length > 0 else {
|
|
return [.font: NotesAppearance.font, .foregroundColor: ThemePalette.text, .paragraphStyle: NotesAppearance.paragraph]
|
|
}
|
|
var result = storage.attributes(at: min(max(0, position), storage.length - 1), effectiveRange: nil)
|
|
result.removeValue(forKey: .attachment)
|
|
return result
|
|
}
|
|
|
|
private var currentTypingAttributes: [NSAttributedString.Key: Any] {
|
|
paragraph(at: selectedRange().location).length == 0 ? emptyTypingAttributes ?? typingAttributes : typingAttributes
|
|
}
|
|
|
|
private func setTypingAttributes(_ original: [NSAttributedString.Key: Any]) {
|
|
var attrs = original
|
|
attrs.removeValue(forKey: .attachment)
|
|
let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1))
|
|
let heading = attrs[NoteAttributes.block] as? String == "heading"
|
|
let size: CGFloat = heading ? [26, 23, 21, 19, 17, 15][level - 1] : 15
|
|
let font = NSFont.monospacedSystemFont(ofSize: size,
|
|
weight: attrs[NoteAttributes.bold] as? Bool == true ? .bold : heading ? .semibold : .regular)
|
|
attrs[.font] = attrs[NoteAttributes.italic] as? Bool == true
|
|
? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font
|
|
attrs[.foregroundColor] = attrs[NoteAttributes.inlineCode] as? Bool == true ? ThemePalette.accent : ThemePalette.text
|
|
typingAttributes = attrs
|
|
emptyTypingAttributes = paragraph(at: selectedRange().location).length == 0 ? attrs : nil
|
|
}
|
|
|
|
private static let blockKeys = [NoteAttributes.block, NoteAttributes.headingLevel, NoteAttributes.depth,
|
|
NoteAttributes.number, NoteAttributes.checked, NoteAttributes.language]
|
|
private static let inlineKeys = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode,
|
|
NSAttributedString.Key.underlineStyle, .strikethroughStyle, .link]
|
|
private func paragraph(at position: Int) -> NSRange {
|
|
(string as NSString).paragraphRange(for: NSRange(location: min(position, string.utf16.count), length: 0))
|
|
}
|
|
private func blockAttributes(_ kind: String?, from original: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
|
|
var result = original
|
|
for key in Self.blockKeys { result.removeValue(forKey: key) }
|
|
if let kind { result[NoteAttributes.block] = kind }
|
|
return result
|
|
}
|
|
|
|
func format(_ tool: Int) {
|
|
guard isEditable else { return }
|
|
endInsertionSession()
|
|
let keys: [Int: NSAttributedString.Key] = [1: NoteAttributes.bold, 2: NoteAttributes.italic,
|
|
3: .strikethroughStyle, 4: NoteAttributes.inlineCode, 5: .link, 11: .underlineStyle]
|
|
if let key = keys[tool] {
|
|
let selection = selectedRange()
|
|
let existing = selection.length > 0 ? attributes(at: selection.location) : typingAttributes
|
|
let removing = (existing[key] as? Bool == true) || (existing[key] as? Int ?? 0) != 0 || (key == .link && existing[key] != nil)
|
|
let value: Any = key == .link ? "url" : (key == .underlineStyle || key == .strikethroughStyle ? 1 : true)
|
|
let booleanMark = [NoteAttributes.bold, NoteAttributes.italic, NoteAttributes.inlineCode].contains(key)
|
|
if selection.length == 0 {
|
|
if removing {
|
|
if booleanMark { typingAttributes[key] = false }
|
|
else { typingAttributes.removeValue(forKey: key) }
|
|
}
|
|
else { typingAttributes[key] = value }
|
|
return
|
|
}
|
|
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: selection))
|
|
let full = NSRange(location: 0, length: replacement.length)
|
|
if removing {
|
|
if booleanMark { replacement.addAttribute(key, value: false, range: full) }
|
|
else { replacement.removeAttribute(key, range: full) }
|
|
}
|
|
else { replacement.addAttribute(key, value: value, range: full) }
|
|
replaceDocument(replacement, range: selection, selection: selection)
|
|
} else {
|
|
let kinds = [0: "heading", 6: "code", 7: "quote", 8: "ordered", 9: "bullet", 10: "task"]
|
|
guard let kind = kinds[tool] else { return }
|
|
modifyParagraphs { attrs in
|
|
let old = attrs[NoteAttributes.block] as? String
|
|
if kind == "heading" {
|
|
let level = attrs[NoteAttributes.headingLevel] as? Int ?? 0
|
|
var changed = self.blockAttributes(level == 6 ? nil : kind, from: attrs)
|
|
if level < 6 { changed[NoteAttributes.headingLevel] = level + 1 }
|
|
return changed
|
|
}
|
|
var changed = self.blockAttributes(old == kind ? nil : kind, from: attrs)
|
|
if old != kind {
|
|
if ["bullet", "ordered", "task"].contains(kind) { changed[NoteAttributes.depth] = 0 }
|
|
if kind == "ordered" { changed[NoteAttributes.number] = 1 }
|
|
if kind == "task" { changed[NoteAttributes.checked] = false }
|
|
}
|
|
return changed
|
|
}
|
|
}
|
|
}
|
|
|
|
private func modifyParagraphs(_ transform: ([NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any]) {
|
|
let selection = selectedRange()
|
|
if string.isEmpty || selection.location == string.utf16.count && string.hasSuffix("\n") && selection.length == 0 {
|
|
setTypingAttributes(transform(currentTypingAttributes))
|
|
return
|
|
}
|
|
let length = selection.length > 0 ? selection.length - 1 : 0
|
|
let range = (string as NSString).paragraphRange(for: NSRange(location: selection.location, length: length))
|
|
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range))
|
|
let source = replacement.string as NSString
|
|
var index = 0
|
|
while index < source.length {
|
|
let row = source.paragraphRange(for: NSRange(location: index, length: 0))
|
|
let attrs = transform(replacement.attributes(at: index, effectiveRange: nil))
|
|
for key in Self.blockKeys {
|
|
replacement.removeAttribute(key, range: row)
|
|
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: row) }
|
|
}
|
|
index = NSMaxRange(row)
|
|
}
|
|
replaceDocument(replacement, range: range, selection: selection)
|
|
setTypingAttributes(attributes(at: selection.location))
|
|
}
|
|
|
|
override func insertTab(_ sender: Any?) {
|
|
if ["bullet", "ordered", "task"].contains(typingBlock) {
|
|
modifyParagraphs { attrs in
|
|
var attrs = attrs
|
|
attrs[NoteAttributes.depth] = (attrs[NoteAttributes.depth] as? Int ?? 0) + 1
|
|
if attrs[NoteAttributes.block] as? String == "ordered" { attrs[NoteAttributes.number] = 1 }
|
|
return attrs
|
|
}
|
|
} else { grouped { super.insertTab(sender) } }
|
|
}
|
|
override func insertBacktab(_ sender: Any?) {
|
|
guard ["bullet", "ordered", "task"].contains(typingBlock) else { return }
|
|
modifyParagraphs { attrs in
|
|
var attrs = attrs
|
|
attrs[NoteAttributes.depth] = max(0, (attrs[NoteAttributes.depth] as? Int ?? 0) - 1)
|
|
return attrs
|
|
}
|
|
}
|
|
private var typingBlock: String {
|
|
let attrs = selectedRange().location < string.utf16.count ? attributes(at: selectedRange().location) : currentTypingAttributes
|
|
return attrs[NoteAttributes.block] as? String ?? ""
|
|
}
|
|
|
|
override func insertNewline(_ sender: Any?) {
|
|
guard isEditable else { return }
|
|
grouped {
|
|
let selection = selectedRange()
|
|
let row = paragraph(at: selection.location)
|
|
let prefix = (string as NSString).substring(with: NSRange(location: row.location, length: selection.location - row.location))
|
|
if prefix.hasPrefix("```"), prefix.dropFirst(3).allSatisfy({ $0.isLetter || $0.isNumber || "_+.-".contains($0) }) {
|
|
var attrs = blockAttributes(typingBlock == "code" ? nil : "code", from: attributes(at: row.location))
|
|
if prefix.count > 3 { attrs[NoteAttributes.language] = String(prefix.dropFirst(3)) }
|
|
replaceDocument(NSAttributedString(string: "", attributes: attrs), range: NSRange(location: row.location, length: prefix.utf16.count))
|
|
setTypingAttributes(attrs)
|
|
return
|
|
}
|
|
let kind = typingBlock
|
|
let empty = (string as NSString).substring(with: row).trimmingCharacters(in: .newlines).isEmpty
|
|
if empty, ["bullet", "ordered", "task", "quote", "code"].contains(kind) {
|
|
let depth = currentTypingAttributes[NoteAttributes.depth] as? Int ?? 0
|
|
modifyParagraphs { attrs in
|
|
if depth > 0 {
|
|
var attrs = attrs; attrs[NoteAttributes.depth] = depth - 1; return attrs
|
|
}
|
|
return self.blockAttributes(nil, from: attrs)
|
|
}
|
|
return
|
|
}
|
|
var next = attributes(at: selection.location)
|
|
if selection.location == string.utf16.count { next = currentTypingAttributes }
|
|
if kind == "heading" { next = blockAttributes(nil, from: next) }
|
|
if kind == "task" { next[NoteAttributes.checked] = false }
|
|
if kind == "ordered" { next[NoteAttributes.number] = (next[NoteAttributes.number] as? Int ?? 1) + 1 }
|
|
for key in Self.inlineKeys { next.removeValue(forKey: key) }
|
|
if NSMaxRange(selection) <= NSMaxRange(row), !(kind == "heading" && selection.location == row.location) {
|
|
let tail = NSRange(location: NSMaxRange(selection), length: NSMaxRange(row) - NSMaxRange(selection))
|
|
let replacement = NSMutableAttributedString(string: "\n", attributes: next)
|
|
replacement.append(documentContents.attributedSubstring(from: tail))
|
|
let full = NSRange(location: 0, length: replacement.length)
|
|
for key in Self.blockKeys {
|
|
replacement.removeAttribute(key, range: full)
|
|
if let value = next[key] { replacement.addAttribute(key, value: value, range: full) }
|
|
}
|
|
replaceDocument(replacement, range: NSRange(location: selection.location, length: NSMaxRange(row) - selection.location),
|
|
selection: NSRange(location: selection.location + 1, length: 0))
|
|
} else {
|
|
replaceDocument(NSAttributedString(string: "\n", attributes: next), range: selection)
|
|
}
|
|
setTypingAttributes(next)
|
|
}
|
|
}
|
|
|
|
override func deleteBackward(_ sender: Any?) {
|
|
let selection = selectedRange()
|
|
if selection.length > 0 {
|
|
replaceDocument(NSAttributedString(string: ""), range: selection)
|
|
} else if selection.location > 0 {
|
|
let source = string as NSString
|
|
let removed = source.rangeOfComposedCharacterSequence(at: selection.location - 1)
|
|
if source.substring(with: removed).allSatisfy(\.isNewline) {
|
|
let current = paragraph(at: selection.location)
|
|
let previous = paragraph(at: removed.location)
|
|
let previousEmpty = source.substring(with: previous).trimmingCharacters(in: .newlines).isEmpty
|
|
let attrs = attributes(at: previousEmpty ? selection.location : previous.location)
|
|
let joined = NSUnionRange(previous, current)
|
|
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: joined))
|
|
replacement.deleteCharacters(in: NSRange(location: removed.location - joined.location, length: removed.length))
|
|
let all = NSRange(location: 0, length: replacement.length)
|
|
for key in Self.blockKeys {
|
|
replacement.removeAttribute(key, range: all)
|
|
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: all) }
|
|
}
|
|
replaceDocument(replacement, range: joined, selection: NSRange(location: removed.location, length: 0))
|
|
setTypingAttributes(attrs)
|
|
} else {
|
|
replaceDocument(NSAttributedString(string: ""), range: removed)
|
|
}
|
|
}
|
|
}
|
|
override func deleteForward(_ sender: Any?) {
|
|
let selection = selectedRange()
|
|
if selection.length > 0 { replaceDocument(NSAttributedString(string: ""), range: selection) }
|
|
else if selection.location < string.utf16.count {
|
|
let range = (string as NSString).rangeOfComposedCharacterSequence(at: selection.location)
|
|
replaceDocument(NSAttributedString(string: ""), range: range)
|
|
}
|
|
}
|
|
override func cut(_ sender: Any?) {
|
|
guard isEditable, selectedRange().length > 0 else { return }
|
|
copy(sender)
|
|
replaceDocument(NSAttributedString(string: ""), range: selectedRange())
|
|
}
|
|
|
|
private static let richPasteboard = NSPasteboard.PasteboardType("dev.conjure.notes.document")
|
|
override var writablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.writablePasteboardTypes }
|
|
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { [Self.richPasteboard] + super.readablePasteboardTypes }
|
|
override func writeSelection(to pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
|
guard type == Self.richPasteboard else { return super.writeSelection(to: pboard, type: type) }
|
|
do {
|
|
let contents = documentContents.attributedSubstring(from: selectedRange())
|
|
guard let data = try NoteDocument.fileWrapper(contents).serializedRepresentation else { return false }
|
|
return pboard.setData(data, forType: type)
|
|
} catch { return false }
|
|
}
|
|
override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
|
guard isEditable else { return false }
|
|
let contents: NSAttributedString
|
|
if type == Self.richPasteboard {
|
|
guard let data = pboard.data(forType: type), let wrapper = FileWrapper(serializedRepresentation: data),
|
|
let decoded = try? NoteDocument.contents(from: wrapper) else { return false }
|
|
contents = decoded
|
|
} else {
|
|
let reader = NSTextView()
|
|
reader.isRichText = true; reader.importsGraphics = true
|
|
guard reader.readSelection(from: pboard, type: type) else { return false }
|
|
let imported = NSMutableAttributedString(attributedString: reader.attributedString())
|
|
imported.enumerateAttribute(.font, in: NSRange(location: 0, length: imported.length)) { value, range, _ in
|
|
guard let font = value as? NSFont else { return }
|
|
let traits = NSFontManager.shared.traits(of: font)
|
|
if traits.contains(.boldFontMask) { imported.addAttribute(NoteAttributes.bold, value: true, range: range) }
|
|
if traits.contains(.italicFontMask) { imported.addAttribute(NoteAttributes.italic, value: true, range: range) }
|
|
}
|
|
contents = imported
|
|
}
|
|
replaceDocument(contents, range: selectedRange())
|
|
return true
|
|
}
|
|
|
|
private static let inlineShortcuts = [
|
|
"(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>$",
|
|
"(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>$",
|
|
"(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~$",
|
|
"(?<![\\\\`])(?<marker>`{1,2})(?<body>[^`\\n]+)\\k<marker>$",
|
|
"(?<!\\\\)<u>(?<body>[^\\n]+?)</u>$",
|
|
"(?<!\\\\)\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)$"
|
|
].map { try! NSRegularExpression(pattern: $0) }
|
|
private func consumeShortcut() {
|
|
guard typingBlock != "code", selectedRange().length == 0 else { return }
|
|
let caret = selectedRange().location
|
|
guard caret > 0 else { return }
|
|
switch (string as NSString).character(at: caret - 1) {
|
|
case 32, 42, 95, 126, 96, 62, 41: break
|
|
default: return
|
|
}
|
|
let row = paragraph(at: caret)
|
|
let prefixRange = NSRange(location: row.location, length: caret - row.location)
|
|
let prefix = (string as NSString).substring(with: prefixRange)
|
|
var kind: String?, level = 0, number = 1
|
|
if prefix.hasSuffix(" ") {
|
|
let command = String(prefix.dropLast())
|
|
if (1...6).contains(command.count), command.allSatisfy({ $0 == "#" }) { kind = "heading"; level = command.count }
|
|
else if command == ">" { kind = "quote" }
|
|
else if ["-", "+", "*"].contains(command) { kind = "bullet" }
|
|
else if ["- [ ]", "- []", "- [x]", "- [X]"].contains(command) { kind = "task" }
|
|
else if ["[ ]", "[]", "[x]", "[X]"].contains(command), typingBlock == "bullet" { kind = "task" }
|
|
else if command.last == "." || command.last == ")", let value = Int(command.dropLast()), value > 0 { kind = "ordered"; number = value }
|
|
}
|
|
if let kind {
|
|
var attrs = blockAttributes(kind, from: attributes(at: row.location))
|
|
if kind == "heading" { attrs[NoteAttributes.headingLevel] = level }
|
|
if ["bullet", "ordered", "task"].contains(kind) { attrs[NoteAttributes.depth] = 0 }
|
|
if kind == "ordered" { attrs[NoteAttributes.number] = number }
|
|
if kind == "task" { attrs[NoteAttributes.checked] = prefix.lowercased().contains("[x]") }
|
|
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: row))
|
|
replacement.deleteCharacters(in: NSRange(location: 0, length: prefixRange.length))
|
|
for key in Self.blockKeys {
|
|
replacement.removeAttribute(key, range: NSRange(location: 0, length: replacement.length))
|
|
if let value = attrs[key] { replacement.addAttribute(key, value: value, range: NSRange(location: 0, length: replacement.length)) }
|
|
}
|
|
replaceDocument(replacement, range: row, selection: NSRange(location: row.location, length: 0))
|
|
setTypingAttributes(attrs)
|
|
return
|
|
}
|
|
// Inline shortcuts parse only the just-completed token, never the editing buffer.
|
|
for expression in Self.inlineShortcuts {
|
|
guard let match = expression.matches(in: prefix, range: NSRange(location: 0, length: prefix.utf16.count)).last else { continue }
|
|
let range = NSRange(location: row.location + match.range.location, length: match.range.length)
|
|
let token = documentContents.attributedSubstring(from: range)
|
|
let converted = NoteDocument.importMarkdown(token)
|
|
guard converted.string != token.string else { continue }
|
|
let next = currentTypingAttributes
|
|
replaceDocument(converted, range: range)
|
|
setTypingAttributes(next)
|
|
return
|
|
}
|
|
}
|
|
|
|
override func didChangeText() {
|
|
if operationDepth > 0 { documentChanged = true; return }
|
|
applyDocumentStyle()
|
|
super.didChangeText()
|
|
}
|
|
|
|
private static let syntaxHighlighter = Highlighter()!
|
|
private static var highlightedAsLight: Bool?
|
|
private static var highlighter: Highlighter {
|
|
let isLight = ThemePalette.isLight
|
|
if highlightedAsLight != isLight {
|
|
syntaxHighlighter.setTheme(isLight ? "atom-one-light" : "atom-one-dark",
|
|
withFont: "Menlo-Regular", ofSize: 15)
|
|
highlightedAsLight = isLight
|
|
}
|
|
return syntaxHighlighter
|
|
}
|
|
// ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow.
|
|
func applyDocumentStyle() {
|
|
guard !styling, let storage = textStorage else { return }
|
|
styling = true
|
|
defer { styling = false }
|
|
let source = storage.string as NSString
|
|
let full = NSRange(location: 0, length: storage.length)
|
|
storage.beginEditing()
|
|
storage.removeAttribute(.backgroundColor, range: full)
|
|
var index = 0
|
|
listParagraphs = []
|
|
var counters: [Int: Int] = [:]
|
|
while index < storage.length {
|
|
let row = source.paragraphRange(for: NSRange(location: index, length: 0))
|
|
let attrs = storage.attributes(at: index, effectiveRange: nil)
|
|
let kind = attrs[NoteAttributes.block] as? String ?? ""
|
|
let depth = max(0, attrs[NoteAttributes.depth] as? Int ?? 0)
|
|
let level = min(6, max(1, attrs[NoteAttributes.headingLevel] as? Int ?? 1))
|
|
// A joined paragraph inherits its first block's type; inline marks remain independent.
|
|
for key in Self.blockKeys {
|
|
storage.removeAttribute(key, range: row)
|
|
if let value = attrs[key] { storage.addAttribute(key, value: value, range: row) }
|
|
}
|
|
let size: CGFloat = kind == "heading" ? [26, 23, 21, 19, 17, 15][level - 1] : 15
|
|
let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle
|
|
if ["bullet", "ordered", "task"].contains(kind) {
|
|
let previous = counters[depth]
|
|
counters = counters.filter { $0.key <= depth }
|
|
let number = kind == "ordered" ? (previous.map { $0 + 1 } ?? (attrs[NoteAttributes.number] as? Int ?? 1)) : 1
|
|
if kind == "ordered" { counters[depth] = number; storage.addAttribute(NoteAttributes.number, value: number, range: row) }
|
|
else { counters.removeValue(forKey: depth) }
|
|
listParagraphs.append((row, kind, depth, number, attrs[NoteAttributes.checked] as? Bool == true))
|
|
paragraph.firstLineHeadIndent = CGFloat(depth) * 20 + 22
|
|
paragraph.headIndent = paragraph.firstLineHeadIndent
|
|
} else { counters = [:] }
|
|
if kind == "quote" { paragraph.firstLineHeadIndent = 18; paragraph.headIndent = 18 }
|
|
storage.addAttribute(.paragraphStyle, value: paragraph, range: row)
|
|
storage.enumerateAttributes(in: row) { run, range, _ in
|
|
let nativeTraits = (run[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
|
let bold = run[NoteAttributes.bold] as? Bool ?? (kind != "heading" && kind != "code" && nativeTraits.contains(.boldFontMask))
|
|
let italic = run[NoteAttributes.italic] as? Bool ?? nativeTraits.contains(.italicFontMask)
|
|
storage.addAttribute(NoteAttributes.bold, value: bold, range: range)
|
|
storage.addAttribute(NoteAttributes.italic, value: italic, range: range)
|
|
let font = NSFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : kind == "heading" ? .semibold : .regular)
|
|
storage.addAttribute(.font, value: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, range: range)
|
|
storage.addAttribute(.foregroundColor, value: run[NoteAttributes.inlineCode] as? Bool == true || run[.link] != nil ? ThemePalette.accent : ThemePalette.text, range: range)
|
|
if run[NoteAttributes.inlineCode] as? Bool == true || kind == "code" { storage.addAttribute(.backgroundColor, value: ThemePalette.selectionFill, range: range) }
|
|
}
|
|
index = NSMaxRange(row)
|
|
}
|
|
applyCodeColors(to: storage)
|
|
storage.enumerateAttribute(.attachment, in: full) { value, _, _ in
|
|
guard let attachment = value as? NSTextAttachment,
|
|
let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return }
|
|
let scale = min(1, 460 / max(image.size.width, 1), 240 / max(image.size.height, 1))
|
|
if scale < 1 { image.size = NSSize(width: image.size.width * scale, height: image.size.height * scale) }
|
|
if !(cell is RoundedImageAttachmentCell) { attachment.attachmentCell = RoundedImageAttachmentCell(imageCell: image) }
|
|
}
|
|
markerColumn = ceil(listParagraphs.reduce(CGFloat(22)) { width, item in
|
|
guard item.kind == "ordered" else { return width }
|
|
return max(width, ("\(item.number)." as NSString).size(withAttributes: [.font: NotesAppearance.font]).width + 4)
|
|
})
|
|
for item in listParagraphs {
|
|
let paragraph = NotesAppearance.paragraph.mutableCopy() as! NSMutableParagraphStyle
|
|
paragraph.firstLineHeadIndent = CGFloat(item.depth) * 20 + markerColumn
|
|
paragraph.headIndent = paragraph.firstLineHeadIndent
|
|
storage.addAttribute(.paragraphStyle, value: paragraph, range: item.range)
|
|
}
|
|
storage.endEditing()
|
|
updateCheckboxes()
|
|
needsDisplay = true
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
guard let layoutManager else { return }
|
|
for item in listParagraphs where item.kind != "task" {
|
|
let glyph = layoutManager.glyphIndexForCharacter(at: item.range.location)
|
|
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
|
|
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
|
|
let label = item.kind == "ordered" ? "\(item.number)." : "•"
|
|
let attrs: [NSAttributedString.Key: Any] = [.font: NotesAppearance.font, .foregroundColor: ThemePalette.accent]
|
|
let width = (label as NSString).size(withAttributes: attrs).width
|
|
let right = textContainerOrigin.x + CGFloat(item.depth) * 20 + markerColumn - 4
|
|
let x = item.kind == "ordered" ? right - width : right - (markerColumn - 4 + width) / 2
|
|
if dirtyRect.intersects(line.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)) {
|
|
(label as NSString).draw(at: NSPoint(x: x, y: baseline - NotesAppearance.font.ascender), withAttributes: attrs)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateCheckboxes() {
|
|
let tasks = listParagraphs.filter { $0.kind == "task" }
|
|
while checkboxes.count > tasks.count { checkboxes.removeLast().removeFromSuperview() }
|
|
while checkboxes.count < tasks.count {
|
|
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:)))
|
|
button.controlSize = .small; button.contentTintColor = ThemePalette.accent; button.sizeToFit()
|
|
checkboxes.append(button); addSubview(button)
|
|
}
|
|
for (button, task) in zip(checkboxes, tasks) {
|
|
button.tag = task.range.location; button.state = task.checked ? .on : .off; button.isEnabled = isEditable
|
|
let title = (string as NSString).substring(with: task.range).trimmingCharacters(in: .newlines)
|
|
button.setAccessibilityLabel(title.isEmpty ? "Task" : title)
|
|
}
|
|
positionCheckboxes()
|
|
}
|
|
private func positionCheckboxes() {
|
|
guard let layoutManager, let textContainer else { return }
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
for button in checkboxes where button.tag < string.utf16.count {
|
|
let glyph = layoutManager.glyphIndexForCharacter(at: button.tag)
|
|
let line = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
|
|
let baseline = line.minY + layoutManager.location(forGlyphAt: glyph).y + textContainerOrigin.y
|
|
let depth = attributes(at: button.tag)[NoteAttributes.depth] as? Int ?? 0
|
|
button.setFrameOrigin(NSPoint(x: textContainerOrigin.x + CGFloat(depth) * 20 + (markerColumn - 4 - button.frame.width) / 2,
|
|
y: baseline - NotesAppearance.font.capHeight / 2 - button.frame.height / 2))
|
|
}
|
|
}
|
|
override func layout() { super.layout(); positionCheckboxes() }
|
|
@objc private func toggleTask(_ sender: NSButton) {
|
|
guard isEditable, sender.tag < string.utf16.count else { return }
|
|
let range = paragraph(at: sender.tag), selection = selectedRange()
|
|
let replacement = NSMutableAttributedString(attributedString: documentContents.attributedSubstring(from: range))
|
|
replacement.addAttribute(NoteAttributes.checked, value: sender.state == .on, range: NSRange(location: 0, length: replacement.length))
|
|
replaceDocument(replacement, range: range, selection: selection)
|
|
window?.makeFirstResponder(self)
|
|
}
|
|
|
|
private var normalCursorWidth: CGFloat {
|
|
let font = attributes(at: selectedRange().location)[.font] as? NSFont ?? NotesAppearance.font
|
|
return max(2, ceil(font.maximumAdvancement.width))
|
|
}
|
|
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
|
|
guard vimEnabled, vimMode == .normal else { super.drawInsertionPoint(in: rect, color: color, turnedOn: flag); return }
|
|
var block = rect; block.size.width = normalCursorWidth
|
|
super.drawInsertionPoint(in: block, color: color.withAlphaComponent(0.45), turnedOn: flag)
|
|
}
|
|
override func setNeedsDisplay(_ invalidRect: NSRect) {
|
|
var rect = invalidRect
|
|
if vimEnabled, vimMode == .normal { rect.size.width += normalCursorWidth }
|
|
super.setNeedsDisplay(rect)
|
|
}
|
|
}
|
|
|
|
private final class RoundedImageAttachmentCell: NSTextAttachmentCell {
|
|
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
|
NSGraphicsContext.saveGraphicsState()
|
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
|
NSBezierPath(roundedRect: cellFrame, xRadius: 8, yRadius: 8).addClip()
|
|
super.draw(withFrame: cellFrame, in: controlView)
|
|
}
|
|
}
|