Skip to content
Closed
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
36 changes: 3 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ A high-performance markdown rendering library for iOS, macOS, and visionOS.
- Inline image rendering with async loading and caching
- Comprehensive theming with fonts, colors, and spacing
- Text selection with long-press, double-tap, and triple-tap gestures
- Line selection in code and diff views with tap (single line) or long-press-drag (multi-line) and callback
- VoiceOver accessibility for text, code blocks, tables, and math content
- UIKit and AppKit support via a single API

Expand Down Expand Up @@ -214,8 +213,6 @@ theme.colors.selectionTint = .systemBlue
// Optional override if you want a custom translucent fill instead of
// selectionTint.withAlphaComponent(0.2)
theme.colors.selectionBackground = .systemBlue.withAlphaComponent(0.16)
// Optional override for line selection highlight in code/diff views
theme.colors.lineSelectionBackground = .systemBlue.withAlphaComponent(0.15)

// Diff-specific styling
theme.diff.backgroundColor = .black
Expand All @@ -240,36 +237,6 @@ markdownView.theme = theme
</details>

Selection tint is theme-driven. By default, `selectionBackground` is derived from `selectionTint` with a 20% alpha. Set `selectionBackground` explicitly when you want a different selection fill without changing the tint.
### Line Selection

Code blocks and diff views support tapping to select a line, or long-press-and-drag to select a range of lines. The selected lines are highlighted and a callback provides the 1-based line range, the text contents, and the language.

<details>
<summary>Show line selection example</summary>

```swift
markdownView.lineSelectionHandler = { info in
guard let info else {
print("Selection cleared")
return
}
print("Selected lines \(info.lineRange) in \(info.language ?? "unknown"):")
for line in info.contents {
print(" \(line)")
}
}

// Customize the selection highlight color
var theme = MarkdownTheme()
theme.colors.lineSelectionBackground = .systemBlue.withAlphaComponent(0.2)
markdownView.theme = theme
```

</details>

Selection is exclusive: selecting lines in one code or diff block automatically clears any selection in other blocks.



### Unified Diffs

Expand Down Expand Up @@ -344,3 +311,6 @@ The library is split into two modules:

MIT

## Inspiration

Inspired by [Lakr233/MarkdownView](https://github.com/Lakr233/MarkdownView)
170 changes: 0 additions & 170 deletions Sources/MarkdownView/Components/CodeView/CodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import Litext
textView.attributedText = highlightMap.apply(to: content, with: theme)
lineNumberView.updateForContent(content)
updateLineNumberView()
clearLineSelection()
}
}

Expand All @@ -56,96 +55,6 @@ import Litext
}
}

// MARK: - LINE SELECTION

var lineSelectionHandler: LineSelectionHandler?
private(set) var selectedLineRange: ClosedRange<Int>?
private lazy var selectionOverlay: LineSelectionOverlayView = .init()
private var dragAnchorLine: Int?

func clearLineSelection() {
guard selectedLineRange != nil else { return }
selectedLineRange = nil
selectionOverlay.clearSelection()
}

private func lineIndex(at point: CGPoint) -> Int? {
let localPoint = scrollView.convert(point, from: self)
let contentPoint = CGPoint(
x: localPoint.x + scrollView.contentOffset.x,
y: localPoint.y + scrollView.contentOffset.y
)
let font = theme.fonts.code
let lineHeight = font.lineHeight
let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
let adjustedY = contentPoint.y
guard adjustedY >= CodeViewConfiguration.codePadding else { return nil }
let line = Int((adjustedY - CodeViewConfiguration.codePadding) / rowAdvance) + 1
guard line >= 1, line <= cachedLineCount else { return nil }
return line
}

