Files
linkding-ios/Marks/Views/BrowserView.swift
T
Krishna KumarandClaude Opus 5 fffb3999bf
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s
Carry the library design across the rest of the app
Every screen now speaks the paper vocabulary rather than only the
bookmarks screen: Search, Sources, Podcasts and the player, Settings,
Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the
Siri snippets, and the share extension's save card.

The change is mostly a design system rather than per-screen tweaks.
LibraryKit gains:

- Semantic colors — secondary/tertiary/faint text, `raised` surfaces,
  and accent/alarm/affirm, so screens stop reaching for .secondary,
  systemGray, .blue, .red and .green. The three status colors come out
  of the palette (the swatch blue, vermilion and forest) rather than
  from the system set, so the design keeps spending one set of inks.
- PaperType — the two voices made explicit. Prose (titles, summaries,
  anything a person wrote) is serif; anything the machine contributes
  (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping
  that split strict is what makes the design read as archival rather
  than as decoration.
- paperSurface() / paperField() / paperCard() for grounds, inputs and
  raised blocks.
- PaperEmptyState, because ContentUnavailableView can't be restyled — it
  draws its own bold system type and grey, which was the loudest
  remaining system voice once the screens moved onto the sheet. All nine
  usages are converted.

The app also sets one accent for the system chrome it doesn't draw —
tab bar, search fields, switches, swipe actions — which otherwise stayed
system blue around paper screens.

BookmarkRow is deleted. Once Search moved to the library row and
TagBookmarksView was gone, nothing passed .classic, so the style fork in
BookmarkListRow went with it. There is one bookmark row now.

The share extension compiles LibraryKit directly, since its save card is
a user-facing surface and should not be the one place still using system
styling.

Verified on device in both color schemes; screens toured with a
temporary UI test harness (removed). Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 12:50:45 -05:00

420 lines
16 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: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// 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
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil
@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
@State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive
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(Paper.accent.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress)
}
// Page loading sweep bar (replaces center spinner)
if state.isLoading {
PageLoadBar()
.transition(.opacity)
}
}
.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")
}
}
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomBar
.offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
}
.onChange(of: state.readingProgress) { old, new in
let delta = new - old
withAnimation(.spring(duration: 0.3, bounce: 0)) {
if delta > 0.012 && new > 0.07 {
toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
toolbarHidden = false
}
}
}
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.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(
vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude,
stopOnDismiss: false
)
}
}
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
// Never interrupt current playback: play-and-show only when idle,
// otherwise generate in the background.
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Paper.raised)
.overlay(alignment: .top) { Divider() }
}
}