Skip to content

Commit 0a721ca

Browse files
committed
feat: add better support for selection
1 parent e78b9af commit 0a721ca

8 files changed

Lines changed: 428 additions & 58 deletions

File tree

Example/Example/DetailViewController.swift

Lines changed: 241 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Gary Tokman on 3/26/26.
66
//
77

8+
import Litext
89
import MarkdownParser
910
import MarkdownView
1011
import UIKit
@@ -13,6 +14,8 @@ class DetailViewController: UIViewController {
1314

1415
private let example: DiffExample
1516
private let markdownView = MarkdownTextView()
17+
private let commentButton = UIButton(type: .system)
18+
private var currentSelectionInfo: LineSelectionInfo?
1619

1720
init(example: DiffExample) {
1821
self.example = example
@@ -29,7 +32,33 @@ class DetailViewController: UIViewController {
2932
title = example.title
3033
view.backgroundColor = .systemBackground
3134

32-
// Custom menu items
35+
switch example.selectionMode {
36+
case .textSelection:
37+
setupTextSelection()
38+
case .lineSelection:
39+
setupLineSelection()
40+
}
41+
42+
view.addSubview(markdownView)
43+
markdownView.translatesAutoresizingMaskIntoConstraints = false
44+
NSLayoutConstraint.activate([
45+
markdownView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
46+
markdownView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
47+
markdownView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
48+
])
49+
50+
let parser = MarkdownParser()
51+
let result = parser.parse(example.markdown)
52+
let content = MarkdownTextView.PreprocessedContent(
53+
parserResult: result,
54+
theme: .default
55+
)
56+
markdownView.setMarkdown(content)
57+
}
58+
59+
// MARK: - Text Selection
60+
61+
private func setupTextSelection() {
3362
markdownView.textView.customMenuItems = [
3463
LTXCustomMenuItem(
3564
title: "Explain",
@@ -50,21 +79,225 @@ class DetailViewController: UIViewController {
5079
print("Reject: \"\(context.text)\" (lines \(context.startLine)-\(context.endLine))")
5180
},
5281
]
82+
}
5383

54-
view.addSubview(markdownView)
55-
markdownView.translatesAutoresizingMaskIntoConstraints = false
84+
// MARK: - Line Selection
85+
86+
private func setupLineSelection() {
87+
markdownView.lineSelectionHandler = { [weak self] info in
88+
self?.updateCommentButton(info: info)
89+
}
90+
91+
setupCommentButton()
92+
}
93+
94+
private func setupCommentButton() {
95+
commentButton.isHidden = true
96+
97+
var config = UIButton.Configuration.filled()
98+
config.baseBackgroundColor = .systemGray5
99+
config.baseForegroundColor = .label
100+
config.cornerStyle = .capsule
101+
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)
102+
config.image = UIImage(systemName: "text.bubble")
103+
config.imagePadding = 8
104+
config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 14)
105+
commentButton.configuration = config
106+
commentButton.addTarget(self, action: #selector(commentTapped), for: .touchUpInside)
107+
108+
view.addSubview(commentButton)
109+
commentButton.translatesAutoresizingMaskIntoConstraints = false
56110
NSLayoutConstraint.activate([
57-
markdownView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
58-
markdownView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
59-
markdownView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
111+
commentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
112+
commentButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
60113
])
114+
}
115+
116+
private func updateCommentButton(info: LineSelectionInfo?) {
117+
currentSelectionInfo = info
118+
guard let info else {
119+
commentButton.isHidden = true
120+
return
121+
}
122+
123+
let range = info.lineRange
124+
let title: String
125+
if range.lowerBound == range.upperBound {
126+
title = "Comment on line \(range.lowerBound)"
127+
} else {
128+
title = "Comment on lines \(range.lowerBound)-\(range.upperBound)"
129+
}
130+
commentButton.configuration?.title = title
131+
commentButton.isHidden = false
132+
133+
UIView.animate(withDuration: 0.2) {
134+
self.commentButton.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
135+
} completion: { _ in
136+
UIView.animate(withDuration: 0.15) {
137+
self.commentButton.transform = .identity
138+
}
139+
}
140+
}
141+
142+
@objc private func commentTapped() {
143+
guard let info = currentSelectionInfo else { return }
144+
let sheet = CommentSheetViewController(selectionInfo: info, language: example.language)
145+
if let pc = sheet.presentationController as? UISheetPresentationController {
146+
pc.detents = [.large()]
147+
}
148+
present(sheet, animated: true)
149+
}
150+
}
151+
152+
// MARK: - Comment Sheet
153+
154+
class CommentSheetViewController: UIViewController {
155+
156+
private let selectionInfo: LineSelectionInfo
157+
private let language: String?
158+
private let codeMarkdownView = MarkdownTextView()
159+
private let commentTextView = UITextView()
160+
161+
init(selectionInfo: LineSelectionInfo, language: String?) {
162+
self.selectionInfo = selectionInfo
163+
self.language = language
164+
super.init(nibName: nil, bundle: nil)
165+
}
166+
167+
@available(*, unavailable)
168+
required init?(coder: NSCoder) {
169+
fatalError()
170+
}
171+
172+
override func viewDidLoad() {
173+
super.viewDidLoad()
174+
view.backgroundColor = .systemBackground
175+
setupNavBar()
176+
setupCodePreview()
177+
setupCommentTextView()
178+
}
179+
180+
override func viewDidAppear(_ animated: Bool) {
181+
super.viewDidAppear(animated)
182+
commentTextView.becomeFirstResponder()
183+
}
184+
185+
private func setupNavBar() {
186+
let navBar = UINavigationBar()
187+
navBar.isTranslucent = false
188+
navBar.barTintColor = .systemBackground
189+
navBar.shadowImage = UIImage()
190+
navBar.translatesAutoresizingMaskIntoConstraints = false
191+
view.addSubview(navBar)
192+
NSLayoutConstraint.activate([
193+
navBar.topAnchor.constraint(equalTo: view.topAnchor),
194+
navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
195+
navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
196+
])
197+
198+
let navItem = UINavigationItem(title: "Add Comment")
199+
200+
let closeButton = UIBarButtonItem(
201+
image: UIImage(systemName: "xmark"),
202+
style: .plain,
203+
target: self,
204+
action: #selector(closeTapped)
205+
)
206+
closeButton.tintColor = .secondaryLabel
207+
navItem.leftBarButtonItem = closeButton
208+
209+
let commentAction = UIAction(title: "Comment") { [weak self] _ in
210+
self?.submitComment()
211+
}
212+
let commentButton = UIButton(type: .system, primaryAction: commentAction)
213+
commentButton.configuration = {
214+
var config = UIButton.Configuration.plain()
215+
config.title = "Comment"
216+
config.baseForegroundColor = .label
217+
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 14, bottom: 6, trailing: 14)
218+
return config
219+
}()
220+
navItem.rightBarButtonItem = UIBarButtonItem(customView: commentButton)
221+
222+
navBar.setItems([navItem], animated: false)
223+
}
224+
225+
private func setupCodePreview() {
226+
codeMarkdownView.translatesAutoresizingMaskIntoConstraints = false
227+
view.addSubview(codeMarkdownView)
228+
NSLayoutConstraint.activate([
229+
codeMarkdownView.topAnchor.constraint(equalTo: view.topAnchor, constant: 56),
230+
codeMarkdownView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
231+
codeMarkdownView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
232+
])
233+
234+
let lines = selectionInfo.contents
235+
let startLine = selectionInfo.lineRange.lowerBound
236+
let lang = language ?? ""
237+
let diffBlock = lines.enumerated().map { idx, line in
238+
let lineNum = startLine + idx
239+
return "\(lineNum) \(line)"
240+
}.joined(separator: "\n")
241+
242+
let markdown = """
243+
```diff \(lang)
244+
\(diffBlock)
245+
```
246+
"""
61247

62248
let parser = MarkdownParser()
63-
let result = parser.parse(example.markdown)
249+
let result = parser.parse(markdown)
64250
let content = MarkdownTextView.PreprocessedContent(
65251
parserResult: result,
66252
theme: .default
67253
)
68-
markdownView.setMarkdown(content)
254+
codeMarkdownView.setMarkdown(content)
255+
}
256+
257+
private func setupCommentTextView() {
258+
commentTextView.font = .systemFont(ofSize: 16)
259+
commentTextView.textColor = .label
260+
commentTextView.backgroundColor = .clear
261+
commentTextView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
262+
commentTextView.translatesAutoresizingMaskIntoConstraints = false
263+
264+
let placeholder = UILabel()
265+
placeholder.text = "Leave a comment..."
266+
placeholder.font = .systemFont(ofSize: 16)
267+
placeholder.textColor = .placeholderText
268+
placeholder.translatesAutoresizingMaskIntoConstraints = false
269+
commentTextView.addSubview(placeholder)
270+
NSLayoutConstraint.activate([
271+
placeholder.topAnchor.constraint(equalTo: commentTextView.topAnchor, constant: 12),
272+
placeholder.leadingAnchor.constraint(equalTo: commentTextView.leadingAnchor, constant: 17),
273+
])
274+
self.placeholderLabel = placeholder
275+
commentTextView.delegate = self
276+
277+
view.addSubview(commentTextView)
278+
NSLayoutConstraint.activate([
279+
commentTextView.topAnchor.constraint(equalTo: codeMarkdownView.bottomAnchor, constant: 8),
280+
commentTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
281+
commentTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
282+
commentTextView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
283+
])
284+
}
285+
286+
private weak var placeholderLabel: UILabel?
287+
288+
@objc private func closeTapped() {
289+
dismiss(animated: true)
290+
}
291+
292+
private func submitComment() {
293+
let text = commentTextView.text ?? ""
294+
print("Comment submitted: \"\(text)\" on lines \(selectionInfo.lineRange)")
295+
dismiss(animated: true)
296+
}
297+
}
298+
299+
extension CommentSheetViewController: UITextViewDelegate {
300+
func textViewDidChange(_ textView: UITextView) {
301+
placeholderLabel?.isHidden = !textView.text.isEmpty
69302
}
70303
}