private func updateLineSelection(_ range: ClosedRange<Int>?) {
selectedLineRange = range
selectionOverlay.selectedRange = range
if let range = range {
let lines = content.components(separatedBy: .newlines)
let contents = (range.lowerBound...range.upperBound).compactMap { idx -> String? in
let arrayIdx = idx - 1
guard arrayIdx >= 0, arrayIdx < lines.count else { return nil }
return lines[arrayIdx]
}
let info = LineSelectionInfo(
lineRange: range,
contents: contents,
language: language.isEmpty ? nil : language
)
lineSelectionHandler?(info)
} else {
lineSelectionHandler?(nil)
}
}

@objc private func handleLineTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let line = lineIndex(at: point) else { return }
if selectedLineRange == line...line {
updateLineSelection(nil)
} else {
updateLineSelection(line...line)
}
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
}

@objc private func handleLineLongPress(_ gesture: UILongPressGestureRecognizer) {
let point = gesture.location(in: self)
switch gesture.state {
case .began:
guard let line = lineIndex(at: point) else { return }
dragAnchorLine = line
updateLineSelection(line...line)
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
case .changed:
guard let anchor = dragAnchorLine,
let line = lineIndex(at: point) else { return }
let newRange = min(anchor, line)...max(anchor, line)
if newRange != selectedLineRange {
updateLineSelection(newRange)
}
case .ended, .cancelled, .failed:
dragAnchorLine = nil
default:
break
}
}

// MARK: LINE SELECTION -

private let callerIdentifier = UUID()
private var currentTaskIdentifier: UUID?

Expand Down Expand Up @@ -304,7 +213,6 @@ import Litext
textView.attributedText = highlightMap.apply(to: content, with: theme)
lineNumberView.updateForContent(content)
updateLineNumberView()
clearLineSelection()
}
}

Expand All @@ -316,84 +224,6 @@ import Litext
}
}

// MARK: - LINE SELECTION

var lineSelectionHandler: LineSelectionHandler?
private(set) var selectedLineRange: ClosedRange<Int>?
private lazy var selectionOverlay: LineSelectionOverlayView = .init()
private var dragAnchorLine: Int?

func clearLineSelection() {
guard selectedLineRange != nil else { return }
selectedLineRange = nil
selectionOverlay.clearSelection()
}

private func lineIndex(at point: CGPoint) -> Int? {
let localPoint = convert(point, from: nil)
let font = theme.fonts.code
let lineHeight = font.ascender + abs(font.descender) + font.leading
let rowAdvance = lineHeight + CodeViewConfiguration.codeLineSpacing
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
let adjustedY = localPoint.y - barHeight
guard adjustedY >= CodeViewConfiguration.codePadding else { return nil }
let line = Int((adjustedY - CodeViewConfiguration.codePadding) / rowAdvance) + 1
guard line >= 1, line <= cachedLineCount else { return nil }
return line
}

private func updateLineSelection(_ range: ClosedRange<Int>?) {
selectedLineRange = range
selectionOverlay.selectedRange = range
if let range = range {
let lines = content.components(separatedBy: .newlines)
let contents = (range.lowerBound...range.upperBound).compactMap { idx -> String? in
let arrayIdx = idx - 1
guard arrayIdx >= 0, arrayIdx < lines.count else { return nil }
return lines[arrayIdx]
}
let info = LineSelectionInfo(
lineRange: range,
contents: contents,
language: language.isEmpty ? nil : language
)
lineSelectionHandler?(info)
} else {
lineSelectionHandler?(nil)
}
}

override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let line = lineIndex(at: point) else {
super.mouseDown(with: event)
return
}
dragAnchorLine = line
if selectedLineRange == line...line {
updateLineSelection(nil)
} else {
updateLineSelection(line...line)
}
}

override func mouseDragged(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let anchor = dragAnchorLine,
let line = lineIndex(at: point) else { return }
let newRange = min(anchor, line)...max(anchor, line)
if newRange != selectedLineRange {
updateLineSelection(newRange)
}
}

override func mouseUp(with event: NSEvent) {
dragAnchorLine = nil
super.mouseUp(with: event)
}

