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
This commit is contained in:
parent
47f8f73664
commit
cf06a3147d
@ -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 {
|
||||
@ -444,6 +445,20 @@ class API: PAPI {
|
||||
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 {
|
||||
|
||||
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
24
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
24
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
readeck/Domain/Model/Annotation.swift
Normal file
19
readeck/Domain/Model/Annotation.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
3
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
3
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
@ -0,0 +1,3 @@
|
||||
protocol PAnnotationsRepository {
|
||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||
}
|
||||
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
120
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
120
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
29
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
29
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,7 @@ 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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -18,6 +18,7 @@ class BookmarkDetailViewModel {
|
||||
var errorMessage: String?
|
||||
var settings: Settings?
|
||||
var readProgress: Int = 0
|
||||
var selectedAnnotationId: String?
|
||||
|
||||
private var factory: UseCaseFactory?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@ -11,6 +11,7 @@ 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)");
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -242,6 +289,9 @@ struct NativeWebView: View {
|
||||
}
|
||||
|
||||
scheduleHeightCheck();
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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)
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -264,6 +308,9 @@ struct WebView: UIViewRepresentable {
|
||||
document.querySelectorAll('img').forEach(img => {
|
||||
img.addEventListener('load', debouncedHeightUpdate);
|
||||
});
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user