ReadKeep/readeck/UI/Components/WebView.swift
Ilyas Hallak 0a53705df1 debug: Add comprehensive logging to JavaScript scroll tracking
- Added console.log statements in JavaScript for scroll events
- Added Swift print statements in message handler
- Added logging in BookmarkDetailLegacyView onScroll callback
- Logs cover: JS initialization, scroll events, message passing, Swift handling

This will help diagnose why scroll events aren't being captured.
2025-10-10 15:25:19 +02:00

471 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> 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
// 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
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 <p> tags inside <pre><span>
.replacingOccurrences(of: #"<pre><span[^>]*>([^<]*)<p>"#, with: "<pre><span>$1\n", options: .regularExpression)
.replacingOccurrences(of: #"</p>([^<]*)</span></pre>"#, with: "\n$1</span></pre>", options: .regularExpression)
let styledHTML = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
:root {
--background-color: \(isDarkMode ? "#000000" : "#ffffff");
--text-color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
--heading-color: \(isDarkMode ? "#ffffff" : "#000000");
--link-color: \(isDarkMode ? "#0A84FF" : "#007AFF");
--quote-color: \(isDarkMode ? "#8E8E93" : "#666666");
--quote-border: \(isDarkMode ? "#0A84FF" : "#007AFF");
--code-background: \(isDarkMode ? "#1C1C1E" : "#f5f5f5");
--code-text: \(isDarkMode ? "#ffffff" : "#000000");
--separator-color: \(isDarkMode ? "#38383A" : "#e0e0e0");
/* Font Settings from Settings */
--base-font-size: \(fontSize)px;
--font-family: \(fontFamily);
}
body {
font-family: var(--font-family);
line-height: 1.8;
margin: 0;
padding: 16px;
background-color: var(--background-color);
color: var(--text-color);
font-size: var(--base-font-size);
-webkit-text-size-adjust: 100%;
-webkit-user-select: text;
-webkit-touch-callout: default;
user-select: text;
}
h1, h2, h3, h4, h5, h6 {
color: var(--heading-color);
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
font-family: var(--font-family);
}
h1 { font-size: calc(var(--base-font-size) * 1.5); }
h2 { font-size: calc(var(--base-font-size) * 1.25); }
h3 { font-size: calc(var(--base-font-size) * 1.125); }
h4 { font-size: var(--base-font-size); }
h5 { font-size: calc(var(--base-font-size) * 0.875); }
h6 { font-size: calc(var(--base-font-size) * 0.75); }
p {
margin-bottom: 16px;
font-family: var(--font-family);
font-size: var(--base-font-size);
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
}
a {
color: var(--link-color);
text-decoration: none;
font-family: var(--font-family);
}
a:hover {
text-decoration: underline;
}
blockquote {
border-left: 4px solid var(--quote-border);
margin: 16px 0;
padding-left: 16px;
font-style: italic;
color: var(--quote-color);
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)");
border-radius: 4px;
padding: 12px 16px;
font-family: var(--font-family);
font-size: var(--base-font-size);
}
code {
background-color: var(--code-background);
color: var(--code-text);
padding: 2px 6px;
border-radius: 4px;
font-family: \(settings.fontFamily == .monospace ? "var(--font-family)" : "'SF Mono', Menlo, Monaco, Consolas, monospace");
font-size: calc(var(--base-font-size) * 0.875);
}
pre {
background-color: var(--code-background);
color: var(--code-text);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-family: \(settings.fontFamily == .monospace ? "var(--font-family)" : "'SF Mono', Menlo, Monaco, Consolas, monospace");
font-size: calc(var(--base-font-size) * 0.875);
border: 1px solid var(--separator-color);
}
pre code {
background-color: transparent;
padding: 0;
font-family: inherit;
}
hr {
border: none;
height: 1px;
background-color: var(--separator-color);
margin: 24px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-family: var(--font-family);
font-size: var(--base-font-size);
}
th, td {
border: 1px solid var(--separator-color);
padding: 8px 12px;
text-align: left;
}
th {
background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.5)" : "rgba(0, 0, 0, 0.05)");
font-weight: 600;
}
ul, ol {
padding-left: 20px;
margin-bottom: 16px;
font-family: var(--font-family);
font-size: var(--base-font-size);
}
li {
margin-bottom: 4px;
}
/* Dark mode media query als Fallback */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #000000;
--text-color: #ffffff;
--heading-color: #ffffff;
--link-color: #0A84FF;
--quote-color: #8E8E93;
--quote-border: #0A84FF;
--code-background: #1C1C1E;
--code-text: #ffffff;
--separator-color: #38383A;
}
}
/* Light mode media query als Fallback */
@media (prefers-color-scheme: light) {
:root {
--background-color: #ffffff;
--text-color: #1a1a1a;
--heading-color: #000000;
--link-color: #007AFF;
--quote-color: #666666;
--quote-border: #007AFF;
--code-background: #f5f5f5;
--code-text: #000000;
--separator-color: #e0e0e0;
}
}
</style>
</head>
<body>
\(cleanedHTML)
<script>
let lastHeight = 0;
let heightUpdateTimeout = null;
let scrollTimeout = null;
let isScrolling = false;
function updateHeight() {
const height = document.body.scrollHeight;
if (Math.abs(height - lastHeight) > 5 && !isScrolling) {
lastHeight = height;
window.webkit.messageHandlers.heightUpdate.postMessage(height);
}
}
function debouncedHeightUpdate() {
clearTimeout(heightUpdateTimeout);
heightUpdateTimeout = setTimeout(updateHeight, 100);
}
window.addEventListener('load', updateHeight);
setTimeout(updateHeight, 500);
document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', debouncedHeightUpdate);
});
console.log('🟢 WebView JavaScript loaded');
console.log('🔵 Document scroll enabled:', document.body.style.overflow);
console.log('🔵 Window innerHeight:', window.innerHeight);
console.log('🔵 Document scrollHeight:', document.documentElement.scrollHeight);
let lastSent = { value: 0 };
window.addEventListener('scroll', function() {
console.log('📜 Scroll event fired!');
isScrolling = true;
let scrollTop = window.scrollY || document.documentElement.scrollTop;
let docHeight = document.documentElement.scrollHeight - window.innerHeight;
let scrollPercent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
console.log('📊 scrollTop:', scrollTop, 'docHeight:', docHeight, 'scrollPercent:', scrollPercent.toFixed(2) + '%');
if (Math.abs(scrollPercent - lastSent.value) >= 3) {
console.log(' Sending scroll progress:', (scrollPercent / 100).toFixed(3));
window.webkit.messageHandlers.scrollProgress.postMessage(scrollPercent / 100);
lastSent.value = scrollPercent;
} else {
console.log(' Skipping (change < 3%): ', Math.abs(scrollPercent - lastSent.value).toFixed(2) + '%');
}
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(function() {
isScrolling = false;
debouncedHeightUpdate();
}, 200);
}, { passive: true });
console.log('🎯 Scroll listener attached');
</script>
</body>
</html>
"""
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)?
// 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?
// 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)
}
func cleanup() {
guard !isCleanedUp else { return }
isCleanedUp = true
scrollEndTimer?.invalidate()
scrollEndTimer = nil
heightUpdateTimer?.invalidate()
heightUpdateTimer = nil
onHeightChange = nil
onScroll = nil
}
}