From ac2f9cf85d2a931faec1fd9aaeae948e6fa443b6 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Thu, 21 May 2026 12:43:33 -0500 Subject: [PATCH] Add in-app browser with reader mode Tapping a bookmark now opens an in-app WKWebView instead of bouncing to Safari. Reader mode injects JS to extract article content and rerender with clean serif typography and dark-mode support. Bottom toolbar has back/forward, reader toggle, share, and open-in-Safari escape hatch. Co-Authored-By: Claude Sonnet 4.6 --- Marks.xcodeproj/project.pbxproj | 4 + Marks/Views/BookmarksView.swift | 15 ++- Marks/Views/BrowserView.swift | 197 ++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 Marks/Views/BrowserView.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 7c1d6e5..d4ba9e8 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; + FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -75,6 +76,7 @@ CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; + FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -129,6 +131,7 @@ AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, + FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, FE19F7619214271A8C12EEEB /* CollectionsView.swift */, D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */, @@ -265,6 +268,7 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, + FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index eb4a24b..0135480 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -9,6 +9,7 @@ struct BookmarksView: View { @State private var showCollections = false @State private var showAddBookmark = false @State private var editingBookmark: Bookmark? + @State private var browsingBookmark: Bookmark? var body: some View { NavigationStack { @@ -16,7 +17,7 @@ struct BookmarksView: View { ForEach(viewModel.bookmarks) { bookmark in BookmarkRow(bookmark: bookmark) .onTapGesture { - if let url = URL(string: bookmark.url) { openURL(url) } + browsingBookmark = bookmark } .onAppear { maybeLoadMore(bookmark) } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) @@ -48,10 +49,15 @@ struct BookmarksView: View { } label: { Label("Edit", systemImage: "pencil") } + Button { + browsingBookmark = bookmark + } label: { + Label("Open", systemImage: "globe") + } Button { if let url = URL(string: bookmark.url) { openURL(url) } } label: { - Label("Open", systemImage: "safari") + Label("Open in Safari", systemImage: "safari") } Divider() Button { @@ -139,6 +145,11 @@ struct BookmarksView: View { .sheet(item: $editingBookmark) { bookmark in EditBookmarkView(viewModel: viewModel, bookmark: bookmark) } + .sheet(item: $browsingBookmark) { bookmark in + if let url = URL(string: bookmark.url) { + BrowserView(url: url, title: bookmark.displayTitle) + } + } .refreshable { await viewModel.load() } diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift new file mode 100644 index 0000000..7006c2d --- /dev/null +++ b/Marks/Views/BrowserView.swift @@ -0,0 +1,197 @@ +import SwiftUI +import WebKit + +@Observable +final class BrowserState: NSObject, WKNavigationDelegate { + let webView: WKWebView + var isLoading = false + var canGoBack = false + var canGoForward = false + var pageTitle = "" + var currentURL: URL? + + init(url: URL) { + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + webView = WKWebView(frame: .zero, configuration: config) + super.init() + webView.navigationDelegate = self + webView.load(URLRequest(url: url)) + } + + 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 + } + + 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() } + } + } + + func exitReaderMode() { + webView.reload() + } + + 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('' + + '' + + '' + + '' + title + '' + + '

' + title + '

' + html + ''); + document.close(); + return 'ok'; + })() + """ + } +} + +private struct WebViewRepresentable: UIViewRepresentable { + let webView: WKWebView + func makeUIView(context: Context) -> WKWebView { webView } + func updateUIView(_ uiView: WKWebView, context: Context) {} +} + +struct BrowserView: View { + let initialURL: URL + let initialTitle: String + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @State private var state: BrowserState + @State private var readerMode = false + + init(url: URL, title: String) { + self.initialURL = url + self.initialTitle = title + self._state = State(initialValue: BrowserState(url: url)) + } + + var body: some View { + NavigationStack { + ZStack(alignment: .top) { + WebViewRepresentable(webView: state.webView) + .ignoresSafeArea(edges: .bottom) + + 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() + + ShareLink(item: state.currentURL ?? initialURL) { + Image(systemName: "square.and.arrow.up") + } + } + } + } + } +}