import SwiftUI import WebKit struct WebView: UIViewRepresentable { let htmlContent: String let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil var onExternalScrollUpdate: ((WebViewCoordinator) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() // Enable text selection and copy functionality let preferences = WKWebpagePreferences() preferences.allowsContentJavaScript = true configuration.defaultWebpagePreferences = preferences let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.scrollView.isScrollEnabled = false webView.isOpaque = false webView.backgroundColor = UIColor.clear print("🟒 WebView created with scrolling DISABLED (embedded in ScrollView)") // Allow text selection and copying webView.allowsBackForwardNavigationGestures = false webView.allowsLinkPreview = true webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll context.coordinator.webView = webView // Notify parent that coordinator is ready onExternalScrollUpdate?(context.coordinator) return webView } func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif) // Clean up problematic HTML that kills performance let cleanedHTML = htmlContent // Remove Google attributes that cause navigation events .replacingOccurrences(of: #"\s*jsaction="[^"]*""#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s*jscontroller="[^"]*""#, with: "", options: .regularExpression) .replacingOccurrences(of: #"\s*jsname="[^"]*""#, with: "", options: .regularExpression) // Remove unnecessary IDs that bloat the DOM .replacingOccurrences(of: #"\s*id="[^"]*""#, with: "", options: .regularExpression) // Remove tabindex from non-interactive elements .replacingOccurrences(of: #"\s*tabindex="[^"]*""#, with: "", options: .regularExpression) // Remove role=button from figures (causes false click targets) .replacingOccurrences(of: #"\s*role="button""#, with: "", options: .regularExpression) // Fix invalid nested

tags inside


            .replacingOccurrences(of: #"
]*>([^<]*)

"#, with: "

$1\n", options: .regularExpression)
            .replacingOccurrences(of: #"

([^<]*)
"#, with: "\n$1
", options: .regularExpression) let styledHTML = """ \(cleanedHTML) """ webView.loadHTMLString(styledHTML, baseURL: nil) } func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) { webView.stopLoading() webView.navigationDelegate = nil webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate") webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") webView.loadHTMLString("", baseURL: nil) coordinator.cleanup() } func makeCoordinator() -> WebViewCoordinator { WebViewCoordinator() } 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, 'Segoe UI', Roboto, sans-serif" case .serif: return "'Times New Roman', Times, 'Liberation Serif', serif" case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif" case .monospace: return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" } } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { // Callbacks var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? // WebView reference weak var webView: WKWebView? // Height management var lastHeight: CGFloat = 0 var pendingHeight: CGFloat = 0 var heightUpdateTimer: Timer? // Scroll management var isScrolling: Bool = false var scrollVelocity: Double = 0 var lastScrollTime: Date = Date() var scrollEndTimer: Timer? var lastSentProgress: Double = 0 // Lifecycle private var isCleanedUp = false deinit { cleanup() } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { UIApplication.shared.open(url) decisionHandler(.cancel) return } } decisionHandler(.allow) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { print("πŸ”” Swift received message: \(message.name)") if message.name == "heightUpdate", let height = message.body as? CGFloat { print("πŸ“ Height update: \(height)px") DispatchQueue.main.async { self.handleHeightUpdate(height: height) } } if message.name == "scrollProgress", let progress = message.body as? Double { print("πŸ“Š Swift received scroll progress: \(String(format: "%.3f", progress)) (\(String(format: "%.1f", progress * 100))%)") DispatchQueue.main.async { self.handleScrollProgress(progress: progress) } } } private func handleHeightUpdate(height: CGFloat) { // Store the pending height pendingHeight = height // If we're actively scrolling, defer the height update if isScrolling { return } // Apply height update immediately if not scrolling applyHeightUpdate(height: height) } private func handleScrollProgress(progress: Double) { print("🎯 handleScrollProgress called with: \(String(format: "%.3f", progress))") let now = Date() let timeDelta = now.timeIntervalSince(lastScrollTime) // Calculate scroll velocity to detect fast scrolling if timeDelta > 0 { scrollVelocity = abs(progress) / timeDelta } lastScrollTime = now isScrolling = true // Longer delay for scroll end detection, especially during fast scrolling let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5 scrollEndTimer?.invalidate() scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in self?.handleScrollEnd() } print("πŸš€ Calling onScroll callback with progress: \(String(format: "%.3f", progress))") onScroll?(progress) } private func handleScrollEnd() { isScrolling = false scrollVelocity = 0 // Apply any pending height update after scrolling ends if pendingHeight != lastHeight && pendingHeight > 0 { // Add small delay to ensure scroll has fully stopped heightUpdateTimer?.invalidate() heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in guard let self = self else { return } self.applyHeightUpdate(height: self.pendingHeight) } } } private func applyHeightUpdate(height: CGFloat) { // Only update if height actually changed significantly let heightDifference = abs(height - lastHeight) if heightDifference < 5 { // Ignore tiny height changes that cause flicker return } lastHeight = height onHeightChange?(height) } // Method to receive scroll updates from SwiftUI ScrollView func updateScrollProgress(offset: CGFloat, maxOffset: CGFloat) { let progress = maxOffset > 0 ? min(max(offset / maxOffset, 0), 1) : 0 print("πŸ“Š External scroll update: offset=\(offset), maxOffset=\(maxOffset), progress=\(String(format: "%.3f", progress))") // Only send if change >= 3% let threshold: Double = 0.03 if abs(progress - lastSentProgress) >= threshold { print("βœ… Calling onScroll callback with: \(String(format: "%.3f", progress))") lastSentProgress = progress onScroll?(progress) } else { print("⏸️ Skipping (change < 3%): \(String(format: "%.3f", abs(progress - lastSentProgress)))") } } func cleanup() { guard !isCleanedUp else { return } isCleanedUp = true scrollEndTimer?.invalidate() scrollEndTimer = nil heightUpdateTimer?.invalidate() heightUpdateTimer = nil onHeightChange = nil onScroll = nil } }