diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 76967da..893e0b2 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ Data/CoreData/CoreDataManager.swift, "Data/Extensions/NSManagedObjectContext+SafeFetch.swift", Data/KeychainHelper.swift, + Data/Utils/LabelUtils.swift, Domain/Model/Bookmark.swift, Domain/Model/BookmarkLabel.swift, Logger.swift, diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 105eabe..a2d8133 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -25,10 +25,9 @@ struct BookmarkDetailView: View { private let headerHeight: CGFloat = 360 - init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) { + init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) { self.bookmarkId = bookmarkId self.viewModel = viewModel - self.webViewHeight = webViewHeight self.showingFontSettings = showingFontSettings self.showingLabelsSheet = showingLabelsSheet } @@ -61,6 +60,8 @@ struct BookmarkDetailView: View { if webViewHeight != height { webViewHeight = height } + }, onScroll: { progress in + // Handle scroll progress if needed }) .frame(height: webViewHeight) .cornerRadius(14) @@ -247,17 +248,7 @@ struct BookmarkDetailView: View { @ViewBuilder private var contentSection: some View { - if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { - WebView(htmlContent: viewModel.articleContent, settings: settings) { height in - withAnimation(.easeInOut(duration: 0.1)) { - webViewHeight = height - } - } - .frame(height: webViewHeight) - .cornerRadius(14) - .padding(.horizontal) - .animation(.easeInOut, value: webViewHeight) - } else if viewModel.isLoadingArticle { + if viewModel.isLoadingArticle { ProgressView("Loading article...") .frame(maxWidth: .infinity, alignment: .center) .padding() @@ -464,7 +455,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey { NavigationView { BookmarkDetailView(bookmarkId: "123", viewModel: .init(MockUseCaseFactory()), - webViewHeight: 300, showingFontSettings: false, showingLabelsSheet: false, playerUIState: .init()) diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 382f1a3..ca95fed 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -1,7 +1,249 @@ import SwiftUI import WebKit -struct WebView: UIViewRepresentable { +// iOS 26+ Native SwiftUI WebView Implementation +@available(iOS 26.0, *) +struct NativeWebView: View { + let htmlContent: String + let settings: Settings + let onHeightChange: (CGFloat) -> Void + var onScroll: ((Double) -> Void)? = nil + + @State private var webPage = WebPage() + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + WebKit.WebView(webPage) + .scrollDisabled(true) // Disable internal scrolling + .onAppear { + loadStyledContent() + } + .onChange(of: htmlContent) { _, _ in + loadStyledContent() + } + .onChange(of: colorScheme) { _, _ in + loadStyledContent() + } + .onChange(of: webPage.isLoading) { _, isLoading in + if !isLoading { + // Update height when content finishes loading + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + Task { + await updateContentHeightWithJS() + } + } + } + } + } + + private func updateContentHeightWithJS() async { + var lastHeight: CGFloat = 0 + + // More frequent attempts with shorter delays + let delays = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0] // 9 attempts + + for (index, delay) in delays.enumerated() { + let attempt = index + 1 + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + do { + // Try to get height via JavaScript + let result = try await webPage.callJavaScript("getContentHeight()") + + if let height = result as? Double, height > 0 { + let cgHeight = CGFloat(height) + + // Update height if it's significantly different or this is the first valid measurement + if lastHeight == 0 || abs(cgHeight - lastHeight) > 10 { + print("JavaScript height updated: \(height)px on attempt \(attempt)") + DispatchQueue.main.async { + self.onHeightChange(cgHeight) + } + lastHeight = cgHeight + } + + // If height seems stable (no change in last few attempts), we can exit early + if attempt >= 3 && lastHeight > 0 { + print("Height stabilized at \(lastHeight)px") + return + } + } + } catch { + print("JavaScript attempt \(attempt) failed: \(error)") + } + } + + // If no valid height was found, use fallback + if lastHeight == 0 { + print("No valid JavaScript height found, using fallback") + updateContentHeightFallback() + } + } + + private func updateContentHeightFallback() { + // Simplified fallback calculation + let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) + let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + let characterCount = plainText.count + let estimatedLines = max(1, characterCount / 80) + let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8 + let finalHeight = max(400, min(textHeight + 100, 3000)) + + DispatchQueue.main.async { + self.onHeightChange(finalHeight) + } + } + + private func loadStyledContent() { + let isDarkMode = colorScheme == .dark + let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) + let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif) + + let styledHTML = """ + +
+ + + + + + \(htmlContent) + + + + """ + webPage.load(html: styledHTML) + + // Update height after content loads + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + await updateContentHeightWithJS() + } + } + } + + private func getFontSize(from fontSize: FontSize) -> Int { + switch fontSize { + case .small: return 14 + case .medium: return 16 + case .large: return 18 + case .extraLarge: return 20 + } + } + + private func getFontFamily(from fontFamily: FontFamily) -> String { + switch fontFamily { + case .system: return "-apple-system, BlinkMacSystemFont, sans-serif" + case .serif: return "'Times New Roman', Times, serif" + case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif" + case .monospace: return "'SF Mono', Menlo, Monaco, monospace" + } + } +} + +// Main WebView - automatically chooses best implementation +struct WebView: View { + let htmlContent: String + let settings: Settings + let onHeightChange: (CGFloat) -> Void + var onScroll: ((Double) -> Void)? = nil + + var body: some View { + if #available(iOS 26.0, *) { + // Use new native SwiftUI WebView on iOS 26+ + NativeWebView( + htmlContent: htmlContent, + settings: settings, + onHeightChange: onHeightChange, + onScroll: onScroll + ) + } else { + // Fallback to WKWebView wrapper for older iOS + LegacyWebView( + htmlContent: htmlContent, + settings: settings, + onHeightChange: onHeightChange, + onScroll: onScroll + ) + } + } +} + +// Fallback: Original WKWebView Implementation +struct LegacyWebView: UIViewRepresentable { let htmlContent: String let settings: Settings let onHeightChange: (CGFloat) -> Void @@ -26,7 +268,7 @@ struct WebView: UIViewRepresentable { webView.allowsBackForwardNavigationGestures = false webView.allowsLinkPreview = true - // Message Handler hier einmalig hinzufügen + // Message Handler für Height und Scroll Updates webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") context.coordinator.onHeightChange = onHeightChange @@ -36,7 +278,7 @@ struct WebView: UIViewRepresentable { } func updateUIView(_ webView: WKWebView, context: Context) { - // Nur den HTML-Inhalt laden, keine Handler-Konfiguration + // Update callbacks context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll @@ -240,6 +482,7 @@ struct WebView: UIViewRepresentable { document.querySelectorAll('img').forEach(img => { img.addEventListener('load', updateHeight); }); + // Scroll progress reporting window.addEventListener('scroll', function() { var scrollTop = window.scrollY || document.documentElement.scrollTop; @@ -288,6 +531,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler var isScrolling: Bool = false var scrollEndTimer: Timer? + deinit { + scrollEndTimer?.invalidate() + scrollEndTimer = nil + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { @@ -300,7 +548,9 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "heightUpdate", let height = message.body as? CGFloat { + switch message.name { + case "heightUpdate": + guard let height = message.body as? CGFloat else { return } DispatchQueue.main.async { // Block height updates during active scrolling to prevent flicker if !self.isScrolling && !self.hasHeightUpdate { @@ -308,20 +558,24 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler self.hasHeightUpdate = true } } - } - if message.name == "scrollProgress", let progress = message.body as? Double { + + case "scrollProgress": + guard let progress = message.body as? Double else { return } DispatchQueue.main.async { // Track scrolling state self.isScrolling = true // Reset scrolling state after scroll ends self.scrollEndTimer?.invalidate() - self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in - self.isScrolling = false + self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in + self?.isScrolling = false } self.onScroll?(progress) } + + default: + print("Unknown message: \(message.name)") } } }