All checks were successful
CI / build-and-deploy (push) Successful in 21s
Without a UI delegate, WKWebView hands target="_blank" and window.open() links to the system (Safari). createWebViewWith now loads them in the existing webview instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
12 KiB
Swift
297 lines
12 KiB
Swift
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, WKUIDelegate {
|
|
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.uiDelegate = 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 }
|
|
|
|
// target="_blank" and window.open() — load in the same webview instead of opening Safari
|
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
|
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
if let url = action.request.url {
|
|
webView.load(URLRequest(url: url))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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('<!DOCTYPE html><html><head>' +
|
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
'<style>' + css + '</style>' +
|
|
'<title>' + title + '</title></head><body>' +
|
|
'<h1>' + title + '</h1>' + html + '</body></html>');
|
|
document.close();
|
|
return 'ok';
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
// MARK: - WebView representable
|
|
|
|
private struct WebViewRepresentable: UIViewRepresentable {
|
|
let webView: WKWebView
|
|
func makeUIView(context: Context) -> WKWebView { webView }
|
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
|
}
|
|
|
|
// MARK: - BrowserView
|
|
|
|
struct BrowserView: View {
|
|
let initialURL: URL
|
|
let initialTitle: String
|
|
let claude: ClaudeService
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var state: BrowserState
|
|
@State private var readerMode = false
|
|
@State private var showPodcast = false
|
|
|
|
init(url: URL, title: String, claude: ClaudeService) {
|
|
self.initialURL = url
|
|
self.initialTitle = title
|
|
self.claude = claude
|
|
self._state = State(initialValue: BrowserState(url: url))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack(alignment: .top) {
|
|
WebViewRepresentable(webView: state.webView)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
|
|
// Reading progress bar
|
|
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(Color.blue.opacity(0.7))
|
|
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
|
}
|
|
.frame(height: 3)
|
|
.animation(.linear(duration: 0.1), value: state.readingProgress)
|
|
}
|
|
|
|
if state.isLoading {
|
|
ProgressView()
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "xmark")
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button { openURL(state.currentURL ?? initialURL) } label: {
|
|
Image(systemName: "safari")
|
|
}
|
|
}
|
|
ToolbarItemGroup(placement: .bottomBar) {
|
|
Button { state.webView.goBack() } label: {
|
|
Image(systemName: "chevron.left")
|
|
}
|
|
.disabled(!state.canGoBack)
|
|
|
|
Spacer()
|
|
|
|
Button { state.webView.goForward() } label: {
|
|
Image(systemName: "chevron.right")
|
|
}
|
|
.disabled(!state.canGoForward)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
if readerMode { state.exitReaderMode(); readerMode = false }
|
|
else { state.enterReaderMode { readerMode = true } }
|
|
} label: {
|
|
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
|
}
|
|
.disabled(state.isLoading)
|
|
|
|
Spacer()
|
|
|
|
Button { showPodcast = true } label: {
|
|
Image(systemName: "headphones")
|
|
}
|
|
.disabled(state.isLoading)
|
|
|
|
Spacer()
|
|
|
|
ShareLink(item: state.currentURL ?? initialURL) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
let urlStr = (state.currentURL ?? initialURL).absoluteString
|
|
let p = state.readingProgress
|
|
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
|
}
|
|
.sheet(isPresented: $showPodcast) {
|
|
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
|
|
}
|
|
}
|
|
}
|