// // KMBOTAOutlineView.swift // PDF Reader Pro // // Created by lizhe on 2023/4/2. // import Cocoa protocol KMBOTAOutlineViewDelegate: NSObjectProtocol { func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, didReloadData: KMBOTAOutlineItem) func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, didSelectItem: [KMBOTAOutlineItem]) func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, rightDidMoseDown: KMBOTAOutlineItem, event: NSEvent) func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, writeItems items: [Any], to pasteboard: NSPasteboard) -> Bool func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation func BOTAOutlineView(_ outlineView: KMBOTAOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool } class KMBOTAOutlineView: BaseXibView { @IBOutlet weak var outlineView: KMOutlineView! @IBOutlet weak var scrollView: NSScrollView! weak var delegate: KMBOTAOutlineViewDelegate? var inputData: CPDFOutline? { didSet { self.reloadData(expandItemType: .none) } } var data: KMBOTAOutlineItem? var selectItems: [KMBOTAOutlineItem]? var dragPDFOutline: KMBOTAOutlineItem! var isSearchMode = false var searchKey = "" var wholeWords = false { didSet { if isValidSearchMode() == false { return } reloadData() outlineView.expandItem(nil, expandChildren: true) } } var caseSensitive = false { didSet { if isValidSearchMode() == false { return } reloadData() outlineView.expandItem(nil, expandChildren: true) } } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) // Drawing code here. } override func awakeFromNib() { super.awakeFromNib() self.setup() } func setup() { self.scrollView.backgroundColor(NSColor.km_init(hex: "#F2F9FF")) self.outlineView.registerForDraggedTypes([NSPasteboard.PasteboardType(rawValue: "kKMPDFViewOutlineDragDataType")]) self.outlineView.delegate = self self.outlineView.dataSource = self self.outlineView.selectionHighlightStyle = NSTableView.SelectionHighlightStyle.none; self.outlineView.allowsMultipleSelection = true // self.outlineView.indentationPerLevel = 0 outlineView.tocDelegate = self outlineView.hasImageToolTips = true } func reloadData(expandItemType: KMOutlineViewExpandItemType = .none) { if self.inputData != nil { //获取数据 var tempData: KMBOTAOutlineItem = KMBOTAOutlineItem() if self.inputData!.numberOfChildren > 0 { let outline: CPDFOutline = self.inputData! tempData = self.addOutlineItem(outlineItem:tempData, outline: outline, expandItemType: expandItemType) } else { tempData.outline = CPDFOutline() if expandItemType == .collapse { tempData.isItemExpanded = false } else if (expandItemType == .expand) { tempData.isItemExpanded = true } } tempData.parent = nil self.data = tempData if isValidSearchMode() { self.reloadSearchChildren(item: self.data) } self.outlineView.reloadData() } self.delegate?.BOTAOutlineView(self, didReloadData: self.data ?? KMBOTAOutlineItem()) } // 递归处理 func reloadSearchChildren(item: KMBOTAOutlineItem?) { guard let theItem = item else { return } // 处理当前 item let models = self.fetchOutlines(for: theItem, searchString: searchKey) // 搜索数据 theItem.searchChildren = models theItem.isItemExpanded = true if theItem.children.isEmpty { // 递归退出条件 return } // 处理 childItem for childM in theItem.children { self.reloadSearchChildren(item: childM) } } func addOutlineItem(outlineItem: KMBOTAOutlineItem?, outline: CPDFOutline, expandItemType: KMOutlineViewExpandItemType = .none) -> KMBOTAOutlineItem { //添加根节点 let item: KMBOTAOutlineItem = KMBOTAOutlineItem() item.outline = outline item.parent = outlineItem var items: [KMBOTAOutlineItem] = [] if outline.numberOfChildren > 0 { for index in 0...outline.numberOfChildren - 1 { let children: CPDFOutline = outline.child(at: index) let childrenItem = self.addOutlineItem(outlineItem: item, outline: children, expandItemType: expandItemType) if expandItemType == .collapse { childrenItem.isItemExpanded = false } else if (expandItemType == .expand) { childrenItem.isItemExpanded = true } items.append(childrenItem) } } item.children = items return item } func updateUI() { } func updateLanguage() { } func hasContainString(_ searchString: String, rootOutline outline: CPDFOutline) -> Bool { var label = outline.label ?? "" var searchLabel = searchString if caseSensitive == false { label = label.lowercased() searchLabel = searchLabel.lowercased() } if label.contains(searchLabel) { if wholeWords { let words = label.words() return words.contains(searchLabel) } return true } else { var subHas = false for i in 0 ..< outline.numberOfChildren { if let subOutline = outline.child(at: i) { subHas = self.hasContainString(searchString, rootOutline: subOutline) } else { continue } if (subHas) { break } } return subHas } } func fetchOutlines(for item: KMBOTAOutlineItem, searchString: String) -> [KMBOTAOutlineItem] { var items: [KMBOTAOutlineItem] = [] for childI in item.children { if self.hasContainString(searchString, rootOutline: childI.outline) { items.append(childI) } } return items } func isValidSearchMode() -> Bool { if isSearchMode == false { return false } if searchKey.isEmpty { return false } return true } } //MARK: NSOutlineViewDataSource,NSOutlineViewDelegate extension KMBOTAOutlineView : NSOutlineViewDataSource,NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { guard let rootModel = self.data else { return 0 } guard let model = item as? KMBOTAOutlineItem else { if isValidSearchMode() { // 是否为搜索模块 if self.hasContainString(searchKey, rootOutline: rootModel.outline) == false { return 0 } return rootModel.searchChildren.count } return Int(rootModel.outline.numberOfChildren) } if isValidSearchMode() { return model.searchChildren.count } return model.children.count } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { guard let rootModel = self.data else { return "" } guard let model = item as? KMBOTAOutlineItem else { if isValidSearchMode() { // 是否为搜索模块 if self.hasContainString(searchKey, rootOutline: rootModel.outline) == false { return "" } return rootModel.searchChildren.safe_element(for: index) as Any } return rootModel.children[index] as Any } if isValidSearchMode() { return model.searchChildren.safe_element(for: index) as Any } return model.children[index] as Any } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { guard let model = item as? KMBOTAOutlineItem else { return false } if isValidSearchMode() { let datas = self.fetchOutlines(for: model, searchString: searchKey) return !datas.isEmpty } return !model.children.isEmpty } func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool { if let item = item as? KMBOTAOutlineItem { if !item.isItemExpanded { item.isItemExpanded = true outlineView.animator().expandItem(item, expandChildren: true) return false } } return true } func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool { if let item = item as? KMBOTAOutlineItem { if item.isItemExpanded { item.isItemExpanded = false outlineView.animator().collapseItem(item, collapseChildren: true) return false } } return true } func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let model = item as? KMBOTAOutlineItem else { return nil } let cell : KMBOTAOutlineCellView = KMBOTAOutlineCellView.init() if isValidSearchMode() { var label = model.outline.label ?? "" let attri = NSMutableAttributedString(string: label, attributes: [ .font : NSFont.SFProTextRegularFont(13), .foregroundColor : KMNColorTools.colorText_1()]) var _searchKey = searchKey if caseSensitive == false { label = label.lowercased() _searchKey = _searchKey.lowercased() } let ranges = label.ranges(of: _searchKey) for range in ranges.nsRnage { attri.addAttributes([.font : NSFont.SFProTextBoldFont(13), .foregroundColor: KMNColorTools.colorPrimary_textLight()], range: range) } cell.titleLabel.attributedStringValue = attri cell.iconButton.isHidden = model.searchChildren.isEmpty } else { cell.titleLabel.stringValue = model.outline.label ?? "" if model.outline.numberOfChildren > 0 { cell.iconButton.isHidden = false } else { cell.iconButton.isHidden = true } } let isItemExpanded = model.isItemExpanded if isItemExpanded { cell.iconButton.image = NSImage(named: "KMImageNameArrowDown") } else { cell.iconButton.image = NSImage(named: "KMImageNameArrowRight") } cell.iconAction = { [unowned self] view in let rowIndex = outlineView.row(forItem: item) let rowView = outlineView.rowView(atRow: rowIndex, makeIfNecessary: false) self.didSelectItem(view: (rowView as? KMBOTAOutlineRowView), event: NSEvent()) if self.selectItems?.count == 1 { self.needOpenOrCloseItem(oulineItem: (self.selectItems?.first)!) } } return cell } func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { let rowView = KMBOTAOutlineRowView() rowView.model = item as? KMBOTAOutlineItem rowView.mouseDownCallback = { [unowned self] (view, event) in self.didSelectItem(view: view, event: event) } rowView.rightMouseCallback = { [unowned self] (view, event) in if !KMOCToolClass.arrayContains(array: self.selectItems, annotation: item) || self.selectItems!.count == 1 { self.selectItem(outlineItem: item as! KMBOTAOutlineItem) } self.delegate?.BOTAOutlineView(self, rightDidMoseDown: item as! KMBOTAOutlineItem, event: event) } rowView.hoverCallback = { [unowned self] (mouseEntered, mouseBox) in self.outlineView.enumerateAvailableRowViews { view, row in if view is KMBOTAOutlineRowView { (view as? KMBOTAOutlineRowView)?.model?.hover = false (view as? KMBOTAOutlineRowView)?.reloadData() } } if mouseEntered { rowView.model?.hover = true } else { rowView.model?.hover = false } } return rowView } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { if item is KMBOTAOutlineItem { let tempItem: KMBOTAOutlineItem = item as! KMBOTAOutlineItem let string: NSString = tempItem.outline.label as NSString let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = 1.32 paragraphStyle.alignment = .left let attributes = [NSAttributedString.Key.paragraphStyle: paragraphStyle, NSAttributedString.Key.font : NSFont.SFProTextRegularFont(14.0)] let size = string.boundingRect(with: NSMakeSize(outlineView.frame.size.width - 30, 200), options: NSString.DrawingOptions(rawValue: 3), attributes: attributes) return max(40, size.height + 16) } return 40 } func outlineView(_ outlineView: NSOutlineView, writeItems items: [Any], to pasteboard: NSPasteboard) -> Bool { guard let callBack = self.delegate else { return false} return callBack.BOTAOutlineView(self, writeItems: items, to: pasteboard) } func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { guard let callBack = self.delegate else { return NSDragOperation.init(rawValue: 0)} return callBack.BOTAOutlineView(self, validateDrop: info, proposedItem: item, proposedChildIndex: index) } func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { guard let callBack = self.delegate else { return false} return callBack.BOTAOutlineView(self, acceptDrop: info, item: item, childIndex: index) } func outlineViewSelectionDidChange(_ notification: Notification) { // if self.outlineView.selectedRow == -1 { // self.cancelSelect() // } } } extension KMBOTAOutlineView: KMTocOutlineViewDelegate { func outlineView(_ anOutlineView: NSOutlineView, imageContextForItem item: Any?) -> AnyObject? { if item == nil { return true as AnyObject } if let data = item as? KMBOTAOutlineItem { return data.outline.destination } return nil } } //MARK: Action extension KMBOTAOutlineView { @objc func expandAllComments(item: NSMenuItem) { self.reloadData(expandItemType: .expand) self.outlineView.reloadData() self.outlineView.expandItem(nil, expandChildren: true) } @objc func collapseAllComments(item: NSMenuItem) { self.reloadData(expandItemType: .collapse) self.outlineView.reloadData() self.outlineView.collapseItem(nil, collapseChildren: true) } func selectItem(outlineItem: KMBOTAOutlineItem) { let index = self.outlineView.row(forItem: outlineItem) self.outlineView.selectRowIndexes(IndexSet(integer: IndexSet.Element(index)), byExtendingSelection: false) self.didSelectItem(view: nil, event: NSEvent(), isNeedDelegate: false) } func selectIndex(index: Int) { self.outlineView.selectRowIndexes(IndexSet(integer: IndexSet.Element(index)), byExtendingSelection: false) self.didSelectItem(view: nil, event: NSEvent(), isNeedDelegate: false) } func cancelSelect() { guard let items = self.selectItems else { return } self.outlineView.deselectAll(nil) for model in items { model.select = false self.outlineView.reloadItem(model) } } func didSelectItem(view: KMBOTAOutlineRowView?, event: NSEvent, isNeedDelegate: Bool = true) { //当选中一个时 if view != nil && (self.outlineView.selectedRowIndexes.count == 1 || (!event.modifierFlags.contains(NSEvent.ModifierFlags.command) && !event.modifierFlags.contains(NSEvent.ModifierFlags.shift))) { let rowView: KMBOTAOutlineRowView = view! let index = self.outlineView.row(for: rowView) self.outlineView.selectRowIndexes(IndexSet(integer: IndexSet.Element(index)), byExtendingSelection: false) } //原始数据置空 if self.selectItems != nil { for item in self.selectItems! { item.select = false let index = self.outlineView.row(forItem: item) if index != -1 { if self.outlineView.rowView(atRow: index, makeIfNecessary: false) != nil { let rowView: KMBOTAOutlineRowView = self.outlineView.rowView(atRow: index, makeIfNecessary: false) as! KMBOTAOutlineRowView rowView.reloadData() } } } } //获取最新数据 var items: [KMBOTAOutlineItem] = [] for index in self.outlineView.selectedRowIndexes { if index != -1 { let item: KMBOTAOutlineItem = self.outlineView.item(atRow: index) as! KMBOTAOutlineItem item.select = true items.append(item) //刷新数据 if self.outlineView.rowView(atRow: index, makeIfNecessary: false) != nil { let rowView: KMBOTAOutlineRowView = self.outlineView.rowView(atRow: index, makeIfNecessary: false) as! KMBOTAOutlineRowView rowView.reloadData() self.outlineView.reloadItem(item, reloadChildren: true) } } } self.selectItems = items // if self.selectItems?.count == 1 { // self.needOpenOrCloseItem(oulineItem: (self.selectItems?.first)!) // } if self.selectItems != nil && isNeedDelegate { self.delegate?.BOTAOutlineView(self, didSelectItem: self.selectItems!) } } func needOpenOrCloseItem(oulineItem: KMBOTAOutlineItem) { //只有一个选中项时开启关闭item if self.selectItems?.count == 1 { if self.outlineView.isItemExpanded(oulineItem) { self.outlineView.collapseItem(oulineItem) oulineItem.isItemExpanded = false } else { self.outlineView.expandItem(oulineItem) oulineItem.isItemExpanded = true } let row = self.outlineView.row(forItem: oulineItem) self.outlineView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: 0)) } } }