Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ let package = Package(
.testTarget(
name: "MarkdownViewTests",
dependencies: [
"Litext",
"MarkdownView",
"MarkdownParser",
]
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -326,7 +330,7 @@ markdownView.textView.customMenuItems = [

</details>

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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
}
Expand All @@ -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()
}

Expand Down
7 changes: 6 additions & 1 deletion Sources/Litext/LTXLabel/Interaction/LTXLabel+Touches.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -353,6 +357,7 @@
return index < customMenuItems.count
&& selectionRange != nil
&& selectionRange!.length > 0
&& selectionContext().map(customMenuItems[index].isAvailable) == true
}
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Litext/LTXLabel/LTXLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
12 changes: 11 additions & 1 deletion Sources/Litext/LTXLabel/Menu/LTXCustomMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions Tests/MarkdownViewTests/SelectionMenuTests.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
Loading