MarkdownView provides rich text selection capabilities powered by the underlying LTXLabel component. Users can select text with precision using various gestures, including long-press, double-tap, and triple-tap.
Overview
Text selection in MarkdownView supports:
- Long-press: Select word at cursor position
- Double-tap: Select word
- Triple-tap: Select entire line
- Drag: Extend selection
- Context menu: Copy, Select All, Share (iOS/visionOS)
- Keyboard shortcuts: Cmd+C to copy, Cmd+A to select all
Selection is enabled by default and can be controlled via the isSelectable property on the underlying textView (which is an LTXLabel).
Enabling Selection
Selection is enabled by default:
let markdownView = MarkdownTextView()
markdownView.textView.isSelectable = true // Already true by default
To disable selection:
markdownView.textView.isSelectable = false
Gesture Recognition
Long-Press Selection
Long-press selection is the primary touch-based selection method on iOS/visionOS. When the user long-presses on text, the word at that position is selected.
From LTXLabel+Interaction.swift:68-98:
func scheduleLongPressTimer(at point: CGPoint) {
guard isSelectable, longPressSelectsWord else { return }
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.prepare()
let workItem = DispatchWorkItem { [weak self] in
self?.handleLongPress(at: point)
}
DispatchQueue.main.asyncAfter(
deadline: .now() + kLongPressDelay, // 0.35 seconds
execute: workItem
)
}
Long-press has a 350ms delay (kLongPressDelay = 0.35) before triggering, with haptic feedback on selection.
Multi-Click Detection
The library tracks click count to distinguish between single, double, and triple taps:
- Click 1: Position cursor (pointer devices) or schedule long-press (touch)
- Click 2: Select word at tap location
- Click 3: Select entire line at tap location
Clicks must occur within 250ms of each other (kMultiClickTimeThreshold = 0.25) to be counted as multi-clicks:
func bumpClickCountIfWithinTimeGap() {
let currentTime = Date().timeIntervalSince1970
let isContinuousClick = currentTime - interactionState.lastClickTime <= 0.25
if isContinuousClick {
interactionState.clickCount += 1
}
}
Touch vs Pointer Devices
The library distinguishes between touch and pointer input:
func isPointerDevice(touch: UITouch) -> Bool {
#if targetEnvironment(macCatalyst)
return true // Mac Catalyst is always a pointer device
#else
switch touch.type {
case .indirectPointer, .pencil:
return true
default:
return false
}
#endif
}
Pointer devices (mouse, trackpad, Apple Pencil) support click-and-drag selection, while touch devices require long-press followed by drag.
Selection Granularity
Word Selection
Double-tap or long-press selects the word at the tap location using NSString.rangeOfWord(at:) for linguistic boundaries:
func selectWordAtIndex(_ index: Int) {
guard isSelectable else { return }
let nsString = attributedString.string as NSString
let range = nsString.rangeOfWord(at: index)
guard range.location != NSNotFound, range.length > 0 else { return }
selectionRange = range
}
Line Selection
Triple-tap selects the entire line containing the tap location:
func selectLineAtIndex(_ index: Int) {
guard isSelectable else { return }
let nsString = attributedString.string as NSString
let lineRange = nsString.rangeOfLine(at: index)
selectionRange = lineRange
}
Select All
The “Select All” action selects the entire text content:
@objc func selectAllText() {
guard let range = selectAllRange() else { return }
selectionRange = range
}
func selectAllRange() -> NSRange? {
guard isSelectable else { return nil }
let attributedString = textLayout.attributedString
guard attributedString.length > 0 else { return nil }
return NSRange(location: 0, length: attributedString.length)
}
Users can invoke Select All via the context menu or by pressing Cmd+A on iPad with keyboard.
Selection Handles
On iOS/visionOS, selection handles (lollipops) appear at the start and end of the selection, allowing users to adjust the selection by dragging.
The handles have expanded hit areas for easier interaction:
let rect = handler.frame
.insetBy(
dx: -LTXSelectionHandle.knobExtraResponsiveArea,
dy: -LTXSelectionHandle.knobExtraResponsiveArea
)
On iOS/visionOS (excluding tvOS and watchOS), a context menu appears when:
- Text is selected and tapped
- Text is long-pressed
- Text is right-clicked (Mac Catalyst)
The menu provides actions:
- Copy: Copy selected text to pasteboard
- Select All: Select all text
- Share: Share selected text via activity controller
From LTXLabel+Touches.swift:224-252:
func showSelectionMenuController() {
guard let range = selectionRange, range.length > 0 else { return }
let menuController = UIMenuController.shared
let items = LTXLabelMenuItem
.textSelectionMenu()
.compactMap { item -> UIMenuItem? in
guard let selector = item.action else { return nil }
guard canPerformAction(selector, withSender: nil) else { return nil }
return UIMenuItem(title: item.title, action: selector)
}
menuController.menuItems = items
menuController.showMenu(from: self, rect: unionRect)
}
Copy Behavior
The copy action copies the selected text to the system pasteboard:
@discardableResult
@objc func copySelectedText() -> NSAttributedString {
guard let selectedText = selectedAttributedText() else {
return .init()
}
#if canImport(UIKit)
UIPasteboard.general.string = selectedText.string
#elseif canImport(AppKit)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(selectedText.string, forType: .string)
#endif
return selectedText.copy() as! NSAttributedString
}
Copy extracts plain text from the selection, even for styled content like bold, links, or code blocks.
Selection with Attachments
When selecting text that contains attachments (images, math expressions), the attachment’s represented text is substituted:
func selectedAttributedText() -> NSAttributedString? {
let mutableResult = NSMutableAttributedString(attributedString: selectedText)
mutableResult.enumerateAttribute(
.ltxAttachment,
in: NSRange(location: 0, length: mutableResult.length),
options: []
) { value, range, _ in
if let attachment = value as? LTXAttachment {
mutableResult.replaceCharacters(
in: range,
with: attachment.attributedStringRepresentation()
)
}
}
return mutableResult
}
For example, selecting text containing an image includes the image’s alt text in the copied string.
Keyboard Support
MarkdownView supports keyboard shortcuts when the text view has focus:
- Cmd+C: Copy selected text
- Cmd+A: Select all text
From LTXLabel+Touches.swift:16-35:
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard isSelectable else {
super.pressesBegan(presses, with: event)
return
}
var didHandleEvent = false
for press in presses {
guard let key = press.key else { continue }
if key.keyCode == .keyboardC, key.modifierFlags.contains(.command) {
let copiedText = copySelectedText()
didHandleEvent = copiedText.length > 0
}
if key.keyCode == .keyboardA, key.modifierFlags.contains(.command) {
selectAllText()
didHandleEvent = true
}
}
if !didHandleEvent { super.pressesBegan(presses, with: event) }
}
The view must become first responder to receive keyboard events. This happens automatically when the user taps to select text.
Selection Clearing
Selection is cleared when:
- User taps outside the selection
- User taps on a link or other interactive element
- Copy action completes (optional, app-specific)
- New content is loaded
@objc func clearSelection() {
selectionRange = nil
}
Interaction with Links
When text contains both selection and tappable links, tapping the selected region shows the context menu instead of following the link. This prevents accidental navigation when trying to copy.
if !isTouchReallyMoved(location),
interactionState.clickCount <= 1
{
if isLocationInSelection(location: location) {
showSelectionMenuController()
} else {
clearSelection()
}
}
Movement Threshold
Touches must move more than 3pt to be considered “moved” and trigger drag selection:
func isTouchReallyMoved(_ point: CGPoint) -> Bool {
let distance = hypot(
point.x - interactionState.initialTouchLocation.x,
point.y - interactionState.initialTouchLocation.y
)
return distance > 3
}
This prevents accidental selection changes from tiny finger movements.
Selection Background Color
The selection background color is controlled by the theme:
textView.selectionBackgroundColor = theme.colors.selectionBackground
This ensures selection appearance matches your app’s visual design.