diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 901d247..97cabd9 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -19,6 +19,7 @@ protocol PAPI { func searchBookmarks(search: String) async throws -> BookmarksPageDto func getBookmarkLabels() async throws -> [BookmarkLabelDto] func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] + func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto } class API: PAPI { @@ -459,6 +460,32 @@ class API: PAPI { logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)") return result } + + func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto { + logger.debug("Creating annotation for bookmark: \(bookmarkId)") + let endpoint = "/api/bookmarks/\(bookmarkId)/annotations" + logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint) + + let bodyDict: [String: Any] = [ + "color": color, + "start_offset": startOffset, + "end_offset": endOffset, + "start_selector": startSelector, + "end_selector": endSelector + ] + + let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: []) + + let result = try await makeJSONRequest( + endpoint: endpoint, + method: .POST, + body: bodyData, + responseType: AnnotationDto.self + ) + + logger.info("Successfully created annotation for bookmark: \(bookmarkId)") + return result + } } enum HTTPMethod: String { diff --git a/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift b/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift new file mode 100644 index 0000000..079dcbd --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct AnnotationColorOverlay: View { + let onColorSelected: (AnnotationColor) -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 8) { + ForEach(Constants.annotationColors, id: \.self) { color in + ColorButton(color: color, onTap: onColorSelected) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + ) + } + + private 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: 36, height: 36) + .overlay( + Circle() + .stroke(Color.primary.opacity(0.15), lineWidth: 1) + ) + } + } + } +} + +#Preview { + AnnotationColorOverlay { color in + print("Selected: \(color)") + } + .padding() +} diff --git a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift index ae410da..802b8d0 100644 --- a/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift +++ b/readeck/UI/BookmarkDetail/AnnotationColorPicker.swift @@ -24,10 +24,9 @@ struct AnnotationColorPicker: View { .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) + ForEach(Constants.annotationColors, id: \.self) { color in + ColorButton(color: color, onTap: handleColorSelection) + } } Button("Cancel") { @@ -62,23 +61,3 @@ struct ColorButton: View { } } } - -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 60e051c..b44becd 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -35,10 +35,6 @@ 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 @@ -93,11 +89,18 @@ struct BookmarkDetailLegacyView: View { } }, selectedAnnotationId: viewModel.selectedAnnotationId, - onTextSelected: { text, startOffset, endOffset in - selectedText = text - selectedStartOffset = startOffset - selectedEndOffset = endOffset - showingColorPicker = true + onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in + Task { + await viewModel.createAnnotation( + bookmarkId: bookmarkId, + color: color, + text: text, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + } } ) .frame(height: webViewHeight) @@ -278,13 +281,6 @@ 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 a5b355c..558ffc5 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -20,10 +20,6 @@ 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 @@ -63,13 +59,6 @@ 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 { @@ -472,11 +461,18 @@ struct BookmarkDetailView2: View { } }, selectedAnnotationId: viewModel.selectedAnnotationId, - onTextSelected: { text, startOffset, endOffset in - selectedText = text - selectedStartOffset = startOffset - selectedEndOffset = endOffset - showingColorPicker = true + onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in + Task { + await viewModel.createAnnotation( + bookmarkId: bookmarkId, + color: color, + text: text, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + } } ) .frame(height: webViewHeight) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index 2ba209f..689df7a 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -8,6 +8,7 @@ class BookmarkDetailViewModel { private let loadSettingsUseCase: PLoadSettingsUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? + private let api: PAPI var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" @@ -29,6 +30,7 @@ class BookmarkDetailViewModel { self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() + self.api = API() self.factory = factory readProgressSubject @@ -138,4 +140,22 @@ class BookmarkDetailViewModel { func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) { readProgressSubject.send((id, progress, anchor)) } + + @MainActor + func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async { + do { + let annotation = try await api.createAnnotation( + bookmarkId: bookmarkId, + color: color, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + print("✅ Annotation created: \(annotation.id)") + } catch { + print("❌ Failed to create annotation: \(error)") + errorMessage = "Error creating annotation" + } + } } diff --git a/readeck/UI/Components/Constants.swift b/readeck/UI/Components/Constants.swift index 09b31f5..d820412 100644 --- a/readeck/UI/Components/Constants.swift +++ b/readeck/UI/Components/Constants.swift @@ -10,7 +10,53 @@ // import Foundation +import SwiftUI struct Constants { - // Empty for now - can be used for other constants in the future + // Annotation colors + static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red] +} + +enum AnnotationColor: String, CaseIterable, Codable { + case yellow = "yellow" + case green = "green" + case blue = "blue" + case red = "red" + + // Base hex color for buttons and overlays + var hexColor: String { + switch self { + case .yellow: return "#D4A843" + case .green: return "#6FB546" + case .blue: return "#4A9BB8" + case .red: return "#C84848" + } + } + + // RGB values for SwiftUI Color + private var rgb: (red: Double, green: Double, blue: Double) { + switch self { + case .yellow: return (212, 168, 67) + case .green: return (111, 181, 70) + case .blue: return (74, 155, 184) + case .red: return (200, 72, 72) + } + } + + func swiftUIColor(isDark: Bool) -> Color { + let (r, g, b) = rgb + return Color(red: r/255, green: g/255, blue: b/255) + } + + // CSS rgba string for JavaScript (for highlighting) + func cssColor(isDark: Bool) -> String { + let (r, g, b) = rgb + return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)" + } + + // CSS rgba string with custom opacity + func cssColorWithOpacity(_ opacity: Double) -> String { + let (r, g, b) = rgb + return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))" + } } diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index a8270b4..352c110 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -12,7 +12,7 @@ struct NativeWebView: View { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? - var onTextSelected: ((String, Int, Int) -> Void)? = nil + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -22,7 +22,7 @@ struct NativeWebView: View { .scrollDisabled(true) // Disable internal scrolling .onAppear { loadStyledContent() - setupTextSelectionCallback() + setupAnnotationMessageHandler() } .onChange(of: htmlContent) { _, _ in loadStyledContent() @@ -45,34 +45,22 @@ struct NativeWebView: View { } } - private func setupTextSelectionCallback() { - guard let onTextSelected = onTextSelected else { return } + private func setupAnnotationMessageHandler() { + guard let onAnnotationCreated = onAnnotationCreated else { return } - // Poll for text selection using JavaScript + // Poll for annotation messages from JavaScript Task { @MainActor in - let page = webPage // Capture the webPage + let page = webPage while true { - try? await Task.sleep(nanoseconds: 300_000_000) // Check every 0.3s + try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s let script = """ return (function() { - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - const range = selection.getRangeAt(0); - const selectedText = selection.toString(); - - const preRange = document.createRange(); - preRange.selectNodeContents(document.body); - preRange.setEnd(range.startContainer, range.startOffset); - const startOffset = preRange.toString().length; - const endOffset = startOffset + selectedText.length; - - return { - text: selectedText, - startOffset: startOffset, - endOffset: endOffset - }; + if (window.__pendingAnnotation) { + const data = window.__pendingAnnotation; + window.__pendingAnnotation = null; + return data; } return null; })(); @@ -80,10 +68,13 @@ struct NativeWebView: View { do { if let result = try await page.callJavaScript(script) as? [String: Any], + let color = result["color"] as? String, let text = result["text"] as? String, let startOffset = result["startOffset"] as? Int, - let endOffset = result["endOffset"] as? Int { - onTextSelected(text, startOffset, endOffset) + let endOffset = result["endOffset"] as? Int, + let startSelector = result["startSelector"] as? String, + let endSelector = result["endSelector"] as? String { + onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector) } } catch { // Silently continue polling @@ -260,38 +251,38 @@ struct NativeWebView: View { /* Yellow annotations */ rd-annotation[data-annotation-color="yellow"] { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="yellow"].selected { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6)); } /* Green annotations */ rd-annotation[data-annotation-color="green"] { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="green"].selected { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6)); } /* Blue annotations */ rd-annotation[data-annotation-color="blue"] { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="blue"].selected { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6)); } /* Red annotations */ rd-annotation[data-annotation-color="red"] { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="red"].selected { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6)); } @@ -339,11 +330,11 @@ struct NativeWebView: View { scheduleHeightCheck(); - // Text selection detection - \(generateTextSelectionJS()) - // Scroll to selected annotation \(generateScrollToAnnotationJS()) + + // Text Selection and Annotation Overlay + \(generateAnnotationOverlayJS(isDarkMode: isDarkMode)) @@ -376,9 +367,247 @@ struct NativeWebView: View { } } - private func generateTextSelectionJS() -> String { - // Not needed for iOS 26 - we use polling instead - return "" + private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String { + return """ + // Create annotation color overlay + (function() { + let currentSelection = null; + let currentRange = null; + let selectionTimeout = null; + + // Create overlay container with arrow + const overlay = document.createElement('div'); + overlay.id = 'annotation-overlay'; + overlay.style.cssText = ` + display: none; + position: absolute; + z-index: 10000; + `; + + // Create arrow/triangle pointing up with glass effect + const arrow = document.createElement('div'); + arrow.style.cssText = ` + position: absolute; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-right: none; + border-bottom: none; + top: -11px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + overlay.appendChild(arrow); + + // Create the actual content container with glass morphism effect + const content = document.createElement('div'); + content.style.cssText = ` + display: flex; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 24px; + padding: 12px 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + gap: 12px; + flex-direction: row; + align-items: center; + `; + overlay.appendChild(content); + + // Add "Markierung" label + const label = document.createElement('span'); + label.textContent = 'Markierung'; + label.style.cssText = ` + color: black; + font-size: 16px; + font-weight: 500; + margin-right: 4px; + `; + content.appendChild(label); + + // Create color buttons with solid colors + const colors = [ + { name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' }, + { name: 'red', color: '\(AnnotationColor.red.hexColor)' }, + { name: 'blue', color: '\(AnnotationColor.blue.hexColor)' }, + { name: 'green', color: '\(AnnotationColor.green.hexColor)' } + ]; + + colors.forEach(({ name, color }) => { + const btn = document.createElement('button'); + btn.dataset.color = name; + btn.style.cssText = ` + width: 40px; + height: 40px; + border-radius: 50%; + background: ${color}; + border: 3px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 0; + margin: 0; + transition: transform 0.2s, border-color 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.transform = 'scale(1.1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.6)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.transform = 'scale(1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; + }); + btn.addEventListener('click', () => handleColorSelection(name)); + content.appendChild(btn); + }); + + document.body.appendChild(overlay); + + // Selection change listener + document.addEventListener('selectionchange', () => { + clearTimeout(selectionTimeout); + selectionTimeout = setTimeout(() => { + const selection = window.getSelection(); + const text = selection.toString().trim(); + + if (text.length > 0) { + currentSelection = text; + currentRange = selection.getRangeAt(0).cloneRange(); + showOverlay(selection.getRangeAt(0)); + } else { + hideOverlay(); + } + }, 150); + }); + + function showOverlay(range) { + const rect = range.getBoundingClientRect(); + const scrollY = window.scrollY || window.pageYOffset; + + overlay.style.display = 'block'; + + // Center horizontally under selection + const overlayWidth = 320; // Approximate width with label + 4 buttons + const centerX = rect.left + (rect.width / 2); + const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8)); + + // Position with extra space below selection (55px instead of 70px) to bring it closer + const topPos = rect.bottom + scrollY + 55; + + overlay.style.left = leftPos + 'px'; + overlay.style.top = topPos + 'px'; + } + + function hideOverlay() { + overlay.style.display = 'none'; + currentSelection = null; + currentRange = null; + } + + function calculateOffset(container, offset) { + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(container, offset); + return preRange.toString().length; + } + + function getXPathSelector(node) { + // If node is text node, use parent element + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element === document.body) return 'body'; + + const path = []; + let current = element; + + while (current && current !== document.body) { + const tagName = current.tagName.toLowerCase(); + + // Count position among siblings of same tag (1-based index) + let index = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (sibling.tagName === current.tagName) { + index++; + } + sibling = sibling.previousElementSibling; + } + + // Format: tagname[index] (1-based) + path.unshift(tagName + '[' + index + ']'); + + current = current.parentElement; + } + + const selector = path.join('/'); + console.log('Generated selector:', selector); + return selector || 'body'; + } + + function calculateOffsetInElement(container, offset) { + // Calculate offset relative to the parent element (not document.body) + const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; + if (!element) return offset; + + // Create range from start of element to the position + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(container, offset); + + return range.toString().length; + } + + function generateTempId() { + return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + + function handleColorSelection(color) { + if (!currentRange || !currentSelection) return; + + // Generate XPath-like selectors for start and end containers + const startSelector = getXPathSelector(currentRange.startContainer); + const endSelector = getXPathSelector(currentRange.endContainer); + + // Calculate offsets relative to the element (not document.body) + const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset); + const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset); + + // Create annotation element + const annotation = document.createElement('rd-annotation'); + annotation.setAttribute('data-annotation-color', color); + annotation.setAttribute('data-annotation-id-value', generateTempId()); + + // Wrap selection in annotation + try { + currentRange.surroundContents(annotation); + } catch (e) { + // If surroundContents fails (e.g., partial element selection), extract and wrap + const fragment = currentRange.extractContents(); + annotation.appendChild(fragment); + currentRange.insertNode(annotation); + } + + // For NativeWebView: use global variable for polling + window.__pendingAnnotation = { + color: color, + text: currentSelection, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + }; + + // Clear selection and hide overlay + window.getSelection().removeAllRanges(); + hideOverlay(); + } + })(); + """ } private func generateScrollToAnnotationJS() -> String { diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 40822f0..442f5b1 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -7,7 +7,7 @@ struct WebView: UIViewRepresentable { let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? - var onTextSelected: ((String, Int, Int) -> Void)? = nil + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -30,10 +30,11 @@ 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") + webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll - context.coordinator.onTextSelected = onTextSelected + context.coordinator.onAnnotationCreated = onAnnotationCreated + context.coordinator.webView = webView return webView } @@ -41,7 +42,7 @@ struct WebView: UIViewRepresentable { func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll - context.coordinator.onTextSelected = onTextSelected + context.coordinator.onAnnotationCreated = onAnnotationCreated let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) @@ -250,38 +251,38 @@ struct WebView: UIViewRepresentable { /* Yellow annotations */ rd-annotation[data-annotation-color="yellow"] { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="yellow"].selected { - background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.6)" : "rgba(107, 79, 3, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(158, 117, 4, 0.5)" : "rgba(107, 79, 3, 0.6)"); + background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6)); } /* Green annotations */ rd-annotation[data-annotation-color="green"] { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="green"].selected { - background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.6)" : "rgba(57, 88, 9, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(132, 204, 22, 0.5)" : "rgba(57, 88, 9, 0.6)"); + background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6)); } /* Blue annotations */ rd-annotation[data-annotation-color="blue"] { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="blue"].selected { - background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.6)" : "rgba(7, 95, 116, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(9, 132, 159, 0.5)" : "rgba(7, 95, 116, 0.6)"); + background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6)); } /* Red annotations */ rd-annotation[data-annotation-color="red"] { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode)); } rd-annotation[data-annotation-color="red"].selected { - background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.6)" : "rgba(103, 29, 29, 0.5)"); - box-shadow: 0 0 0 2px \(isDarkMode ? "rgba(152, 43, 43, 0.5)" : "rgba(103, 29, 29, 0.6)"); + background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5)); + box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6)); } @@ -313,30 +314,11 @@ 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()) + + // Text Selection and Annotation Overlay + \(generateAnnotationOverlayJS(isDarkMode: isDarkMode)) @@ -349,6 +331,7 @@ struct WebView: UIViewRepresentable { webView.navigationDelegate = nil webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate") webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") + webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated") webView.loadHTMLString("", baseURL: nil) coordinator.cleanup() } @@ -409,13 +392,264 @@ struct WebView: UIViewRepresentable { } """ } + + private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String { + let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode) + let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode) + let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode) + let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode) + + return """ + // Create annotation color overlay + (function() { + let currentSelection = null; + let currentRange = null; + let selectionTimeout = null; + + // Create overlay container with arrow + const overlay = document.createElement('div'); + overlay.id = 'annotation-overlay'; + overlay.style.cssText = ` + display: none; + position: absolute; + z-index: 10000; + `; + + // Create arrow/triangle pointing up with glass effect + const arrow = document.createElement('div'); + arrow.style.cssText = ` + position: absolute; + width: 20px; + height: 20px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-right: none; + border-bottom: none; + top: -11px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + overlay.appendChild(arrow); + + // Create the actual content container with glass morphism effect + const content = document.createElement('div'); + content.style.cssText = ` + display: flex; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 24px; + padding: 12px 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + gap: 12px; + flex-direction: row; + align-items: center; + `; + overlay.appendChild(content); + + // Add "Markierung" label + const label = document.createElement('span'); + label.textContent = 'Markierung'; + label.style.cssText = ` + color: black; + font-size: 16px; + font-weight: 500; + margin-right: 4px; + `; + content.appendChild(label); + + // Create color buttons with solid colors + const colors = [ + { name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' }, + { name: 'red', color: '\(AnnotationColor.red.hexColor)' }, + { name: 'blue', color: '\(AnnotationColor.blue.hexColor)' }, + { name: 'green', color: '\(AnnotationColor.green.hexColor)' } + ]; + + colors.forEach(({ name, color }) => { + const btn = document.createElement('button'); + btn.dataset.color = name; + btn.style.cssText = ` + width: 40px; + height: 40px; + border-radius: 50%; + background: ${color}; + border: 3px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 0; + margin: 0; + transition: transform 0.2s, border-color 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.transform = 'scale(1.1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.6)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.transform = 'scale(1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; + }); + btn.addEventListener('click', () => handleColorSelection(name)); + content.appendChild(btn); + }); + + document.body.appendChild(overlay); + + // Selection change listener + document.addEventListener('selectionchange', () => { + clearTimeout(selectionTimeout); + selectionTimeout = setTimeout(() => { + const selection = window.getSelection(); + const text = selection.toString().trim(); + + if (text.length > 0) { + currentSelection = text; + currentRange = selection.getRangeAt(0).cloneRange(); + showOverlay(selection.getRangeAt(0)); + } else { + hideOverlay(); + } + }, 150); + }); + + function showOverlay(range) { + const rect = range.getBoundingClientRect(); + const scrollY = window.scrollY || window.pageYOffset; + + overlay.style.display = 'block'; + + // Center horizontally under selection + const overlayWidth = 320; // Approximate width with label + 4 buttons + const centerX = rect.left + (rect.width / 2); + const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8)); + + // Position with extra space below selection (55px instead of 70px) to bring it closer + const topPos = rect.bottom + scrollY + 55; + + overlay.style.left = leftPos + 'px'; + overlay.style.top = topPos + 'px'; + } + + function hideOverlay() { + overlay.style.display = 'none'; + currentSelection = null; + currentRange = null; + } + + function calculateOffset(container, offset) { + const preRange = document.createRange(); + preRange.selectNodeContents(document.body); + preRange.setEnd(container, offset); + return preRange.toString().length; + } + + function getXPathSelector(node) { + // If node is text node, use parent element + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element === document.body) return 'body'; + + const path = []; + let current = element; + + while (current && current !== document.body) { + const tagName = current.tagName.toLowerCase(); + + // Count position among siblings of same tag (1-based index) + let index = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (sibling.tagName === current.tagName) { + index++; + } + sibling = sibling.previousElementSibling; + } + + // Format: tagname[index] (1-based) + path.unshift(tagName + '[' + index + ']'); + + current = current.parentElement; + } + + const selector = path.join('/'); + console.log('Generated selector:', selector); + return selector || 'body'; + } + + function calculateOffsetInElement(container, offset) { + // Calculate offset relative to the parent element (not document.body) + const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; + if (!element) return offset; + + // Create range from start of element to the position + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(container, offset); + + return range.toString().length; + } + + function generateTempId() { + return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + + function handleColorSelection(color) { + if (!currentRange || !currentSelection) return; + + // Generate XPath-like selectors for start and end containers + const startSelector = getXPathSelector(currentRange.startContainer); + const endSelector = getXPathSelector(currentRange.endContainer); + + // Calculate offsets relative to the element (not document.body) + const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset); + const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset); + + // Create annotation element + const annotation = document.createElement('rd-annotation'); + annotation.setAttribute('data-annotation-color', color); + annotation.setAttribute('data-annotation-id-value', generateTempId()); + + // Wrap selection in annotation + try { + currentRange.surroundContents(annotation); + } catch (e) { + // If surroundContents fails (e.g., partial element selection), extract and wrap + const fragment = currentRange.extractContents(); + annotation.appendChild(fragment); + currentRange.insertNode(annotation); + } + + // Send to Swift with selectors + window.webkit.messageHandlers.annotationCreated.postMessage({ + color: color, + text: currentSelection, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + }); + + // Clear selection and hide overlay + window.getSelection().removeAllRanges(); + hideOverlay(); + } + })(); + """ + } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { // Callbacks var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? - var onTextSelected: ((String, Int, Int) -> Void)? + var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? + + // WebView reference + weak var webView: WKWebView? // Height management var lastHeight: CGFloat = 0 @@ -457,12 +691,15 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler self.handleScrollProgress(progress: progress) } } - if message.name == "textSelected", let body = message.body as? [String: Any], + if message.name == "annotationCreated", let body = message.body as? [String: Any], + let color = body["color"] as? String, let text = body["text"] as? String, let startOffset = body["startOffset"] as? Int, - let endOffset = body["endOffset"] as? Int { + let endOffset = body["endOffset"] as? Int, + let startSelector = body["startSelector"] as? String, + let endSelector = body["endSelector"] as? String { DispatchQueue.main.async { - self.onTextSelected?(text, startOffset, endOffset) + self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector) } } } @@ -532,13 +769,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler func cleanup() { guard !isCleanedUp else { return } isCleanedUp = true - + scrollEndTimer?.invalidate() scrollEndTimer = nil heightUpdateTimer?.invalidate() heightUpdateTimer = nil - + onHeightChange = nil onScroll = nil + onAnnotationCreated = nil } }