From cf06a3147d9fac0992dbbc6eecb15e1729a3e88d Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 22 Oct 2025 15:25:55 +0200 Subject: [PATCH] feat: Add annotations support with color-coded highlighting Add comprehensive annotations feature to bookmark detail views: - Implement annotations list view with date formatting and state machine - Add CSS-based highlighting for rd-annotation tags in WebView components - Support Readeck color scheme (yellow, green, blue, red) for annotations - Enable tap-to-scroll functionality to navigate to selected annotations - Integrate annotations button in bookmark detail toolbar - Add API endpoint and repository layer for fetching annotations --- readeck/Data/API/API.swift | 19 ++- readeck/Data/API/DTOs/AnnotationDto.swift | 21 +++ .../Repository/AnnotationsRepository.swift | 24 ++++ readeck/Domain/Model/Annotation.swift | 19 +++ .../Protocols/PAnnotationsRepository.swift | 3 + .../GetBookmarkAnnotationsUseCase.swift | 17 +++ .../BookmarkDetail/AnnotationsListView.swift | 120 ++++++++++++++++++ .../AnnotationsListViewModel.swift | 29 +++++ .../BookmarkDetailLegacyView.swift | 32 ++++- .../BookmarkDetail/BookmarkDetailView2.swift | 25 +++- .../BookmarkDetailViewModel.swift | 13 +- readeck/UI/Components/NativeWebView.swift | 83 +++++++++++- readeck/UI/Components/WebView.swift | 78 ++++++++++++ .../UI/Factory/DefaultUseCaseFactory.swift | 6 + readeck/UI/Factory/MockUseCaseFactory.swift | 12 ++ readeck/UI/Menu/PadSidebarView.swift | 6 +- readeck/UI/Menu/PhoneTabView.swift | 8 +- 17 files changed, 494 insertions(+), 21 deletions(-) create mode 100644 readeck/Data/API/DTOs/AnnotationDto.swift create mode 100644 readeck/Data/Repository/AnnotationsRepository.swift create mode 100644 readeck/Domain/Model/Annotation.swift create mode 100644 readeck/Domain/Protocols/PAnnotationsRepository.swift create mode 100644 readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift create mode 100644 readeck/UI/BookmarkDetail/AnnotationsListView.swift create mode 100644 readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 57cb0f1..901d247 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -18,6 +18,7 @@ protocol PAPI { func deleteBookmark(id: String) async throws func searchBookmarks(search: String) async throws -> BookmarksPageDto func getBookmarkLabels() async throws -> [BookmarkLabelDto] + func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] } class API: PAPI { @@ -435,15 +436,29 @@ class API: PAPI { logger.debug("Fetching bookmark labels") let endpoint = "/api/bookmarks/labels" logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) - + let result = try await makeJSONRequest( endpoint: endpoint, responseType: [BookmarkLabelDto].self ) - + logger.info("Successfully fetched \(result.count) bookmark labels") return result } + + func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] { + logger.debug("Fetching annotations for bookmark: \(bookmarkId)") + let endpoint = "/api/bookmarks/\(bookmarkId)/annotations" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + + let result = try await makeJSONRequest( + endpoint: endpoint, + responseType: [AnnotationDto].self + ) + + logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)") + return result + } } enum HTTPMethod: String { diff --git a/readeck/Data/API/DTOs/AnnotationDto.swift b/readeck/Data/API/DTOs/AnnotationDto.swift new file mode 100644 index 0000000..54bce0b --- /dev/null +++ b/readeck/Data/API/DTOs/AnnotationDto.swift @@ -0,0 +1,21 @@ +import Foundation + +struct AnnotationDto: Codable { + let id: String + let text: String + let created: String + let startOffset: Int + let endOffset: Int + let startSelector: String + let endSelector: String + + enum CodingKeys: String, CodingKey { + case id + case text + case created + case startOffset = "start_offset" + case endOffset = "end_offset" + case startSelector = "start_selector" + case endSelector = "end_selector" + } +} diff --git a/readeck/Data/Repository/AnnotationsRepository.swift b/readeck/Data/Repository/AnnotationsRepository.swift new file mode 100644 index 0000000..e4a7afc --- /dev/null +++ b/readeck/Data/Repository/AnnotationsRepository.swift @@ -0,0 +1,24 @@ +import Foundation + +class AnnotationsRepository: PAnnotationsRepository { + private let api: PAPI + + init(api: PAPI) { + self.api = api + } + + func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] { + let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId) + return annotationDtos.map { dto in + Annotation( + id: dto.id, + text: dto.text, + created: dto.created, + startOffset: dto.startOffset, + endOffset: dto.endOffset, + startSelector: dto.startSelector, + endSelector: dto.endSelector + ) + } + } +} diff --git a/readeck/Domain/Model/Annotation.swift b/readeck/Domain/Model/Annotation.swift new file mode 100644 index 0000000..4a5b9c6 --- /dev/null +++ b/readeck/Domain/Model/Annotation.swift @@ -0,0 +1,19 @@ +import Foundation + +struct Annotation: Identifiable, Hashable { + let id: String + let text: String + let created: String + let startOffset: Int + let endOffset: Int + let startSelector: String + let endSelector: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Annotation, rhs: Annotation) -> Bool { + lhs.id == rhs.id + } +} diff --git a/readeck/Domain/Protocols/PAnnotationsRepository.swift b/readeck/Domain/Protocols/PAnnotationsRepository.swift new file mode 100644 index 0000000..122b67c --- /dev/null +++ b/readeck/Domain/Protocols/PAnnotationsRepository.swift @@ -0,0 +1,3 @@ +protocol PAnnotationsRepository { + func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] +} diff --git a/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift b/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift new file mode 100644 index 0000000..7e9db77 --- /dev/null +++ b/readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PGetBookmarkAnnotationsUseCase { + func execute(bookmarkId: String) async throws -> [Annotation] +} + +class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { + private let repository: PAnnotationsRepository + + init(repository: PAnnotationsRepository) { + self.repository = repository + } + + func execute(bookmarkId: String) async throws -> [Annotation] { + return try await repository.fetchAnnotations(bookmarkId: bookmarkId) + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListView.swift b/readeck/UI/BookmarkDetail/AnnotationsListView.swift new file mode 100644 index 0000000..4c63668 --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationsListView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct AnnotationsListView: View { + let bookmarkId: String + @State private var viewModel = AnnotationsListViewModel() + @Environment(\.dismiss) private var dismiss + var onAnnotationTap: ((String) -> Void)? + + enum ViewState { + case loading + case empty + case loaded([Annotation]) + case error(String) + } + + private var viewState: ViewState { + if viewModel.isLoading { + return .loading + } else if let error = viewModel.errorMessage, viewModel.showErrorAlert { + return .error(error) + } else if viewModel.annotations.isEmpty { + return .empty + } else { + return .loaded(viewModel.annotations) + } + } + + var body: some View { + List { + switch viewState { + case .loading: + HStack { + Spacer() + ProgressView() + Spacer() + } + + case .empty: + ContentUnavailableView( + "No Annotations", + systemImage: "pencil.slash", + description: Text("This bookmark has no annotations yet.") + ) + + case .loaded(let annotations): + ForEach(annotations) { annotation in + Button(action: { + onAnnotationTap?(annotation.id) + dismiss() + }) { + VStack(alignment: .leading, spacing: 8) { + if !annotation.text.isEmpty { + Text(annotation.text) + .font(.body) + .foregroundColor(.primary) + } + + Text(formatDate(annotation.created)) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + case .error: + EmptyView() + } + } + .navigationTitle("Annotations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .task { + await viewModel.loadAnnotations(for: bookmarkId) + } + .alert("Error", isPresented: $viewModel.showErrorAlert) { + Button("OK", role: .cancel) {} + } message: { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } + } + } + + private func formatDate(_ dateString: String) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFormatterNoMillis = ISO8601DateFormatter() + isoFormatterNoMillis.formatOptions = [.withInternetDateTime] + var date: Date? + if let parsedDate = isoFormatter.date(from: dateString) { + date = parsedDate + } else if let parsedDate = isoFormatterNoMillis.date(from: dateString) { + date = parsedDate + } + if let date = date { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + displayFormatter.locale = .autoupdatingCurrent + return displayFormatter.string(from: date) + } + return dateString + } +} + +#Preview { + NavigationStack { + AnnotationsListView(bookmarkId: "123") + } +} diff --git a/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift new file mode 100644 index 0000000..aede002 --- /dev/null +++ b/readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift @@ -0,0 +1,29 @@ +import Foundation + +@Observable +class AnnotationsListViewModel { + private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase + + var annotations: [Annotation] = [] + var isLoading = false + var errorMessage: String? + var showErrorAlert = false + + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase() + } + + @MainActor + func loadAnnotations(for bookmarkId: String) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId) + } catch { + errorMessage = "Failed to load annotations" + showErrorAlert = true + } + } +} diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index 34c4154..90e76d8 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -29,18 +29,19 @@ struct BookmarkDetailLegacyView: View { @State private var initialContentEndPosition: CGFloat = 0 @State private var showingFontSettings = false @State private var showingLabelsSheet = false + @State private var showingAnnotationsSheet = false @State private var readingProgress: Double = 0.0 @State private var lastSentProgress: Double = 0.0 @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) @State private var showingImageViewer = false - + // MARK: - Envs - + @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings @Environment(\.dismiss) private var dismiss - + private let headerHeight: CGFloat = 360 init(bookmarkId: String, useNativeWebView: Binding, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) { @@ -86,7 +87,8 @@ struct BookmarkDetailLegacyView: View { if webViewHeight != height { webViewHeight = height } - } + }, + selectedAnnotationId: viewModel.selectedAnnotationId ) .frame(height: webViewHeight) .cornerRadius(14) @@ -220,6 +222,12 @@ struct BookmarkDetailLegacyView: View { Image(systemName: "tag") } + Button(action: { + showingAnnotationsSheet = true + }) { + Image(systemName: "pencil.line") + } + Button(action: { showingFontSettings = true }) { @@ -252,6 +260,11 @@ struct BookmarkDetailLegacyView: View { .sheet(isPresented: $showingLabelsSheet) { BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) } + .sheet(isPresented: $showingAnnotationsSheet) { + AnnotationsListView(bookmarkId: bookmarkId) { annotationId in + viewModel.selectedAnnotationId = annotationId + } + } .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } @@ -271,9 +284,20 @@ struct BookmarkDetailLegacyView: View { } } } + .onChange(of: showingAnnotationsSheet) { _, isShowing in + if !isShowing { + // Reload bookmark detail when labels sheet is dismissed + Task { + await viewModel.refreshBookmarkDetail(id: bookmarkId) + } + } + } .onChange(of: viewModel.readProgress) { _, progress in showJumpToProgressButton = progress > 0 && progress < 100 } + .onChange(of: viewModel.selectedAnnotationId) { _, _ in + // Trigger WebView reload when annotation is selected + } .task { await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index b37f463..852bd1e 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -14,6 +14,7 @@ struct BookmarkDetailView2: View { @State private var initialContentEndPosition: CGFloat = 0 @State private var showingFontSettings = false @State private var showingLabelsSheet = false + @State private var showingAnnotationsSheet = false @State private var readingProgress: Double = 0.0 @State private var lastSentProgress: Double = 0.0 @State private var showJumpToProgressButton: Bool = false @@ -50,6 +51,11 @@ struct BookmarkDetailView2: View { .sheet(isPresented: $showingLabelsSheet) { BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) } + .sheet(isPresented: $showingAnnotationsSheet) { + AnnotationsListView(bookmarkId: bookmarkId) { annotationId in + viewModel.selectedAnnotationId = annotationId + } + } .sheet(isPresented: $showingImageViewer) { ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) } @@ -67,9 +73,19 @@ struct BookmarkDetailView2: View { } } } + .onChange(of: showingAnnotationsSheet) { _, isShowing in + if !isShowing { + Task { + await viewModel.refreshBookmarkDetail(id: bookmarkId) + } + } + } .onChange(of: viewModel.readProgress) { _, progress in showJumpToProgressButton = progress > 0 && progress < 100 } + .onChange(of: viewModel.selectedAnnotationId) { _, _ in + // Trigger WebView reload when annotation is selected + } .task { await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId) @@ -254,6 +270,12 @@ struct BookmarkDetailView2: View { Image(systemName: "tag") } + Button(action: { + showingAnnotationsSheet = true + }) { + Image(systemName: "pencil.line") + } + Button(action: { showingFontSettings = true }) { @@ -437,7 +459,8 @@ struct BookmarkDetailView2: View { if webViewHeight != height { webViewHeight = height } - } + }, + selectedAnnotationId: viewModel.selectedAnnotationId ) .frame(height: webViewHeight) .cornerRadius(14) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index e76899e..2ba209f 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -8,7 +8,7 @@ class BookmarkDetailViewModel { private let loadSettingsUseCase: PLoadSettingsUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? - + var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" var articleParagraphs: [String] = [] @@ -18,7 +18,8 @@ class BookmarkDetailViewModel { var errorMessage: String? var settings: Settings? var readProgress: Int = 0 - + var selectedAnnotationId: String? + private var factory: UseCaseFactory? private var cancellables = Set() private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>() @@ -29,7 +30,7 @@ class BookmarkDetailViewModel { self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.factory = factory - + readProgressSubject .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] (id, progress, anchor) in @@ -67,17 +68,17 @@ class BookmarkDetailViewModel { @MainActor func loadArticleContent(id: String) async { isLoadingArticle = true - + do { articleContent = try await getBookmarkArticleUseCase.execute(id: id) processArticleContent() } catch { errorMessage = "Error loading article" } - + isLoadingArticle = false } - + private func processArticleContent() { let paragraphs = articleContent .components(separatedBy: .newlines) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 507d3b9..6ba8c48 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -11,7 +11,8 @@ struct NativeWebView: View { let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil - + var selectedAnnotationId: String? + @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -27,6 +28,9 @@ struct NativeWebView: View { .onChange(of: colorScheme) { _, _ in loadStyledContent() } + .onChange(of: selectedAnnotationId) { _, _ in + loadStyledContent() + } .onChange(of: webPage.isLoading) { _, isLoading in if !isLoading { // Update height when content finishes loading @@ -197,6 +201,49 @@ struct NativeWebView: View { th { font-weight: 600; } hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; } + + /* Annotation Highlighting - for rd-annotation tags */ + rd-annotation { + border-radius: 3px; + padding: 2px 0; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + + /* Yellow annotations */ + rd-annotation[data-annotation-color="yellow"] { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + } + 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)"); + } + + /* Green annotations */ + rd-annotation[data-annotation-color="green"] { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + } + 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)"); + } + + /* Blue annotations */ + rd-annotation[data-annotation-color="blue"] { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + } + 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)"); + } + + /* Red annotations */ + rd-annotation[data-annotation-color="red"] { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + } + 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)"); + } @@ -242,6 +289,9 @@ struct NativeWebView: View { } scheduleHeightCheck(); + + // Scroll to selected annotation + \(generateScrollToAnnotationJS()) @@ -273,6 +323,37 @@ struct NativeWebView: View { case .monospace: return "'SF Mono', Menlo, Monaco, monospace" } } + + private func generateScrollToAnnotationJS() -> String { + guard let selectedId = selectedAnnotationId else { + return "" + } + + return """ + // Scroll to selected annotation and add selected class + function scrollToAnnotation() { + // Remove 'selected' class from all annotations + document.querySelectorAll('rd-annotation.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Find and highlight selected annotation + const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); + if (selectedElement) { + selectedElement.classList.add('selected'); + setTimeout(() => { + selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', scrollToAnnotation); + } else { + setTimeout(scrollToAnnotation, 300); + } + """ + } } // MARK: - Hybrid WebView (Not Currently Used) diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index e59c281..4043ee0 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -6,6 +6,7 @@ struct WebView: UIViewRepresentable { let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil + var selectedAnnotationId: String? @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -235,6 +236,49 @@ struct WebView: UIViewRepresentable { --separator-color: #e0e0e0; } } + + /* Annotation Highlighting - for rd-annotation tags */ + rd-annotation { + border-radius: 3px; + padding: 2px 0; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + } + + /* Yellow annotations */ + rd-annotation[data-annotation-color="yellow"] { + background-color: \(isDarkMode ? "rgba(158, 117, 4, 0.4)" : "rgba(107, 79, 3, 0.3)"); + } + 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)"); + } + + /* Green annotations */ + rd-annotation[data-annotation-color="green"] { + background-color: \(isDarkMode ? "rgba(132, 204, 22, 0.4)" : "rgba(57, 88, 9, 0.3)"); + } + 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)"); + } + + /* Blue annotations */ + rd-annotation[data-annotation-color="blue"] { + background-color: \(isDarkMode ? "rgba(9, 132, 159, 0.4)" : "rgba(7, 95, 116, 0.3)"); + } + 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)"); + } + + /* Red annotations */ + rd-annotation[data-annotation-color="red"] { + background-color: \(isDarkMode ? "rgba(152, 43, 43, 0.4)" : "rgba(103, 29, 29, 0.3)"); + } + 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)"); + } @@ -264,6 +308,9 @@ struct WebView: UIViewRepresentable { document.querySelectorAll('img').forEach(img => { img.addEventListener('load', debouncedHeightUpdate); }); + + // Scroll to selected annotation + \(generateScrollToAnnotationJS()) @@ -305,6 +352,37 @@ struct WebView: UIViewRepresentable { return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" } } + + private func generateScrollToAnnotationJS() -> String { + guard let selectedId = selectedAnnotationId else { + return "" + } + + return """ + // Scroll to selected annotation and add selected class + function scrollToAnnotation() { + // Remove 'selected' class from all annotations + document.querySelectorAll('rd-annotation.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Find and highlight selected annotation + const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); + if (selectedElement) { + selectedElement.classList.add('selected'); + setTimeout(() => { + selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', scrollToAnnotation); + } else { + setTimeout(scrollToAnnotation, 300); + } + """ + } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index bef448c..1a69ce2 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -21,6 +21,7 @@ protocol UseCaseFactory { func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase } @@ -33,6 +34,7 @@ class DefaultUseCaseFactory: UseCaseFactory { private let settingsRepository: PSettingsRepository = SettingsRepository() private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider) private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) + private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api) static let shared = DefaultUseCaseFactory() @@ -119,4 +121,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase { return CheckServerReachabilityUseCase(repository: serverInfoRepository) } + + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { + return GetBookmarkAnnotationsUseCase(repository: annotationsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 61dba4a..6bf191c 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -88,6 +88,10 @@ class MockUseCaseFactory: UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { MockSaveCardLayoutUseCase() } + + func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase { + MockGetBookmarkAnnotationsUseCase() + } } @@ -238,6 +242,14 @@ class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase { } } +class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase { + func execute(bookmarkId: String) async throws -> [Annotation] { + return [ + .init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "") + ] + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index a373b48..8683678 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -87,11 +87,11 @@ struct PadSidebarView: View { case .all: BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .unread: - BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .favorite: - BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .archived: - BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark) + BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .settings: SettingsView() case .article: diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index dbd2f0d..fde9a15 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -153,7 +153,7 @@ struct PhoneTabView: View { // Hidden NavigationLink to remove disclosure indicator NavigationLink { - BookmarkDetailView(bookmarkId: bookmark.id) + BookmarkDetailView(bookmarkId: bookmark.id) } label: { EmptyView() } @@ -234,11 +234,11 @@ struct PhoneTabView: View { case .all: BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .unread: - BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .favorite: - BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .archived: - BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil)) + BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil)) case .search: EmptyView() // search is directly implemented case .settings: