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)] = [] 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: SunsetPalette.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 ? SunsetPalette.accent : SunsetPalette.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 = [ "(?\\*{2,3}|_{2,3})(?
\\S(?:[^\\n]*?\\S)?)\\k