From cdfa6dc4c54ff5c1b12bab104102dd8923e38592 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Thu, 30 Oct 2025 21:07:13 +0100 Subject: [PATCH] Fix annotation navigation by scrolling outer ScrollView instead of WebView The JavaScript was executing scrollIntoView() but the WebView itself cannot scroll (isScrollEnabled = false). Fixed by calculating the annotation's Y position in the WebView and scrolling the outer ScrollView to the correct position instead. Changes: - WebView.swift: Added onScrollToPosition callback and scrollToPosition message handler. JavaScript now calculates and sends annotation position to Swift instead of using scrollIntoView(). - NativeWebView.swift: Same changes for iOS 26+ with polling mechanism for window.__pendingScrollPosition. - BookmarkDetailLegacyView.swift: Implemented onScrollToPosition callback that calculates final scroll position (header height + annotation position) and scrolls the outer ScrollView. - BookmarkDetailView2.swift: Same implementation as BookmarkDetailLegacyView. --- .../BookmarkDetailLegacyView.swift | 12 +- .../BookmarkDetail/BookmarkDetailView2.swift | 10 + readeck/UI/Components/NativeWebView.swift | 43 ++- readeck/UI/Components/WebView.swift | 21 +- readeck/UI/Resources/RELEASE_NOTES.md | 120 ++++++++ readeck/Utils/Logger.swift | 262 ++++++++++++++++++ 6 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 readeck/UI/Resources/RELEASE_NOTES.md create mode 100644 readeck/Utils/Logger.swift diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift index b44becd..ef242ef 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailLegacyView.swift @@ -101,6 +101,16 @@ struct BookmarkDetailLegacyView: View { endSelector: endSelector ) } + }, + onScrollToPosition: { position in + // Calculate scroll position: add header height and webview offset + let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight + let targetPosition = imageHeight + position + + // Scroll to the annotation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + scrollPosition = ScrollPosition(y: targetPosition) + } } ) .frame(height: webViewHeight) @@ -392,7 +402,7 @@ struct BookmarkDetailLegacyView: View { }) { HStack { Image(systemName: "safari") - Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open") + Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + "open") } .font(.title3.bold()) .frame(maxWidth: .infinity) diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index f43cad4..833c22c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -475,6 +475,16 @@ struct BookmarkDetailView2: View { endSelector: endSelector ) } + }, + onScrollToPosition: { position in + // Calculate scroll position: add header height and webview offset + let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight + let targetPosition = imageHeight + position + + // Scroll to the annotation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + scrollPosition = ScrollPosition(y: targetPosition) + } } ) .frame(height: webViewHeight) diff --git a/readeck/UI/Components/NativeWebView.swift b/readeck/UI/Components/NativeWebView.swift index 352c110..ca5180b 100644 --- a/readeck/UI/Components/NativeWebView.swift +++ b/readeck/UI/Components/NativeWebView.swift @@ -13,6 +13,7 @@ struct NativeWebView: View { var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil + var onScrollToPosition: ((CGFloat) -> Void)? = nil @State private var webPage = WebPage() @Environment(\.colorScheme) private var colorScheme @@ -23,6 +24,7 @@ struct NativeWebView: View { .onAppear { loadStyledContent() setupAnnotationMessageHandler() + setupScrollToPositionHandler() } .onChange(of: htmlContent) { _, _ in loadStyledContent() @@ -82,6 +84,38 @@ struct NativeWebView: View { } } } + + private func setupScrollToPositionHandler() { + guard let onScrollToPosition = onScrollToPosition else { return } + + // Poll for scroll position messages from JavaScript + Task { @MainActor in + let page = webPage + + while true { + try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s + + let script = """ + return (function() { + if (window.__pendingScrollPosition !== undefined) { + const position = window.__pendingScrollPosition; + window.__pendingScrollPosition = undefined; + return position; + } + return null; + })(); + """ + + do { + if let position = try await page.callJavaScript(script) as? Double { + onScrollToPosition(CGFloat(position)) + } + } catch { + // Silently continue polling + } + } + } + } private func updateContentHeightWithJS() async { var lastHeight: CGFloat = 0 @@ -627,8 +661,15 @@ struct NativeWebView: View { const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); if (selectedElement) { selectedElement.classList.add('selected'); + + // Get the element's position relative to the document + const rect = selectedElement.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const elementTop = rect.top + scrollTop; + + // Send position to Swift via polling mechanism setTimeout(() => { - selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.__pendingScrollPosition = elementTop; }, 100); } } diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 442f5b1..0be285a 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -8,6 +8,7 @@ struct WebView: UIViewRepresentable { var onScroll: ((Double) -> Void)? = nil var selectedAnnotationId: String? var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil + var onScrollToPosition: ((CGFloat) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { @@ -31,9 +32,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: "annotationCreated") + webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll context.coordinator.onAnnotationCreated = onAnnotationCreated + context.coordinator.onScrollToPosition = onScrollToPosition context.coordinator.webView = webView return webView @@ -43,6 +46,7 @@ struct WebView: UIViewRepresentable { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll context.coordinator.onAnnotationCreated = onAnnotationCreated + context.coordinator.onScrollToPosition = onScrollToPosition let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) @@ -332,6 +336,7 @@ struct WebView: UIViewRepresentable { webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate") webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated") + webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition") webView.loadHTMLString("", baseURL: nil) coordinator.cleanup() } @@ -379,8 +384,15 @@ struct WebView: UIViewRepresentable { const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]'); if (selectedElement) { selectedElement.classList.add('selected'); + + // Get the element's position relative to the document + const rect = selectedElement.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const elementTop = rect.top + scrollTop; + + // Send position to Swift setTimeout(() => { - selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop); }, 100); } } @@ -647,6 +659,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? + var onScrollToPosition: ((CGFloat) -> Void)? // WebView reference weak var webView: WKWebView? @@ -702,6 +715,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector) } } + if message.name == "scrollToPosition", let position = message.body as? Double { + DispatchQueue.main.async { + self.onScrollToPosition?(CGFloat(position)) + } + } } private func handleHeightUpdate(height: CGFloat) { @@ -778,5 +796,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler onHeightChange = nil onScroll = nil onAnnotationCreated = nil + onScrollToPosition = nil } } diff --git a/readeck/UI/Resources/RELEASE_NOTES.md b/readeck/UI/Resources/RELEASE_NOTES.md new file mode 100644 index 0000000..bbc938a --- /dev/null +++ b/readeck/UI/Resources/RELEASE_NOTES.md @@ -0,0 +1,120 @@ +# Release Notes + +Thanks for using the Readeck iOS app! Below are the release notes for each version. + +**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features. + +## Version 1.2.0 + +### Annotations & Highlighting + +- **Highlight important passages** directly in your articles +- Select text to bring up a beautiful color picker overlay +- Choose from four distinct colors: yellow, green, blue, and red +- Your highlights are saved and synced across devices +- Tap on annotations in the list to jump directly to that passage in the article +- Glass morphism design for a modern, elegant look + +### Performance Improvements + +- **Dramatically faster label loading** - especially with 1000+ labels +- Labels now load instantly from local cache, then sync in background +- Optimized label management to prevent crashes and lag +- Share Extension now loads labels without delay +- Reduced memory usage when working with large label collections +- Better offline support - labels always available even without internet + +### Fixes & Improvements + +- Centralized color management for consistent appearance +- Improved annotation creation workflow +- Better text selection handling in article view +- Implemented lazy loading for label lists +- Switched to Core Data as primary source for labels +- Batch operations for faster database queries +- Background sync to keep labels up-to-date without blocking the UI +- Fixed duplicate ID warnings in label lists + +--- + +## Version 1.1.0 + +There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development. + +### Modern Reading Experience (iOS 26+) + +- **Completely rebuilt article view** for the latest iOS version +- Smoother scrolling and faster page loading +- Better battery life and memory usage +- Native iOS integration for the best experience + +### Quick Actions + +- **Smart action buttons** appear automatically when you're almost done reading +- Beautiful, modern design that blends with your content +- Quickly favorite or archive articles without scrolling back up +- Buttons fade away elegantly when you scroll back +- Your progress bar now reflects the entire article length + +### Beautiful Article Images + +- **Article header images now display properly** without awkward cropping +- Full images with a subtle blurred background +- Tap to view images in full screen + +### Smoother Performance + +- **Dramatically improved scrolling** - no more stuttering or lag +- Faster article loading times +- Better handling of long articles with many images +- Overall snappier app experience + +### Open Links Your Way + +- **Choose your preferred browser** for opening links +- Open in Safari or in-app browser +- Thanks to christian-putzke for this contribution! + +### Fixes & Improvements + +- Articles no longer overflow the screen width +- Fixed spacing issues in article view +- Improved progress calculation accuracy +- Better handling of article content +- Fixed issues with label names containing spaces + +--- + +## Version 1.0 (Initial Release) + +### Core Features + +- Browse and read saved articles +- Bookmark management with labels +- Full article view with custom fonts +- Text-to-speech support (Beta) +- Archive and favorite functionality +- Choose different Layouts (Compact, Magazine, Natural) + +### Reading Experience + +- Clean, distraction-free reading interface +- Customizable font settings +- Header Image viewer with zoom support +- Progress tracking per article +- Dark mode support + +### Organization + +- Label system for categorization (multi-select) +- Search +- Archive completed articles +- Jump to last read position + +### Share Extension + +- Save articles from other apps +- Quick access to save and label bookmarks +- Save Bookmarks offline if your server is not reachable and sync later + + diff --git a/readeck/Utils/Logger.swift b/readeck/Utils/Logger.swift new file mode 100644 index 0000000..cc8f6c1 --- /dev/null +++ b/readeck/Utils/Logger.swift @@ -0,0 +1,262 @@ +// +// Logger.swift +// readeck +// +// Created by Ilyas Hallak on 16.08.25. +// + +import Foundation +import os + +// MARK: - Log Configuration + +enum LogLevel: Int, CaseIterable { + case debug = 0 + case info = 1 + case notice = 2 + case warning = 3 + case error = 4 + case critical = 5 + + var emoji: String { + switch self { + case .debug: return "🔍" + case .info: return "â„šī¸" + case .notice: return "đŸ“ĸ" + case .warning: return "âš ī¸" + case .error: return "❌" + case .critical: return "đŸ’Ĩ" + } + } +} + +enum LogCategory: String, CaseIterable { + case network = "Network" + case ui = "UI" + case data = "Data" + case auth = "Authentication" + case performance = "Performance" + case general = "General" + case manual = "Manual" + case viewModel = "ViewModel" +} + +class LogConfiguration: ObservableObject { + static let shared = LogConfiguration() + + @Published private var categoryLevels: [LogCategory: LogLevel] = [:] + @Published var globalMinLevel: LogLevel = .debug + @Published var showPerformanceLogs = true + @Published var showTimestamps = true + @Published var includeSourceLocation = true + + private init() { + loadConfiguration() + } + + func setLevel(_ level: LogLevel, for category: LogCategory) { + categoryLevels[category] = level + saveConfiguration() + } + + func getLevel(for category: LogCategory) -> LogLevel { + return categoryLevels[category] ?? globalMinLevel + } + + func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { + let categoryLevel = getLevel(for: category) + return level.rawValue >= categoryLevel.rawValue + } + + private func loadConfiguration() { + // Load from UserDefaults + if let data = UserDefaults.standard.data(forKey: "LogConfiguration"), + let config = try? JSONDecoder().decode([String: Int].self, from: data) { + for (categoryString, levelInt) in config { + if let category = LogCategory(rawValue: categoryString), + let level = LogLevel(rawValue: levelInt) { + categoryLevels[category] = level + } + } + } + + globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug + showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") + showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") + includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + } + + private func saveConfiguration() { + let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue } + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: "LogConfiguration") + } + + UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel") + UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") + UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") + UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") + } +} + +struct Logger { + private let logger: os.Logger + private let category: LogCategory + private let config = LogConfiguration.shared + + init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) { + self.logger = os.Logger(subsystem: subsystem, category: category.rawValue) + self.category = category + } + + // MARK: - Log Levels + + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.debug, for: category) else { return } + let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) + logger.debug("\(formattedMessage)") + } + + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.info, for: category) else { return } + let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) + logger.info("\(formattedMessage)") + } + + func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.notice, for: category) else { return } + let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) + logger.notice("\(formattedMessage)") + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.warning, for: category) else { return } + let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) + logger.warning("\(formattedMessage)") + } + + func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.error, for: category) else { return } + let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) + logger.error("\(formattedMessage)") + } + + func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.critical, for: category) else { return } + let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) + logger.critical("\(formattedMessage)") + } + + // MARK: - Convenience Methods + + func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) { + guard config.shouldLog(.info, for: category) else { return } + if let statusCode = statusCode { + info("🌐 \(method) \(url) - Status: \(statusCode)") + } else { + info("🌐 \(method) \(url)") + } + } + + func logNetworkError(method: String, url: String, error: Error) { + guard config.shouldLog(.error, for: category) else { return } + self.error("❌ \(method) \(url) - Error: \(error.localizedDescription)") + } + + func logPerformance(_ operation: String, duration: TimeInterval) { + guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return } + info("âąī¸ \(operation) completed in \(String(format: "%.3f", duration))s") + } + + // MARK: - Private Helpers + + private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String { + var components: [String] = [] + + if config.showTimestamps { + let timestamp = DateFormatter.logTimestamp.string(from: Date()) + components.append(timestamp) + } + + components.append(level.emoji) + components.append("[\(category.rawValue)]") + + if config.includeSourceLocation { + components.append("[\(sourceFileName(filePath: file)):\(line)]") + components.append(function) + } + + components.append("-") + components.append(message) + + return components.joined(separator: " ") + } + + private func sourceFileName(filePath: String) -> String { + return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "") + } +} + +// MARK: - Category-specific Loggers + +extension Logger { + static let network = Logger(category: .network) + static let ui = Logger(category: .ui) + static let data = Logger(category: .data) + static let auth = Logger(category: .auth) + static let performance = Logger(category: .performance) + static let general = Logger(category: .general) + static let manual = Logger(category: .manual) + static let viewModel = Logger(category: .viewModel) +} + +// MARK: - Performance Measurement Helper + +struct PerformanceMeasurement { + private let startTime = CFAbsoluteTimeGetCurrent() + private let operation: String + private let logger: Logger + + init(operation: String, logger: Logger = .performance) { + self.operation = operation + self.logger = logger + logger.debug("🚀 Starting \(operation)") + } + + func end() { + let duration = CFAbsoluteTimeGetCurrent() - startTime + logger.logPerformance(operation, duration: duration) + } +} + +// MARK: - DateFormatter Extension + +extension DateFormatter { + static let logTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + + return formatter + }() +} + +// MARK: - Dictionary Extension + +extension Dictionary { + func mapKeys(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + return try Dictionary(uniqueKeysWithValues: map { (try transform($0.key), $0.value) }) + } +} + +// MARK: - Debug Build Detection + +extension Bundle { + var isDebugBuild: Bool { + #if DEBUG + return true + #else + return false + #endif + } +} +