From ec12815a51e96f481e671515fb52bc446a15cb0a Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:30:34 +0200 Subject: [PATCH] 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. --- .../AnnotationColorPicker.swift | 84 +++++++++++++++++++ .../BookmarkDetailLegacyView.swift | 19 ++++- .../BookmarkDetail/BookmarkDetailView2.swift | 11 +++ readeck/UI/Components/WebView.swift | 35 ++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 readeck/UI/BookmarkDetail/AnnotationColorPicker.swift diff --git a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift new file mode 100644 index 0000000..ae410da --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift @@ -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) + } + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index 90e76d8..60e051c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -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 diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index 852bd1e..043ba27 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -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 { diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 4043ee0..40822f0 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -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()) @@ -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) {