Example/Example/DiffExample.swift

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,24 @@
77

88
import Foundation
99

10+
enum SelectionMode {
11+
case textSelection
12+
case lineSelection
13+
}
14+
1015
struct DiffExample {
1116
let title: String
1217
let subtitle: String
1318
let markdown: String
19+
let selectionMode: SelectionMode
20+
var language: String? = nil
1421
}
1522

1623
let examples: [DiffExample] = [
24+
// Line selection examples — tap a line or long-press-drag to select a range
1725
DiffExample(
1826
title: "Rename Refactor",
19-
subtitle: "Swift class rename",
27+
subtitle: "Line selection · Tap or drag lines",
2028
markdown: """
2129
```diff swift
2230
@@ -1,6 +1,6 @@
@@ -29,11 +37,13 @@ let examples: [DiffExample] = [
2937
}
3038
}
3139
```
32-
"""
40+
""",
41+
selectionMode: .lineSelection,
42+
language: "swift"
3343
),
3444
DiffExample(
3545
title: "Bug Fix",
36-
subtitle: "Off-by-one error in Python",
46+
subtitle: "Line selection · Tap or drag lines",
3747
markdown: """
3848
```diff python
3949
@@ -3,7 +3,7 @@
@@ -46,11 +56,15 @@ let examples: [DiffExample] = [
4656
return mid
4757
elif arr[mid] < target:
4858
```
49-
"""
59+
""",
60+
selectionMode: .lineSelection,
61+
language: "python"
5062
),
63+
64+
// Text selection examples — long-press to select text, custom menu actions
5165
DiffExample(
5266
title: "Add Logging",
53-
subtitle: "TypeScript API handler",
67+
subtitle: "Text selection · Long-press to select",
5468
markdown: """
5569
```diff typescript
5670
@@ -8,6 +8,8 @@
@@ -63,11 +77,13 @@ let examples: [DiffExample] = [
6377
return Response.notFound();
6478
}
6579
```
66-
"""
80+
""",
81+
selectionMode: .textSelection,
82+
language: "typescript"
6783
),
6884
DiffExample(
6985
title: "Config Change",
70-
subtitle: "JSON configuration update",
86+
subtitle: "Text selection · Long-press to select",
7187
markdown: """
7288
```diff json
7389
@@ -2,5 +2,6 @@
@@ -79,11 +95,13 @@ let examples: [DiffExample] = [
7995
+ "license": "MIT"
8096
}
8197
```
82-
"""
98+
""",
99+
selectionMode: .textSelection,
100+
language: "json"
83101
),
84102
DiffExample(
85103
title: "SQL Migration",
86-
subtitle: "Add index and column",
104+
subtitle: "Text selection · Long-press to select",
87105
markdown: """
88106
```diff sql
89107
@@ -1,4 +1,6 @@
@@ -95,6 +113,8 @@ let examples: [DiffExample] = [
95113
+ created_at TIMESTAMPTZ DEFAULT NOW()
96114
);
97115
```
98-
"""
116+
""",
117+
selectionMode: .textSelection,
118+
language: "sql"
99119
),
100120
]

0 commit comments

Comments
 (0)