feat: Add text selection and annotation creation UI

Implement interactive text annotation feature:
- Add text selection detection via JavaScript in WebView
- Create AnnotationColorPicker with 4 color options (yellow, green, blue, red)
- Integrate color picker sheet in bookmark detail views
- Calculate text offsets for precise annotation positioning
- Add onTextSelected callback for WebView component
- Prepare UI for future API integration

Users can now select text in articles and choose a highlight color.
API integration for persisting annotations will follow.
This commit is contained in:
Ilyas Hallak 2025-10-22 15:30:34 +02:00
parent cf06a3147d
commit ec12815a51
4 changed files with 148 additions and 1 deletions

View File

@ -0,0 +1,84 @@
import SwiftUI
struct AnnotationColorPicker: View {
let selectedText: String
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Highlight Text")
.font(.headline)
Text(selectedText)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("Select Color")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ColorButton(color: .yellow, onTap: handleColorSelection)
ColorButton(color: .green, onTap: handleColorSelection)
ColorButton(color: .blue, onTap: handleColorSelection)
ColorButton(color: .red, onTap: handleColorSelection)
}
Button("Cancel") {
dismiss()
}
.foregroundColor(.secondary)
}
.padding(24)
.frame(maxWidth: 400)
}
private func handleColorSelection(_ color: AnnotationColor) {
onColorSelected(color)
dismiss()
}
}
struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
)
}
}
}
enum AnnotationColor: String, CaseIterable {
case yellow = "yellow"
case green = "green"
case blue = "blue"
case red = "red"
func swiftUIColor(isDark: Bool) -> Color {
switch self {
case .yellow:
return isDark ? Color(red: 158/255, green: 117/255, blue: 4/255) : Color(red: 107/255, green: 79/255, blue: 3/255)
case .green:
return isDark ? Color(red: 132/255, green: 204/255, blue: 22/255) : Color(red: 57/255, green: 88/255, blue: 9/255)
case .blue:
return isDark ? Color(red: 9/255, green: 132/255, blue: 159/255) : Color(red: 7/255, green: 95/255, blue: 116/255)
case .red:
return isDark ? Color(red: 152/255, green: 43/255, blue: 43/255) : Color(red: 103/255, green: 29/255, blue: 29/255)
}
}
}

View File

@ -35,6 +35,10 @@ struct BookmarkDetailLegacyView: View {
@State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top)
@State private var showingImageViewer = false
@State private var showingColorPicker = false
@State private var selectedText: String = ""
@State private var selectedStartOffset: Int = 0
@State private var selectedEndOffset: Int = 0
// MARK: - Envs
@ -88,7 +92,13 @@ struct BookmarkDetailLegacyView: View {
webViewHeight = height
}
},
selectedAnnotationId: viewModel.selectedAnnotationId
selectedAnnotationId: viewModel.selectedAnnotationId,
onTextSelected: { text, startOffset, endOffset in
selectedText = text
selectedStartOffset = startOffset
selectedEndOffset = endOffset
showingColorPicker = true
}
)
.frame(height: webViewHeight)
.cornerRadius(14)
@ -268,6 +278,13 @@ struct BookmarkDetailLegacyView: View {
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.sheet(isPresented: $showingColorPicker) {
AnnotationColorPicker(selectedText: selectedText) { color in
// TODO: API call to create annotation will go here
print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)")
}
.presentationDetents([.height(300)])
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
// Reload settings when sheet is dismissed

View File

@ -20,6 +20,10 @@ struct BookmarkDetailView2: View {
@State private var showJumpToProgressButton: Bool = false
@State private var scrollPosition = ScrollPosition(edge: .top)
@State private var showingImageViewer = false
@State private var showingColorPicker = false
@State private var selectedText: String = ""
@State private var selectedStartOffset: Int = 0
@State private var selectedEndOffset: Int = 0
// MARK: - Envs
@ -59,6 +63,13 @@ struct BookmarkDetailView2: View {
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.sheet(isPresented: $showingColorPicker) {
AnnotationColorPicker(selectedText: selectedText) { color in
// TODO: API call to create annotation will go here
print("Creating annotation with color: \(color.rawValue), offsets: \(selectedStartOffset)-\(selectedEndOffset)")
}
.presentationDetents([.height(300)])
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
Task {

View File

@ -7,6 +7,7 @@ struct WebView: UIViewRepresentable {
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onTextSelected: ((String, Int, Int) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -29,8 +30,10 @@ struct WebView: UIViewRepresentable {
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "textSelected")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onTextSelected = onTextSelected
return webView
}
@ -38,6 +41,7 @@ struct WebView: UIViewRepresentable {
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onTextSelected = onTextSelected
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
@ -309,6 +313,28 @@ struct WebView: UIViewRepresentable {
img.addEventListener('load', debouncedHeightUpdate);
});
// Text selection detection
document.addEventListener('selectionchange', function() {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const selectedText = selection.toString();
// Calculate character offset from start of body
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(range.startContainer, range.startOffset);
const startOffset = preRange.toString().length;
const endOffset = startOffset + selectedText.length;
window.webkit.messageHandlers.textSelected.postMessage({
text: selectedText,
startOffset: startOffset,
endOffset: endOffset
});
}
});
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
</script>
@ -389,6 +415,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var onTextSelected: ((String, Int, Int) -> Void)?
// Height management
var lastHeight: CGFloat = 0
@ -430,6 +457,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.handleScrollProgress(progress: progress)
}
}
if message.name == "textSelected", let body = message.body as? [String: Any],
let text = body["text"] as? String,
let startOffset = body["startOffset"] as? Int,
let endOffset = body["endOffset"] as? Int {
DispatchQueue.main.async {
self.onTextSelected?(text, startOffset, endOffset)
}
}
}
private func handleHeightUpdate(height: CGFloat) {