import AppKit final class DocumentTreeController: NSTreeController { override init(content: Any?) { super.init(content: content) leafKeyPath = "leaf" childrenKeyPath = "children" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } static let headingAttributes: [NSAttributedString.Key: Any] = { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.headerLevel = 1 paragraphStyle.paragraphSpacing = 10 return [ .foregroundColor: NSColor.textColor, .font: NSFont.systemFont(ofSize: 15, weight: .bold), .paragraphStyle: paragraphStyle, ] }() func dividerAttributedString(blockID: UUID) -> NSAttributedString { let dividerString = NSMutableAttributedString() dividerString.append(NSAttributedString(string: "\n", attributes: [.newlineBeforeDivider: ()])) dividerString.append(NSAttributedString(attachment: DividerTextAttachment())) dividerString.append(NSAttributedString(string: "\n", attributes: [.newlineAfterDivider: ()])) var attributes = CodeTextView.typingAttributes attributes[.dividerBlockID] = blockID let dividerRange = NSRange(location: 0, length: dividerString.length) dividerString.addAttributes(attributes, range: dividerRange) return dividerString } var visibleDocumentNodes: [DocumentNode] { var accumulator = DocumentNodeAccumulator() let selectedObjects = selectedObjects for object in selectedObjects { guard let item = object as? DocumentNode else { continue } accumulator.visit(item) } return accumulator.items } var firstVisibleDocumentNode: DocumentNode? { var firstNode: DocumentNode? = nil for object in selectedObjects { if let node = object as? DocumentNode { firstNode = node break } } return firstNode } var visibleBlockRanges: [UUID: NSRange] { guard let firstBlockID = firstVisibleDocumentNode?.id else { return [:] } return attributedString.blockRanges(firstBlockID: firstBlockID) } func firstTreeNode(where predicate: (NSTreeNode) throws -> Bool) rethrows -> NSTreeNode? { var workList: [NSTreeNode] = [arrangedObjects] while let node = workList.popLast() { if try predicate(node) { return node } else if let children = node.children { workList.append(contentsOf: children) } } return nil } @objc dynamic var attributedString: NSAttributedString { get { let out = NSMutableAttributedString() let items = visibleDocumentNodes for (index, item) in items.enumerated() { if index != items.startIndex { out.append(dividerAttributedString(blockID: item.id)) } if item.displaysSectionHeading { out.append(NSAttributedString(string: item.title)) out.append(NSAttributedString(string: "\n")) } out.append(NSAttributedString(string: item.content)) } applyAttributes(out) return out } set { guard let firstItem = selectedObjects.first as? DocumentNode else { return } let blockRanges = newValue.blockRanges(firstBlockID: firstItem.id) for selectedObject in selectedObjects { guard let item = selectedObject as? DocumentNode else { continue } item.update(from: NSString(string: newValue.string), blockRanges: blockRanges) } } } func range(for targetID: UUID, in attributedString: NSAttributedString) -> NSRange? { guard let firstBlockID = firstVisibleDocumentNode?.id else { return nil } var foundRange: NSRange? = nil attributedString.enumerateBlockRanges(in: NSRange(location: 0, length: attributedString.length)) { blockIDOrNil, blockRange, shouldStop in let blockID = blockIDOrNil ?? firstBlockID if blockID == targetID { foundRange = blockRange shouldStop = false } } return foundRange } func titleRange(for targetID: UUID, in attributedString: NSAttributedString) -> (String, NSRange, NSRange)? { guard let blockRange = range(for: targetID, in: attributedString) else { return nil } let string = NSString(string: attributedString.string) var title: String? = nil var titleRange: NSRange? = nil var titleEnclosingRange: NSRange? = nil string.enumerateSubstrings(in: blockRange, options: .byParagraphs) { paragraph, paragraphRange, enclosingRange, shouldStop in title = paragraph titleRange = paragraphRange titleEnclosingRange = enclosingRange shouldStop.pointee = true } guard let title, let titleRange, let titleEnclosingRange else { return nil } return (title, titleRange, titleEnclosingRange) } func applyAttributes(_ string: NSMutableAttributedString) { let items = visibleDocumentNodes guard let firstItem = items.first else { return } string.enumerateBlockRanges(in: NSRange(location: 0, length: string.length)) { blockIDOrNil, blockRange, _ in let blockID = blockIDOrNil ?? firstItem.id guard let block = items.first(where: { $0.id == blockID }) else { return } if block.displaysSectionHeading { var currentParagraph = 0 string.mutableString.enumerateSubstrings(in: blockRange, options: .byParagraphs) { substring, substringRange, enclosingRange, shouldStop in let attributes = currentParagraph == 0 ? Self.headingAttributes : CodeTextView.typingAttributes string.setAttributes(attributes, range: enclosingRange) currentParagraph += 1 } } else { string.setAttributes(CodeTextView.typingAttributes, range: blockRange) } } } func indexPathForDocumentNodeInsertion() -> IndexPath? { func isDocumentNode(_ node: NSTreeNode) -> Bool { node.representedObject is DocumentNode } let targetTreeNode = selectedNodes.last(where: isDocumentNode) ?? arrangedObjects.first(where: isDocumentNode) guard let targetTreeNode else { return nil } // If the target tree node is a leaf node, we should insert directly after it. If it is a branch node, we should insert at the end of its children. var targetIndexPath = targetTreeNode.indexPath if targetTreeNode.isLeaf { targetIndexPath[targetIndexPath.count - 1] += 1 } else { // I believe that targetTreeNode must not be nil because targetTreeNode.isLeaf == false. targetIndexPath.append(IndexPath(index: targetTreeNode.children!.count)) } return targetIndexPath } }