import SwiftUI import WebKit // MARK: - Reading progress persistence enum ReadingProgress { private static let key = "readingProgress" static func get(url: String) -> Double { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0 } static func set(url: String, progress: Double) { var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] if progress >= 0.99 { store.removeValue(forKey: url) // fully read — clean up } else { store[url] = progress } UserDefaults.standard.set(store, forKey: key) } static func all() -> [String: Double] { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] } } // MARK: - BrowserState @Observable final class BrowserState: NSObject, WKNavigationDelegate { let webView: WKWebView var isLoading = false var canGoBack = false var canGoForward = false var pageTitle = "" var currentURL: URL? var readingProgress: Double = 0 // Weak wrapper breaks WKUserContentController's strong retain of the handler private final class ScrollHandler: NSObject, WKScriptMessageHandler { weak var state: BrowserState? init(_ state: BrowserState) { self.state = state } func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) { if let p = msg.body as? Double { state?.readingProgress = p } } } private var scrollHandler: ScrollHandler? init(url: URL) { let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: config) super.init() let handler = ScrollHandler(self) scrollHandler = handler webView.configuration.userContentController.add(handler, name: "scrollProgress") webView.navigationDelegate = self webView.load(URLRequest(url: url)) } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") } func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { isLoading = true } func webView(_ webView: WKWebView, didCommit _: WKNavigation!) { currentURL = webView.url canGoBack = webView.canGoBack canGoForward = webView.canGoForward } func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { isLoading = false canGoBack = webView.canGoBack canGoForward = webView.canGoForward pageTitle = webView.title ?? "" currentURL = webView.url // Restore persisted progress, then start tracking scroll if let urlStr = webView.url?.absoluteString { let saved = ReadingProgress.get(url: urlStr) if saved > 0 { let pct = saved * 100 webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil) } } webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil) } func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false } func enterReaderMode(completion: @escaping () -> Void) { webView.evaluateJavaScript(readerModeJS) { _, _ in DispatchQueue.main.async { completion() self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil) } } } func exitReaderMode() { webView.reload() } private var scrollTrackingJS: String { """ (function(){ function report(){ var h=document.body.scrollHeight-window.innerHeight; var p=h>0?window.scrollY/h:0; window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p))); } window.removeEventListener('scroll',window._marksScrollFn); window._marksScrollFn=report; window.addEventListener('scroll',report,{passive:true}); report(); })(); """ } private var readerModeJS: String { """ (function() { var title = document.title; var selectors = ['article','[role="article"]','main','[role="main"]', '.post-content','.article-content','.entry-content', '.post-body','.article-body','.story-body','.content','#content']; var content = null; for (var i = 0; i < selectors.length; i++) { var el = document.querySelector(selectors[i]); if (el && (el.innerText||'').trim().length > 300) { content = el; break; } } if (!content) { var best = { el: null, len: 0 }; Array.from(document.querySelectorAll('div')).forEach(function(d) { var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0); if (len > best.len) best = { el: d, len: len }; }); content = best.el || document.body; } var clone = content.cloneNode(true); ['script','style','nav','header','footer','aside'].forEach(function(tag){ clone.querySelectorAll(tag).forEach(function(el){ el.remove(); }); }); var html = clone.innerHTML; var css = [ 'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;', 'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}', 'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}', 'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}', 'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}', 'a{color:#007aff;text-decoration:none}', 'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}', 'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}', 'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}', '@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}', 'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}' ].join(''); document.open(); document.write('
' + '' + '' + '