diff --git a/Package.swift b/Package.swift index abee4b6..d025228 100644 --- a/Package.swift +++ b/Package.swift @@ -147,6 +147,7 @@ let package = Package( .testTarget( name: "MarkdownViewTests", dependencies: [ + "Litext", "MarkdownView", "MarkdownParser", ] diff --git a/README.md b/README.md index 882c86f..2be71cc 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,11 @@ Text selection is enabled by default. Long-press to select text, then use the st ```swift markdownView.textView.customMenuItems = [ - LTXCustomMenuItem(title: "Explain", image: UIImage(systemName: "lightbulb")) { context in + LTXCustomMenuItem( + title: "Explain", + image: UIImage(systemName: "lightbulb"), + isAvailable: { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + ) { context in print("Explain: \(context.text) (lines \(context.startLine)-\(context.endLine))") }, LTXCustomMenuItem(title: "Apply", image: UIImage(systemName: "checkmark.circle")) { context in @@ -326,7 +330,7 @@ markdownView.textView.customMenuItems = [ -Custom items appear after the built-in Copy, Select All, and Share actions. On iOS they integrate with `UIMenuController`, on Mac Catalyst with `UIContextMenuInteraction`, and on macOS with `NSMenu`. +Custom items appear after the built-in Copy, Select All, and Share actions. Use `isAvailable` to show mutually exclusive commands for different selected ranges; it is reevaluated while the user drags the selection handles. Set `selectionChangeHandler` when the surrounding UI also needs the live selected range. On iOS custom actions integrate with `UIMenuController`, on Mac Catalyst with `UIContextMenuInteraction`, and on macOS with `NSMenu`. #### Line Selection (Opt-in) diff --git a/Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@AppKit.swift b/Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@AppKit.swift index c040ca7..a435294 100644 --- a/Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@AppKit.swift +++ b/Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@AppKit.swift @@ -182,6 +182,9 @@ import Foundation private func showContextMenu() { let menu = NSMenu() + let availableCustomItems = selectionContext().map { context in + customMenuItems.filter { $0.isAvailable(context) } + } ?? [] let addBuiltIn = { menu.addItem( @@ -192,9 +195,12 @@ import Foundation } let addCustom = { [self] in - for customItem in self.customMenuItems { + for customItem in availableCustomItems { menu.addItem(withTitle: customItem.title, image: customItem.image) { [weak self] in - guard let self, let context = self.selectionContext() else { return } + guard let self, + let context = self.selectionContext(), + customItem.isAvailable(context) + else { return } customItem.handler(context) } } @@ -203,11 +209,11 @@ import Foundation switch customMenuItemPosition { case .beforeBuiltIn: addCustom() - if !customMenuItems.isEmpty { menu.addItem(.separator()) } - addBuiltIn() + if !availableCustomItems.isEmpty { menu.addItem(.separator()) } + _ = addBuiltIn() case .afterBuiltIn: - addBuiltIn() - if !customMenuItems.isEmpty { menu.addItem(.separator()) } + _ = addBuiltIn() + if !availableCustomItems.isEmpty { menu.addItem(.separator()) } addCustom() } diff --git a/Sources/Litext/LTXLabel/Interaction/LTXLabel+Touches.swift b/Sources/Litext/LTXLabel/Interaction/LTXLabel+Touches.swift index e724b6e..a46e369 100644 --- a/Sources/Litext/LTXLabel/Interaction/LTXLabel+Touches.swift +++ b/Sources/Litext/LTXLabel/Interaction/LTXLabel+Touches.swift @@ -251,8 +251,10 @@ guard canPerformAction(selector, withSender: nil) else { return nil } return UIMenuItem(title: item.title, action: selector) } + let context = selectionContext() for (index, customItem) in customMenuItems.enumerated() { guard index < Self.customMenuSelectors.count else { break } + guard let context, customItem.isAvailable(context) else { continue } let selector = Self.customMenuSelectors[index] items.append(UIMenuItem(title: customItem.title, action: selector)) } @@ -295,7 +297,9 @@ guard index < customMenuItems.count, let context = selectionContext() else { return } - customMenuItems[index].handler(context) + let item = customMenuItems[index] + guard item.isAvailable(context) else { return } + item.handler(context) } @objc func copyMenuItemTapped() { @@ -353,6 +357,7 @@ return index < customMenuItems.count && selectionRange != nil && selectionRange!.length > 0 + && selectionContext().map(customMenuItems[index].isAvailable) == true } return false } diff --git a/Sources/Litext/LTXLabel/Interaction/LTXLabel+UIContextMenuInteractionDelegate.swift b/Sources/Litext/LTXLabel/Interaction/LTXLabel+UIContextMenuInteractionDelegate.swift index 6aebe09..a94a019 100644 --- a/Sources/Litext/LTXLabel/Interaction/LTXLabel+UIContextMenuInteractionDelegate.swift +++ b/Sources/Litext/LTXLabel/Interaction/LTXLabel+UIContextMenuInteractionDelegate.swift @@ -25,9 +25,13 @@ self.perform(selector) } } - let customActions: [UIMenuElement] = self.customMenuItems.map { customItem in - UIAction(title: customItem.title, image: customItem.image) { [weak self] _ in - guard let self, let context = self.selectionContext() else { return } + let context = selectionContext() + let customActions: [UIMenuElement] = self.customMenuItems.compactMap { customItem in + guard let context, customItem.isAvailable(context) else { return nil } + return UIAction(title: customItem.title, image: customItem.image) { [weak self] _ in + guard let self, let context = self.selectionContext(), customItem.isAvailable(context) else { + return + } customItem.handler(context) } } diff --git a/Sources/Litext/LTXLabel/LTXLabel.swift b/Sources/Litext/LTXLabel/LTXLabel.swift index d53c50d..8dbc232 100644 --- a/Sources/Litext/LTXLabel/LTXLabel.swift +++ b/Sources/Litext/LTXLabel/LTXLabel.swift @@ -160,10 +160,10 @@ public class LTXLabel: LTXPlatformView, Identifiable { public internal(set) var selectionRange: NSRange? { didSet { - updateSelectionLayer() if selectionRange != oldValue { delegate?.ltxLabelSelectionDidChange(self, selection: selectionRange) } + updateSelectionLayer() } } diff --git a/Sources/Litext/LTXLabel/Menu/LTXCustomMenuItem.swift b/Sources/Litext/LTXLabel/Menu/LTXCustomMenuItem.swift index 0e0d932..b6dacb6 100644 --- a/Sources/Litext/LTXLabel/Menu/LTXCustomMenuItem.swift +++ b/Sources/Litext/LTXLabel/Menu/LTXCustomMenuItem.swift @@ -36,11 +36,21 @@ public struct LTXSelectionContext { public struct LTXCustomMenuItem { public let title: String public let image: PlatformImage? + /// Determines whether the item is included for the current selection. + /// Evaluated every time the selection range changes, including while the + /// user drags either selection handle. + public let isAvailable: (LTXSelectionContext) -> Bool public let handler: (LTXSelectionContext) -> Void - public init(title: String, image: PlatformImage? = nil, handler: @escaping (LTXSelectionContext) -> Void) { + public init( + title: String, + image: PlatformImage? = nil, + isAvailable: @escaping (LTXSelectionContext) -> Bool = { _ in true }, + handler: @escaping (LTXSelectionContext) -> Void + ) { self.title = title self.image = image + self.isAvailable = isAvailable self.handler = handler } } diff --git a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+LTXDelegate.swift b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+LTXDelegate.swift index 1a30d93..3b168f6 100644 --- a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+LTXDelegate.swift +++ b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+LTXDelegate.swift @@ -11,8 +11,8 @@ import Litext import UIKit extension MarkdownTextView: LTXLabelDelegate { - public func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection _: NSRange?) { - // reserved for future use + public func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection: NSRange?) { + selectionChangeHandler?(selection) } public func ltxLabelDetectedUserEventMovingAtLocation(_ label: Litext.LTXLabel, location: CGPoint) { @@ -71,8 +71,8 @@ import Litext import AppKit extension MarkdownTextView: LTXLabelDelegate { - public func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection _: NSRange?) { - // reserved for future use + public func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection: NSRange?) { + selectionChangeHandler?(selection) } public func ltxLabelDetectedUserEventMovingAtLocation(_ label: Litext.LTXLabel, location: CGPoint) { diff --git a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift index 577a380..ee83f9e 100644 --- a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift +++ b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift @@ -23,6 +23,9 @@ enum ContentPipelineMode { public var codePreviewHandler: ((String?, NSAttributedString) -> Void)? public var lineSelectionHandler: LineSelectionHandler? public var lineSelectionEndedHandler: LineSelectionHandler? + /// Called whenever the selected character range changes, including + /// while either selection handle is being dragged. + public var selectionChangeHandler: ((NSRange?) -> Void)? public internal(set) var document: PreprocessedContent = .init() public let textView: LTXLabel = .init() @@ -229,6 +232,8 @@ enum ContentPipelineMode { public var codePreviewHandler: ((String?, NSAttributedString) -> Void)? public var lineSelectionHandler: LineSelectionHandler? public var lineSelectionEndedHandler: LineSelectionHandler? + /// Called whenever the selected character range changes. + public var selectionChangeHandler: ((NSRange?) -> Void)? public internal(set) var document: PreprocessedContent = .init() public let textView: LTXLabel = .init() diff --git a/Tests/MarkdownViewTests/SelectionMenuTests.swift b/Tests/MarkdownViewTests/SelectionMenuTests.swift new file mode 100644 index 0000000..84daa01 --- /dev/null +++ b/Tests/MarkdownViewTests/SelectionMenuTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import Litext + +@MainActor +final class SelectionMenuTests: XCTestCase { + func testCustomMenuItemAvailabilityUsesCurrentSelection() { + let label = LTXLabel() + label.isSelectable = true + label.attributedText = NSAttributedString(string: "highlighted plain") + + let item = LTXCustomMenuItem( + title: "Unhighlight", + isAvailable: { $0.range.location < 11 }, + handler: { _ in } + ) + + label.selectionRange = NSRange(location: 0, length: 11) + XCTAssertTrue(item.isAvailable(try XCTUnwrap(label.selectionContext()))) + + label.selectionRange = NSRange(location: 12, length: 5) + XCTAssertFalse(item.isAvailable(try XCTUnwrap(label.selectionContext()))) + } + + func testSelectionChangeDelegateRunsBeforeSelectionLayerUpdate() { + let label = LTXLabel() + label.isSelectable = true + label.attributedText = NSAttributedString(string: "text") + let delegate = SelectionDelegateSpy() + label.delegate = delegate + + label.selectionRange = NSRange(location: 0, length: 4) + + XCTAssertEqual(delegate.selections, [NSRange(location: 0, length: 4)]) + } +} + +@MainActor +private final class SelectionDelegateSpy: LTXLabelDelegate { + var selections: [NSRange?] = [] + + func ltxLabelSelectionDidChange(_ ltxLabel: LTXLabel, selection: NSRange?) { + selections.append(selection) + } + + func ltxLabelDidTapOnHighlightContent( + _ ltxLabel: LTXLabel, + region: LTXHighlightRegion?, + location: CGPoint + ) {} + + func ltxLabelDetectedUserEventMovingAtLocation(_ ltxLabel: LTXLabel, location: CGPoint) {} +}