spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// ReaderView.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// Semantic zoom (CLAUDE.md §5). The pinch does not change text size — it11/// changes the level of detail: full text, condensed sections, outline,12/// one sentence. Spreading past the full text dismisses the reader and13/// returns the raw page. The rendering adapts to the page kind (article →14/// serif reading page; documentation → code kept prominent).15struct ReaderView: View {16 @State private var model: ReaderModel17 let accent: Color18 let library: LibraryStore19 let onSaveFavorite: () -> Void2021 @Environment(\.dismiss) private var dismiss22 @GestureState private var pinch: CGFloat = 123 @State private var scrollTarget: Int?2425 /// Single tint for generated content, everywhere in the app (§7).26 static let generated = Color(red: 0.55, green: 0.36, blue: 0.96)2728 init(29 tab: Tab,30 intelligence: IntelligenceCenter,31 accent: Color,32 library: LibraryStore,33 onSaveFavorite: @escaping () -> Void34 ) {35 _model = State(initialValue: ReaderModel(tab: tab, intelligence: intelligence))36 self.accent = accent37 self.library = library38 self.onSaveFavorite = onSaveFavorite39 }4041 var body: some View {42 VStack(spacing: 0) {43 header44 Divider()45 content46 .scaleEffect(pinchScale)47 .opacity(pinchOpacity)48 .animation(.spring(duration: 0.35), value: model.level)49 }50 .background(Color(.systemBackground))51 .gesture(zoomGesture)52 .task { await model.load() }53 }5455 // MARK: - Header5657 private var header: some View {58 VStack(spacing: Spacing.s) {59 HStack {60 Button {61 dismiss()62 } label: {63 Image(systemName: "xmark")64 .font(.subheadline.weight(.semibold))65 .frame(width: 34, height: 34)66 .background(.quaternary.opacity(0.5), in: Circle())67 }68 .buttonStyle(.plain)69 .accessibilityIdentifier("reader.close")7071 Spacer()7273 Text(model.title.isEmpty ? "Lecture" : model.title)74 .font(.footnote.weight(.semibold))75 .lineLimit(1)7677 Spacer()7879 // Save the page as native data ("Le favori structuré", P0).80 let saved = library.hasFavorite(for: model.tab.page.url)81 Button {82 if !saved { onSaveFavorite() }83 } label: {84 Image(systemName: saved ? "bookmark.fill" : "bookmark")85 .font(.subheadline.weight(.semibold))86 .foregroundStyle(saved ? accent : .primary)87 .frame(width: 34, height: 34)88 .background(.quaternary.opacity(0.5), in: Circle())89 }90 .buttonStyle(.plain)91 .accessibilityIdentifier("reader.save")92 }9394 levelChips95 }96 .padding(.horizontal, Spacing.l)97 .padding(.vertical, Spacing.s)98 }99100 private var levelChips: some View {101 HStack(spacing: Spacing.s) {102 ForEach(ReaderLevel.allCases, id: \.rawValue) { level in103 let selected = model.level == level104 Button {105 withAnimation(.spring(duration: 0.35)) { model.level = level }106 } label: {107 Text(level.label)108 .font(.caption.weight(selected ? .semibold : .regular))109 .foregroundStyle(selected ? .white : .primary)110 .padding(.horizontal, Spacing.m)111 .padding(.vertical, Spacing.s)112 .background(113 selected ? AnyShapeStyle(accent) : AnyShapeStyle(.quaternary.opacity(0.5)),114 in: Capsule()115 )116 }117 .buttonStyle(.plain)118 .accessibilityIdentifier("reader.level.\(level.label)")119 }120 }121 }122123 // MARK: - Pinch = level of detail124125 private var zoomGesture: some Gesture {126 MagnifyGesture()127 .updating($pinch) { value, state, _ in128 state = value.magnification129 }130 .onEnded { value in131 if value.magnification < 0.8 {132 // Pinch in: condense.133 if let next = ReaderLevel(rawValue: model.level.rawValue + 1) {134 model.level = next135 }136 } else if value.magnification > 1.25 {137 // Spread: more detail; past full text, the raw page.138 if let previous = ReaderLevel(rawValue: model.level.rawValue - 1) {139 model.level = previous140 } else {141 dismiss()142 }143 }144 }145 }146147 /// The text visibly contracts or expands while pinching (§5: the user148 /// must see it happen, no screen jumps).149 private var pinchScale: CGFloat {150 min(max(pinch, 0.9), 1.1)151 }152153 private var pinchOpacity: Double {154 let deviation = abs(pinch - 1)155 return max(0.6, 1 - deviation * 0.8)156 }157158 // MARK: - Content159160 @ViewBuilder161 private var content: some View {162 if model.extractionFailed {163 ContentUnavailableView {164 Label("Rien à lire ici", systemImage: "book")165 } description: {166 Text("Cette page ne contient pas assez de texte. La page d'origine reste affichée derrière.")167 }168 } else {169 switch model.level {170 case .full: fullText171 case .condensed: condensedSections172 case .outline: outline173 case .gist: gistView174 }175 }176 }177178 /// Article pages read in serif; everything else keeps the system face.179 private var serif: Bool { model.digest?.kind == .article }180181 private var fullText: some View {182 ScrollViewReader { proxy in183 ScrollView {184 LazyVStack(alignment: .leading, spacing: Spacing.l) {185 ForEach(Array(model.blocks.enumerated()), id: \.offset) { index, block in186 BlockView(block: block, serif: serif)187 .id(index)188 }189 }190 .padding(Spacing.xl)191 .frame(maxWidth: 700, alignment: .leading)192 .frame(maxWidth: .infinity)193 }194 .onAppear {195 if let target = scrollTarget {196 proxy.scrollTo(target, anchor: .top)197 scrollTarget = nil198 }199 }200 }201 }202203 private var condensedSections: some View {204 ScrollView {205 LazyVStack(alignment: .leading, spacing: Spacing.m) {206 ForEach(model.sections) { section in207 let summary = model.summary(for: section)208 Button {209 jump(to: section)210 } label: {211 VStack(alignment: .leading, spacing: Spacing.s) {212 Text(section.title)213 .font(.headline)214 .foregroundStyle(.primary)215 .multilineTextAlignment(.leading)216 if !summary.text.isEmpty {217 summaryText(summary)218 }219 }220 .padding(Spacing.l)221 .frame(maxWidth: .infinity, alignment: .leading)222 .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.l))223 }224 .buttonStyle(.plain)225 }226 }227 .padding(Spacing.l)228 }229 }230231 private var outline: some View {232 ScrollView {233 VStack(alignment: .leading, spacing: 0) {234 ForEach(model.sections) { section in235 Button {236 jump(to: section)237 } label: {238 HStack(spacing: Spacing.m) {239 Rectangle()240 .fill(accent.opacity(0.6))241 .frame(width: 3, height: 18)242 Text(section.title)243 .font(section.level <= 1 ? .body.weight(.semibold) : .subheadline)244 .foregroundStyle(.primary)245 .multilineTextAlignment(.leading)246 Spacer()247 }248 .padding(.leading, CGFloat(max(0, section.level - 1)) * Spacing.l)249 .padding(.vertical, Spacing.m)250 .contentShape(Rectangle())251 }252 .buttonStyle(.plain)253 }254 }255 .padding(Spacing.xl)256 }257 }258259 private var gistView: some View {260 VStack(spacing: Spacing.l) {261 Spacer()262 if let kind = model.digest?.kind {263 Text(kind.label)264 .font(.caption.weight(.semibold))265 .foregroundStyle(Self.generated)266 .padding(.horizontal, Spacing.m)267 .padding(.vertical, Spacing.xs)268 .background(Self.generated.opacity(0.12), in: Capsule())269 }270 Text(model.title)271 .font(.footnote.weight(.semibold))272 .foregroundStyle(.secondary)273 .multilineTextAlignment(.center)274 summaryText(model.gist)275 .font(.title2.weight(.medium))276 .multilineTextAlignment(.center)277 Spacer()278 Spacer()279 }280 .padding(Spacing.xl)281 .frame(maxWidth: .infinity)282 .accessibilityIdentifier("reader.gist")283 }284285 /// Generated text always wears the distinct treatment; deterministic286 /// fallbacks look like ordinary interface text (§7).287 @ViewBuilder288 private func summaryText(_ summary: (text: String, generated: Bool)) -> some View {289 if summary.generated {290 HStack(alignment: .top, spacing: Spacing.s) {291 Image(systemName: "sparkles")292 .font(.caption)293 .foregroundStyle(Self.generated)294 .padding(.top, 3)295 Text(summary.text)296 .italic()297 .foregroundStyle(.primary)298 }299 } else {300 Text(summary.text)301 .foregroundStyle(.secondary)302 }303 }304305 private func jump(to section: ReaderSection) {306 scrollTarget = section.blockRange.lowerBound307 withAnimation(.spring(duration: 0.35)) { model.level = .full }308 }309}310311// MARK: - Block rendering (adaptive by kind)312313private struct BlockView: View {314 let block: ContentBlock315 let serif: Bool316317 var body: some View {318 switch block.kind {319 case .heading:320 Text(block.text)321 .font(headingFont)322 .padding(.top, block.level <= 2 ? Spacing.m : Spacing.xs)323 case .paragraph:324 Text(block.text)325 .font(serif ? .system(.body, design: .serif) : .body)326 .lineSpacing(5)327 case .listItem:328 HStack(alignment: .top, spacing: Spacing.s) {329 Text("•").foregroundStyle(.secondary)330 Text(block.text)331 .font(serif ? .system(.body, design: .serif) : .body)332 .lineSpacing(4)333 }334 case .quote:335 HStack(alignment: .top, spacing: Spacing.m) {336 RoundedRectangle(cornerRadius: 2)337 .fill(.quaternary)338 .frame(width: 3)339 Text(block.text)340 .font(.system(.body, design: serif ? .serif : .default))341 .italic()342 .foregroundStyle(.secondary)343 }344 case .code:345 ScrollView(.horizontal, showsIndicators: false) {346 Text(block.text)347 .font(.system(.callout, design: .monospaced))348 .padding(Spacing.m)349 }350 .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: Radius.m))351 case .table:352 Text(block.text)353 .font(.system(.caption, design: .monospaced))354 .padding(Spacing.m)355 .frame(maxWidth: .infinity, alignment: .leading)356 .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: Radius.m))357 case .caption:358 Text(block.text)359 .font(.caption)360 .foregroundStyle(.secondary)361 }362 }363364 private var headingFont: Font {365 let design: Font.Design = serif ? .serif : .default366 switch block.level {367 case 1: return .system(.title, design: design).weight(.bold)368 case 2: return .system(.title2, design: design).weight(.bold)369 case 3: return .system(.title3, design: design).weight(.semibold)370 default: return .system(.headline, design: design)371 }372 }373}374