// MARK: LINE SELECTION -

private let callerIdentifier = UUID()
private var currentTaskIdentifier: UUID?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ enum CodeViewConfiguration {
setupScrollView()
setupTextView()
setupLineNumberView()
setupLineSelectionGestures()
updateHeaderVisibility()
}

Expand Down Expand Up @@ -143,21 +142,6 @@ enum CodeViewConfiguration {
updateLineNumberView()
}

private func setupLineSelectionGestures() {
selectionOverlay.isUserInteractionEnabled = false
let selectionColor = theme.colors.lineSelectionBackground
?? theme.colors.selectionTint.withAlphaComponent(0.15)
selectionOverlay.selectionColor = selectionColor
scrollView.addSubview(selectionOverlay)

let tap = UITapGestureRecognizer(target: self, action: #selector(handleLineTap(_:)))
addGestureRecognizer(tap)

let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLineLongPress(_:)))
longPress.minimumPressDuration = 0.15
addGestureRecognizer(longPress)
}

func performLayout() {
let labelSize = languageLabel.intrinsicContentSize
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
Expand Down Expand Up @@ -252,24 +236,12 @@ enum CodeViewConfiguration {

textView.setNeedsLayout()
textView.layoutIfNeeded()
let resolvedLineRects = offsetCodeLineRects(
textView.lineRects(),
by: textView.frame.origin
)
lineNumberView.updateLineRects(resolvedLineRects)

selectionOverlay.frame = CGRect(
origin: .zero,
size: CGSize(
width: scrollView.contentSize.width,
height: max(textView.frame.maxY + CodeViewConfiguration.codePadding, scrollView.bounds.height)
lineNumberView.updateLineRects(
offsetCodeLineRects(
textView.lineRects(),
by: textView.frame.origin
)
)
selectionOverlay.updateLineRects(resolvedLineRects)

let selectionColor = theme.colors.lineSelectionBackground
?? theme.colors.selectionTint.withAlphaComponent(0.15)
selectionOverlay.selectionColor = selectionColor
}
}

Expand All @@ -282,7 +254,6 @@ enum CodeViewConfiguration {
setupScrollView()
setupTextView()
setupLineNumberView()
setupLineSelectionOverlay()
updateHeaderVisibility()
}

Expand Down Expand Up @@ -359,13 +330,6 @@ enum CodeViewConfiguration {
updateLineNumberView()
}

private func setupLineSelectionOverlay() {
let selectionColor = theme.colors.lineSelectionBackground
?? theme.colors.selectionTint.withAlphaComponent(0.15)
selectionOverlay.selectionColor = selectionColor
scrollView.documentView?.addSubview(selectionOverlay, positioned: .below, relativeTo: textView)
}

func performLayout() {
let labelSize = languageLabel.intrinsicContentSize
let barHeight = CodeViewConfiguration.barHeight(theme: theme)
Expand Down Expand Up @@ -455,24 +419,12 @@ enum CodeViewConfiguration {

textView.needsLayout = true
textView.layoutSubtreeIfNeeded()
let resolvedLineRects = offsetCodeLineRects(
textView.lineRects(),
by: textView.frame.origin
)
lineNumberView.updateLineRects(resolvedLineRects)

selectionOverlay.frame = CGRect(
origin: .zero,
size: CGSize(
width: max(scrollView.bounds.width - CodeViewConfiguration.codePadding * 2, textView.frame.width),
height: max(textView.frame.maxY + CodeViewConfiguration.codePadding, scrollView.bounds.height)
lineNumberView.updateLineRects(
offsetCodeLineRects(
textView.lineRects(),
by: textView.frame.origin
)
)
selectionOverlay.updateLineRects(resolvedLineRects)

let selectionColor = theme.colors.lineSelectionBackground
?? theme.colors.selectionTint.withAlphaComponent(0.15)
selectionOverlay.selectionColor = selectionColor
}
}
#endif
Loading
Loading