diff --git a/.gitignore b/.gitignore index 2792406..b5da516 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ playground.xcworkspace fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png -fastlane/test_output*.profraw +fastlane/test_output +*.profraw diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e513a4..27a2f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 continue-reading hero gets a ⋯ menu and context-menu delete, and shelf covers reveal a trash button on hover (the right-click menu stays) +- Native EPUB reader selections were still coarse whole-paragraph picks and + highlight rendering was approximate: text blocks now resolve exact UTF-16 + spans against chapter plain text, iOS/macOS use native selectable text + views for fine-grained ranges, and stored highlights paint the exact local + ranges instead of whole matching paragraphs + +- Native reader selection still stopped at paragraph boundaries because each + paragraph rendered as its own native text view: the selection toolbar now + offers a 跨段 picker that opens a chapter-wide native selection sheet, so + multi-paragraph excerpts can be selected and sent back through the same + explain / translate / highlight flow. + +- Native EPUB selection felt much slower in 双语 mode than the older reader: + native text views were forwarding every drag-step selection update upstream, + which retriggered SwiftUI reconciliation while the parallel inline-note + layout was visible. Selection callbacks are now debounced to the settled + range, and inline note lookup is indexed by paragraph id instead of scanning + the whole note list on every render pass. +- Highlight rows only jumped to chapter starts and existing notes were + read-only: the highlight sheet now jumps back to the stored UTF-16 anchor, + exposes inline “写批注 / 编辑批注”, and Mac reader top bars also surface the + shared highlight sheet so note editing and jump-back work there too. + +- The native EPUB path still carried ~1300 lines of unreachable WebView/JS + bridge code and resumed long chapters only at block tops while loading + chapter images synchronously on the main thread: the dead `WKWebView` + reader path is gone, resume now lands inside the matched text block using + its stored UTF-16 progress, and native image decode runs in a utility task + before hopping back to the main actor for display. + +- The chapter-wide 跨段 picker always reopened at the top and dropped the + user’s prior selection state: it now restores the current selection when + present, otherwise auto-focuses near the current UTF-16 reading offset, and + keeps the whole-chapter native text view aligned with the reader context. + ### Added - Two cloud standards behind the same `AIService` protocol: the existing @@ -81,6 +116,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 progress / 朱批 count / estimated minutes / 预译 state, current chapter highlighted + +- Native SwiftUI EPUB rendering path for iOS and Mac readers: chapters now + parse into heading / paragraph / quote / list / image blocks and render in + `ScrollView + LazyVStack`, with visible-paragraph reporting for 双语/导读 + and stable inline-note insertion without WebView DOM reflow + + +- Deterministic screenshot/smoke launch hooks for both platforms: Mac now + honors `-ScreenshotSeed` like iOS, and the screenshot UI tests seed/open the + demo book through launch arguments instead of relying on whatever book + happened to be in the library. + +- Native reader smoke tests previously depended on an empty local library, + repo-writable screenshot paths, and brittle chrome labels: screenshot + seeding now guarantees the demo book (plus an optional deterministic + highlight), reader chrome exposes stable accessibility IDs for automation, + and the UI smoke suite now exercises the native highlight sheet / jump-back + flow on both macOS and iOS. ### Added (earlier rounds) - iOS 随身伴读 aligned with the 02 iOS prototype: diff --git a/Empty/DesignSystem/EmptyTheme.swift b/Empty/DesignSystem/EmptyTheme.swift index d3844a6..1f2a768 100644 --- a/Empty/DesignSystem/EmptyTheme.swift +++ b/Empty/DesignSystem/EmptyTheme.swift @@ -58,11 +58,11 @@ nonisolated struct EmptyPalette: Equatable { ink3: Color(hex: 0x988E7D), line: Color(hex: 0xE3DACA), line2: Color(hex: 0xD8CEBB), - accent: Color(hex: 0xB5482A), - accentSoft: Color(hex: 0xB5482A, opacity: 0.08), - accentSoft2: Color(hex: 0xB5482A, opacity: 0.16), + accent: Color(hex: 0xA93E22), + accentSoft: Color(hex: 0xA93E22, opacity: 0.08), + accentSoft2: Color(hex: 0xA93E22, opacity: 0.16), onAccent: Color(hex: 0xFFF6EC), - highlight: Color(hex: 0xDEB248, opacity: 0.4), + highlight: Color(hex: 0xE5C55E, opacity: 0.45), shadow: Color(hex: 0x1F1B16, opacity: 0.12) ) @@ -80,7 +80,7 @@ nonisolated struct EmptyPalette: Equatable { accentSoft: Color(hex: 0xD86B47, opacity: 0.12), accentSoft2: Color(hex: 0xD86B47, opacity: 0.22), onAccent: Color(hex: 0xFFF6EC), - highlight: Color(hex: 0xDEB248, opacity: 0.28), + highlight: Color(hex: 0xE5C55E, opacity: 0.3), shadow: Color(hex: 0x000000, opacity: 0.45) ) } diff --git a/Empty/DesignSystem/ReaderAppearance.swift b/Empty/DesignSystem/ReaderAppearance.swift new file mode 100644 index 0000000..4ca0379 --- /dev/null +++ b/Empty/DesignSystem/ReaderAppearance.swift @@ -0,0 +1,149 @@ +// +// ReaderAppearance.swift +// Empty +// +// Reader-surface appearance: canvas themes (微信读书-style background +// swatches, in the 朱批 voice) and body-text font choices. These apply +// to the reading canvas only — the app shell keeps its own palette. +// + +import SwiftUI + +/// Reader canvas theme — the design handoff's four: 纸白 / 暖纸 / 夜读 / +/// 墨黑. `paper` follows the app palette (warm paper in light, night-read +/// in dark); the rest pin the canvas regardless of the shell theme. +nonisolated enum ReaderTheme: String, CaseIterable { + case paper + case wheat + case night + case inkblack + + var title: String { + switch self { + case .paper: "纸白" + case .wheat: "暖纸" + case .night: "夜读" + case .inkblack: "墨黑" + } + } + + /// The palette the reading surface should run under. + func palette(base: EmptyPalette) -> EmptyPalette { + switch self { + case .paper: + return base + case .wheat: + var palette = EmptyPalette.light + palette.window = Color(hex: 0xEFE3CC) + palette.side = Color(hex: 0xE7D8BB) + palette.line = Color(hex: 0xDCCAA6) + palette.line2 = Color(hex: 0xCFBA90) + palette.ink = Color(hex: 0x46391F) + palette.ink2 = Color(hex: 0x6B5B3A) + return palette + case .night: + var palette = EmptyPalette.dark + palette.window = Color(hex: 0x2A241C) + palette.side = Color(hex: 0x322B21) + palette.card = Color(hex: 0x352D23) + palette.ink = Color(hex: 0xC9BFAC) + palette.ink2 = Color(hex: 0xA89D89) + return palette + case .inkblack: + var palette = EmptyPalette.dark + palette.window = Color(hex: 0x0D0C0A) + palette.side = Color(hex: 0x171511) + palette.card = Color(hex: 0x1B1915) + palette.ink = Color(hex: 0xA89F8D) + palette.ink2 = Color(hex: 0x8A8273) + palette.line = Color(hex: 0x26231D) + palette.line2 = Color(hex: 0x333028) + return palette + } + } + + /// Whether the canvas is dark, given the shell theme (only `paper` + /// follows the shell). + func isDarkCanvas(baseIsDark: Bool) -> Bool { + switch self { + case .paper: baseIsDark + case .night, .inkblack: true + case .wheat: false + } + } + + /// Body-text colors for the native text views (primary, secondary). + func inkHexes(baseIsDark: Bool) -> (primary: UInt32, secondary: UInt32) { + switch self { + case .paper: + return baseIsDark ? (0xEDE5D4, 0xC4B9A4) : (0x3A332A, 0x5C5443) + case .wheat: + return (0x46391F, 0x6B5B3A) + case .night: + return (0xC9BFAC, 0xA89D89) + case .inkblack: + return (0xA89F8D, 0x8A8273) + } + } + + /// Swatch fill for the settings picker. + var swatch: Color { + switch self { + case .paper: Color(hex: 0xF7F2E9) + case .wheat: Color(hex: 0xEFE3CC) + case .night: Color(hex: 0x2A241C) + case .inkblack: Color(hex: 0x0D0C0A) + } + } +} + +/// Reader body-text font. `serif` is the system serif the reader shipped +/// with; 宋体 / 楷体 are the classic CJK book faces; 黑体 is the system +/// sans for readers who prefer it. +nonisolated enum ReaderFont: String, CaseIterable { + case serif + case song + case kai + case sans + + var title: String { + switch self { + case .serif: "衬线" + case .song: "宋体" + case .kai: "楷体" + case .sans: "黑体" + } + } + + /// Named font family to resolve, or nil for a system design. + var familyName: String? { + switch self { + case .serif, .sans: nil + case .song: "Songti SC" + case .kai: "Kaiti SC" + } + } + + var usesSerifDesign: Bool { + self == .serif + } +} + +/// Everything the reading canvas needs to draw itself. +nonisolated struct ReaderAppearance: Equatable { + var theme: ReaderTheme = .paper + var font: ReaderFont = .serif +} + +/// How the iOS reader advances text (Mac always scrolls). +nonisolated enum ReaderPageTurn: String, CaseIterable { + case paged + case scroll + + var title: String { + switch self { + case .paged: "左右翻页" + case .scroll: "上下滚动" + } + } +} diff --git a/Empty/EmptyApp.swift b/Empty/EmptyApp.swift index 97ea7e8..b58b48c 100644 --- a/Empty/EmptyApp.swift +++ b/Empty/EmptyApp.swift @@ -12,7 +12,13 @@ struct EmptyApp: App { init() { do { - container = try AppStores.makeContainer() + // Unit-test hosts get a throwaway container: tests must never + // touch (or contend with a running app for) the real stores — + // parallel test clones racing the production SQLite/CloudKit + // locks crashed whole suites at 0.000s. + let isTestHost = ProcessInfo.processInfo + .environment["XCTestConfigurationFilePath"] != nil + container = try AppStores.makeContainer(ephemeral: isTestHost) } catch { // No degraded mode without persistence — fail loudly at launch. fatalError("Failed to set up persistence: \(error)") diff --git a/Empty/Models/AppStores.swift b/Empty/Models/AppStores.swift index 0703d65..71c4e91 100644 --- a/Empty/Models/AppStores.swift +++ b/Empty/Models/AppStores.swift @@ -35,6 +35,7 @@ enum AppStores { ReadingSession.self, VocabEntry.self, StudyCardEntry.self, + Bookmark.self, ]) static let localSchema = Schema([ @@ -87,6 +88,7 @@ enum AppStores { ReadingSession.self, VocabEntry.self, StudyCardEntry.self, + Bookmark.self, Chapter.self, Chunk.self, ParagraphTranslation.self, diff --git a/Empty/Models/Book.swift b/Empty/Models/Book.swift index ca91c86..fe81b94 100644 --- a/Empty/Models/Book.swift +++ b/Empty/Models/Book.swift @@ -95,6 +95,9 @@ final class Book { @Relationship(deleteRule: .cascade, inverse: \StudyCardEntry.book) var studyCards: [StudyCardEntry]? + @Relationship(deleteRule: .cascade, inverse: \Bookmark.book) + var bookmarks: [Bookmark]? + init(title: String, author: String = "", format: BookFormat) { self.title = title self.author = author diff --git a/Empty/Models/Bookmark.swift b/Empty/Models/Bookmark.swift new file mode 100644 index 0000000..7a6f1df --- /dev/null +++ b/Empty/Models/Bookmark.swift @@ -0,0 +1,33 @@ +// +// Bookmark.swift +// Empty +// + +import Foundation +import SwiftData + +/// A reader-placed bookmark (P0 handoff: the 目录/书签/搜索 drawer). +/// Synced store — bookmarks are reader data, like highlights. +/// +/// Set `book` after inserting into a context (SwiftData relationships are +/// only safe to wire between inserted models). +@Model +final class Bookmark { + var id: UUID = UUID() + var book: Book? + + // Flattened `TextAnchor`-style position so queries can sort/filter. + var chapterIndex: Int = 0 + var utf16Offset: Int = 0 + + /// A short excerpt at the bookmark, shown in the drawer list. + var snippet: String = "" + + var createdAt: Date = Date() + + init(chapterIndex: Int, utf16Offset: Int, snippet: String) { + self.chapterIndex = chapterIndex + self.utf16Offset = utf16Offset + self.snippet = snippet + } +} diff --git a/Empty/Models/Highlight.swift b/Empty/Models/Highlight.swift index 2e7796e..273a982 100644 --- a/Empty/Models/Highlight.swift +++ b/Empty/Models/Highlight.swift @@ -7,12 +7,43 @@ import Foundation import SwiftData /// Marker colors. Raw values are stable storage identifiers; rendering -/// decides the actual appearance per platform and theme. +/// decides the actual appearance per platform and theme. The reader UI +/// offers the design system's trio — 金 (yellow) / 朱 (vermilion) / +/// 青 (celadon); the other cases are legacy storage values that still +/// render. nonisolated enum HighlightColor: String, Codable, CaseIterable, Sendable { case yellow case green case blue case pink + case vermilion + case celadon + + /// The picker trio, in design order. + static let readerChoices: [HighlightColor] = [.yellow, .vermilion, .celadon] + + var title: String { + switch self { + case .yellow: "金" + case .vermilion: "朱" + case .celadon: "青" + case .green: "绿" + case .blue: "蓝" + case .pink: "粉" + } + } + + /// Underline tint (高亮改为底线染色,不再整块涂底). + var hex: UInt32 { + switch self { + case .yellow: 0xE5C55E + case .vermilion: 0xC0532F + case .celadon: 0x5E8C7B + case .green: 0x6FA56F + case .blue: 0x5B85A8 + case .pink: 0xC97A8E + } + } } /// A reader-created highlight with an optional note, anchored to book text. diff --git a/Empty/Models/ReadingSession.swift b/Empty/Models/ReadingSession.swift index c87f2f4..2a0089b 100644 --- a/Empty/Models/ReadingSession.swift +++ b/Empty/Models/ReadingSession.swift @@ -19,6 +19,10 @@ final class ReadingSession { var startedAt: Date = Date() var endedAt: Date? + /// Metered *active* reading seconds (scroll/page activity with idle + /// gaps dropped). 0 on legacy rows — stats fall back to wall-clock. + var activeSeconds: Double = 0 + // Flattened `ReadingPosition`s for where the sitting started and ended. var startChapterIndex: Int = 0 var startUTF16Offset: Int = 0 diff --git a/Empty/Services/AIProviderRegistry.swift b/Empty/Services/AIProviderRegistry.swift new file mode 100644 index 0000000..4451b41 --- /dev/null +++ b/Empty/Services/AIProviderRegistry.swift @@ -0,0 +1,209 @@ +// +// AIProviderRegistry.swift +// Empty +// +// P0 handoff §3 多模型: a provider LIST with per-feature routing and a +// fallback chain, upgrading the single mode/protocol choice. The legacy +// `AIProviderSettings` remains the storage for the editable cloud config +// (the diagnostics screen edits it); the registry mirrors it as the +// "linked" cloud provider and layers routing on top. +// +// Fallback chain per the spec: routed provider → default provider → +// local — and when nothing can serve, the routed provider is returned +// so its unavailability reason reaches the UI (features degrade +// silently; reading itself never blocks). +// + +import Foundation + +nonisolated enum AIProviderKind: String, Codable, CaseIterable, Sendable { + case local + case openAI + case anthropic + + var title: String { + switch self { + case .local: "Apple 本地" + case .openAI: "OpenAI 兼容" + case .anthropic: "Anthropic 兼容" + } + } +} + +nonisolated struct AIProvider: Codable, Identifiable, Equatable, Sendable { + var id: UUID + var name: String + var kind: AIProviderKind + var baseURL: String = "" + var model: String = "" + /// Keychain account holding this provider's key; empty = keyless. + var keychainAccount: String = "" + + var isLocal: Bool { kind == .local } +} + +/// The AI features that can be routed to different providers. +nonisolated enum AIFeature: String, Codable, CaseIterable, Sendable { + case translate + case recap + case chat + case vocab + + var title: String { + switch self { + case .translate: "翻译 · 导读" + case .recap: "回顾 · 概览" + case .chat: "伴读对话" + case .vocab: "词汇" + } + } +} + +nonisolated struct AIProviderRegistry: Codable, Equatable, Sendable { + var providers: [AIProvider] + var defaultProviderID: UUID + /// AIFeature.rawValue → provider id. Missing key = default provider. + var routes: [String: UUID] = [:] + + /// Stable identities so migration and the legacy-linked cloud entry + /// survive reloads. + static let localID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")! + static let linkedCloudID = UUID(uuidString: "00000000-0000-0000-0000-0000000000B2")! + + private static let storageKey = "ai.providers.registry.v1" + + static let localProvider = AIProvider( + id: localID, + name: "On-Device · Apple", + kind: .local + ) + + // MARK: Load / save + + /// Loads the registry, synthesizing it from the legacy single-provider + /// settings on first run — and re-syncing the linked cloud entry with + /// the legacy config every load, since the diagnostics screen edits + /// the legacy fields. + static func load(defaults: UserDefaults = .standard) -> AIProviderRegistry { + let legacy = AIProviderSettings.load(from: defaults) + var registry: AIProviderRegistry + if let data = defaults.data(forKey: storageKey), + let stored = try? JSONDecoder().decode(AIProviderRegistry.self, from: data) { + registry = stored + } else { + registry = AIProviderRegistry( + providers: [localProvider], + defaultProviderID: legacy.mode == .cloud ? linkedCloudID : localID + ) + } + + if !registry.providers.contains(where: { $0.id == localID }) { + registry.providers.insert(localProvider, at: 0) + } + + // Mirror the legacy cloud config as the linked provider. + let linked = AIProvider( + id: linkedCloudID, + name: legacy.cloudBaseURL.contains("kimi") + ? "Kimi Code" + : (legacy.cloudBaseURL.contains("deepseek") ? "DeepSeek" : "云端"), + kind: legacy.cloudProtocol == .anthropic ? .anthropic : .openAI, + baseURL: legacy.cloudBaseURL, + model: legacy.cloudModel, + keychainAccount: AIProviderSettings.apiKeyAccount + ) + if let index = registry.providers.firstIndex(where: { $0.id == linkedCloudID }) { + registry.providers[index] = linked + } else { + registry.providers.append(linked) + } + + if !registry.providers.contains(where: { $0.id == registry.defaultProviderID }) { + registry.defaultProviderID = localID + } + return registry + } + + func save(defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.storageKey) + } + + // MARK: Routing + + func provider(id: UUID) -> AIProvider? { + providers.first { $0.id == id } + } + + /// The provider a feature routes to (explicit route → default). + func provider(for feature: AIFeature) -> AIProvider { + if let routed = routes[feature.rawValue], let provider = provider(id: routed) { + return provider + } + return provider(id: defaultProviderID) ?? Self.localProvider + } + + mutating func route(_ feature: AIFeature, to providerID: UUID?) { + if let providerID { + routes[feature.rawValue] = providerID + } else { + routes.removeValue(forKey: feature.rawValue) + } + } + + // MARK: Services + + @MainActor + func service(for provider: AIProvider, apiKey: String? = nil) -> any AIService { + switch provider.kind { + case .local: + return FoundationModelsAIService() + case .openAI: + return CloudAIService( + configuration: CloudAIService.Configuration( + baseURLString: provider.baseURL, + model: provider.model, + apiKey: apiKey + ?? KeychainStore.read(account: provider.keychainAccount) + ?? "" + ) + ) + case .anthropic: + return AnthropicAIService( + configuration: AnthropicAIService.Configuration( + baseURLString: provider.baseURL, + model: provider.model, + apiKey: apiKey + ?? KeychainStore.read(account: provider.keychainAccount) + ?? "" + ) + ) + } + } + + /// Resolves a feature through the fallback chain: routed provider → + /// default provider → local. When none can serve, returns the routed + /// provider's service so its unavailability reason surfaces. + @MainActor + func resolveUsableService( + feature: AIFeature, + apiKey: String? = nil + ) -> (service: any AIService, provider: AIProvider, fellBack: Bool) { + let routed = provider(for: feature) + var chain: [AIProvider] = [routed] + if let fallback = provider(id: defaultProviderID), fallback.id != routed.id { + chain.append(fallback) + } + if !chain.contains(where: { $0.isLocal }) { + chain.append(provider(id: Self.localID) ?? Self.localProvider) + } + + for (index, candidate) in chain.enumerated() { + let service = service(for: candidate, apiKey: apiKey) + if service.availability.isAvailable { + return (service, candidate, index > 0) + } + } + return (service(for: routed, apiKey: apiKey), routed, false) + } +} diff --git a/Empty/Services/BookExporter.swift b/Empty/Services/BookExporter.swift new file mode 100644 index 0000000..d40cb1d --- /dev/null +++ b/Empty/Services/BookExporter.swift @@ -0,0 +1,141 @@ +// +// BookExporter.swift +// Empty +// +// P1 导出: highlights / notes / bookmarks as Markdown or plain text, +// every entry carrying an empty:// deep link back to its exact place +// in the book (book id + TextAnchor). +// + +import Foundation +import SwiftData + +/// `empty://book/?c=&o=` — the stable +/// reference format exported notes carry back to the original passage. +nonisolated enum EmptyDeepLink { + static func urlString(bookID: UUID, chapterIndex: Int, utf16Offset: Int) -> String { + "empty://book/\(bookID.uuidString)?c=\(chapterIndex)&o=\(utf16Offset)" + } + + static func parse(_ url: URL) -> (bookID: UUID, position: ReadingPosition)? { + guard url.scheme == "empty", url.host == "book" else { return nil } + let idPart = url.lastPathComponent + guard let bookID = UUID(uuidString: idPart) else { return nil } + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let chapter = items.first { $0.name == "c" }?.value.flatMap(Int.init) ?? 0 + let offset = items.first { $0.name == "o" }?.value.flatMap(Int.init) ?? 0 + return (bookID, ReadingPosition(chapterIndex: chapter, utf16Offset: offset)) + } +} + +nonisolated struct BookExportOptions: Equatable { + enum Format: String, CaseIterable { + case markdown = "Markdown" + case plainText = "纯文本" + } + + var includeHighlights = true + var includeNotes = true + var includeBookmarks = true + var format: Format = .markdown +} + +/// Renders a book's reader data as a shareable document. +@MainActor +struct BookExporter { + let modelContext: ModelContext + + func export(book: Book, options: BookExportOptions) throws -> String { + let highlights = options.includeHighlights + ? try HighlightStore(modelContext: modelContext).highlights(for: book) + : [] + let bookmarks = options.includeBookmarks + ? try BookmarkStore(modelContext: modelContext).bookmarks(for: book) + : [] + let titles = try chapterTitles(book: book) + let markdown = options.format == .markdown + + var lines: [String] = [] + if markdown { + lines.append("# \(book.title) · 摘录") + if !book.author.isEmpty { lines.append("_\(book.author)_") } + } else { + lines.append("\(book.title) · 摘录") + if !book.author.isEmpty { lines.append(book.author) } + } + lines.append("") + + let chapterIndexes = Set(highlights.map(\.chapterIndex)) + .union(bookmarks.map(\.chapterIndex)) + .sorted() + + for chapterIndex in chapterIndexes { + let title = titles[chapterIndex] ?? "第 \(chapterIndex + 1) 章" + lines.append(markdown ? "## \(title)" : "◇ \(title)") + lines.append("") + + for highlight in highlights where highlight.chapterIndex == chapterIndex { + let link = EmptyDeepLink.urlString( + bookID: book.id, + chapterIndex: highlight.chapterIndex, + utf16Offset: highlight.startUTF16 + ) + if markdown { + lines.append("> \(highlight.textSnapshot)") + if options.includeNotes, + let note = highlight.note, !note.isEmpty { + lines.append(">") + lines.append("> 批注:\(note)") + } + lines.append("") + lines.append("[回到原文](\(link))") + } else { + lines.append("“\(highlight.textSnapshot)”") + if options.includeNotes, + let note = highlight.note, !note.isEmpty { + lines.append("批注:\(note)") + } + lines.append(link) + } + lines.append("") + } + + for bookmark in bookmarks where bookmark.chapterIndex == chapterIndex { + let link = EmptyDeepLink.urlString( + bookID: book.id, + chapterIndex: bookmark.chapterIndex, + utf16Offset: bookmark.utf16Offset + ) + if markdown { + lines.append("- 🔖 \(bookmark.snippet) — [回到原文](\(link))") + } else { + lines.append("🔖 \(bookmark.snippet)") + lines.append(link) + } + lines.append("") + } + } + + if chapterIndexes.isEmpty { + lines.append(markdown ? "_这本书还没有摘录。_" : "这本书还没有摘录。") + } + return lines.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + private func chapterTitles(book: Book) throws -> [Int: String] { + let bookID = book.id + let chapters = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.bookID == bookID } + )) + return Dictionary( + uniqueKeysWithValues: chapters.compactMap { chapter -> (Int, String)? in + guard let title = chapter.title, + !title.trimmingCharacters(in: .whitespaces).isEmpty else { + return nil + } + return (chapter.index, title) + } + ) + } +} diff --git a/Empty/Services/BookmarkStore.swift b/Empty/Services/BookmarkStore.swift new file mode 100644 index 0000000..a915e1a --- /dev/null +++ b/Empty/Services/BookmarkStore.swift @@ -0,0 +1,135 @@ +// +// BookmarkStore.swift +// Empty +// + +import Foundation +import SwiftData + +/// Creates, lists, toggles, and deletes reader bookmarks (P0 drawer). +@MainActor +struct BookmarkStore { + let modelContext: ModelContext + + /// Bookmarks of `book` in reading order. + func bookmarks(for book: Book) throws -> [Bookmark] { + let bookID = book.id + return try modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.book?.id == bookID }, + sortBy: [ + SortDescriptor(\.chapterIndex), + SortDescriptor(\.utf16Offset), + ] + ) + ) + } + + /// Adds a bookmark at the position, or removes the existing one nearby + /// (within ~a page) — the ⌘D toggle the prototype shows. + /// Returns true when a bookmark was added. + @discardableResult + func toggle( + book: Book, + chapterIndex: Int, + utf16Offset: Int, + snippet: String + ) throws -> Bool { + let existing = try bookmarks(for: book).first { + $0.chapterIndex == chapterIndex + && abs($0.utf16Offset - utf16Offset) < 600 + } + if let existing { + modelContext.delete(existing) + try modelContext.save() + return false + } + let bookmark = Bookmark( + chapterIndex: chapterIndex, + utf16Offset: utf16Offset, + snippet: String(snippet.prefix(80)) + ) + modelContext.insert(bookmark) + bookmark.book = book + try modelContext.save() + return true + } + + func delete(_ bookmark: Bookmark) throws { + modelContext.delete(bookmark) + try modelContext.save() + } +} + +/// One full-text search hit inside a book. +nonisolated struct BookSearchHit: Identifiable, Equatable { + var chapterIndex: Int + var utf16Offset: Int + var snippet: String + + var id: String { "\(chapterIndex)-\(utf16Offset)" } +} + +/// Local full-text search over a book's chapters, grouped for the +/// spoiler-safe drawer: hits in unread chapters fold behind a count. +@MainActor +struct BookTextSearch { + let modelContext: ModelContext + + func search( + book: Book, + query: String, + maxReadChapter: Int, + limitPerChapter: Int = 8 + ) throws -> (read: [BookSearchHit], unread: [BookSearchHit]) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 2 else { return ([], []) } + let bookID = book.id + let chapters = try modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.bookID == bookID }, + sortBy: [SortDescriptor(\.index)] + ) + ) + + var read: [BookSearchHit] = [] + var unread: [BookSearchHit] = [] + for chapter in chapters { + var found = 0 + var searchStart = chapter.text.startIndex + while found < limitPerChapter, + let range = chapter.text.range( + of: trimmed, + options: [.caseInsensitive], + range: searchStart.. String { + text.applyingTransform(hansToHant, reverse: false) ?? text + } +} diff --git a/Empty/Services/HighlightStore.swift b/Empty/Services/HighlightStore.swift index 55261eb..7901ad4 100644 --- a/Empty/Services/HighlightStore.swift +++ b/Empty/Services/HighlightStore.swift @@ -23,7 +23,8 @@ struct HighlightStore { chapterIndex: Int, selection: String, prefix: String = "", - suffix: String = "" + suffix: String = "", + color: HighlightColor = .yellow ) throws -> Highlight { let bookID = book.id let chapter = try modelContext.fetch( @@ -49,7 +50,8 @@ struct HighlightStore { startUTF16: range.lowerBound, endUTF16: range.upperBound ), - textSnapshot: selection.trimmingCharacters(in: .whitespacesAndNewlines) + textSnapshot: selection.trimmingCharacters(in: .whitespacesAndNewlines), + color: color ) modelContext.insert(highlight) highlight.book = book @@ -78,6 +80,12 @@ struct HighlightStore { ) } + func updateNote(_ highlight: Highlight, note: String?) throws { + let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) + highlight.note = trimmed?.isEmpty == false ? trimmed : nil + try modelContext.save() + } + func delete(_ highlight: Highlight) throws { modelContext.delete(highlight) try modelContext.save() diff --git a/Empty/Services/NativeChapterDocument.swift b/Empty/Services/NativeChapterDocument.swift new file mode 100644 index 0000000..a0ccea1 --- /dev/null +++ b/Empty/Services/NativeChapterDocument.swift @@ -0,0 +1,866 @@ +// +// NativeChapterDocument.swift +// Empty +// + +import Foundation + +/// A small native reading model extracted from EPUB XHTML. +/// +/// EPUB content is still HTML, but the reader does not need a browser for the +/// common long-form path: headings, paragraphs, quotes, list items, and images +/// are enough to build a stable SwiftUI scroll view. Complex markup can keep +/// flowing through the existing WebView fallback later if needed. +nonisolated struct NativeChapterDocument: Equatable { + var blocks: [NativeChapterBlock] + + var textBlocks: [NativeChapterBlock] { + blocks.filter(\.isReadableText) + } + + var plainText: String { + textBlocks.map(\.text).joined(separator: "\n") + } + + func blockIDForLanding( + _ landing: ChapterLanding, + resumeUTF16Offset: Int, + chapterPlainText: String? + ) -> String? { + switch landing { + case .end: + return textBlocks.last?.id ?? blocks.last?.id + case .start: + guard resumeUTF16Offset > 0, + let chapterPlainText, + !chapterPlainText.isEmpty else { + return textBlocks.first?.id ?? blocks.first?.id + } + var prefix = "" + var best = textBlocks.first?.id + for block in textBlocks { + let offset = PlainTextSearch.utf16Offset( + afterNormalizedPrefix: prefix, + in: chapterPlainText + ) + if offset <= resumeUTF16Offset { + best = block.id + } else { + break + } + prefix = prefix.isEmpty ? block.text : prefix + "\n" + block.text + } + return best + } + } + + func prefix(before blockID: String) -> String { + var parts: [String] = [] + for block in textBlocks { + guard block.id != blockID else { break } + parts.append(block.text) + } + return parts.joined(separator: "\n") + } + + func resolvedTextSpans(in chapterPlainText: String?) -> [String: NativeTextBlockSpan] { + let readable = textBlocks + guard !readable.isEmpty else { return [:] } + + let source = textSource(prefer: chapterPlainText) + let chapterUTF16Count = source.utf16.count + var spans: [String: NativeTextBlockSpan] = [:] + var prefix = "" + var runningOffset = 0 + + for index in readable.indices { + let block = readable[index] + let suffixContext = readable[index...] + .dropFirst() + .prefix(2) + .map(\.text) + .joined(separator: "\n") + let prefixContext = String(prefix.suffix(240)) + let suffixSnippet = String(suffixContext.prefix(240)) + + var range = PlainTextSearch.utf16Range( + of: block.text, + prefix: prefixContext, + suffix: suffixSnippet, + in: source + ) + + if range == nil { + let start = PlainTextSearch.utf16Offset( + afterNormalizedPrefix: prefix, + in: source + ) + let prefixWithCurrent = prefix.isEmpty ? block.text : prefix + "\n" + block.text + let end = PlainTextSearch.utf16Offset( + afterNormalizedPrefix: prefixWithCurrent, + in: source + ) + if end > start { + range = start.., + chapterPlainText: String?, + spans: [String: NativeTextBlockSpan] + ) -> ReaderSelection? { + guard let block = textBlocks.first(where: { $0.id == blockID }), + let span = spans[blockID] else { + return nil + } + + let blockUTF16Count = block.text.utf16.count + let clampedLower = max(0, min(localUTF16Range.lowerBound, blockUTF16Count)) + let clampedUpper = max(clampedLower, min(localUTF16Range.upperBound, blockUTF16Count)) + guard clampedUpper > clampedLower else { return nil } + + let source = textSource(prefer: chapterPlainText) + let absoluteLower = span.chapterRange.lowerBound + clampedLower + let absoluteUpper = min(span.chapterRange.lowerBound + clampedUpper, source.utf16.count) + guard absoluteUpper > absoluteLower else { return nil } + return ReaderSelectionContext.selection( + in: source, + utf16Range: absoluteLower.. String { + guard let chapterPlainText, !chapterPlainText.isEmpty else { return plainText } + return chapterPlainText + } +} + +nonisolated struct NativeTextBlockSpan: Equatable { + var blockID: String + var chapterRange: Range + var paragraphInfo: ReaderParagraph? + + func localRange(intersecting absoluteRange: Range) -> Range? { + let lower = max(chapterRange.lowerBound, absoluteRange.lowerBound) + let upper = min(chapterRange.upperBound, absoluteRange.upperBound) + guard upper > lower else { return nil } + return (lower - chapterRange.lowerBound)..<(upper - chapterRange.lowerBound) + } + + func localProgress(for absoluteUTF16Offset: Int) -> CGFloat { + let length = max(chapterRange.upperBound - chapterRange.lowerBound, 1) + let local = max(0, min(absoluteUTF16Offset - chapterRange.lowerBound, length - 1)) + return CGFloat(local) / CGFloat(length) + } +} + +nonisolated enum ReaderSelectionContext { + static func selection( + in source: String, + utf16Range: Range, + contextWindow: Int = 180 + ) -> ReaderSelection? { + let sourceUTF16Count = source.utf16.count + let lower = max(0, min(utf16Range.lowerBound, sourceUTF16Count)) + let upper = max(lower, min(utf16Range.upperBound, sourceUTF16Count)) + guard upper > lower else { return nil } + + let selectionText = utf16Slice(source, range: lower.. Range? { + PlainTextSearch.utf16Range( + of: selection.text, + prefix: selection.prefix, + suffix: selection.suffix, + in: source + ) + } +} +nonisolated enum NativeChapterBlock: Equatable, Identifiable { + case heading(id: String, level: Int, text: String) + case paragraph(id: String, paragraphIndex: Int, text: String) + case quote(id: String, paragraphIndex: Int, text: String) + case listItem(id: String, paragraphIndex: Int, text: String, level: Int, marker: String) + case footnote(id: String, paragraphIndex: Int, text: String) + case code(id: String, text: String) + case table(id: String, rows: [[String]]) + case image(id: String, source: String, alt: String?) + + var id: String { + switch self { + case .heading(let id, _, _), + .paragraph(let id, _, _), + .quote(let id, _, _), + .footnote(let id, _, _), + .code(let id, _), + .image(let id, _, _): + id + case .listItem(let id, _, _, _, _): + id + case .table(let id, _): + id + } + } + + var text: String { + switch self { + case .heading(_, _, let text), + .paragraph(_, _, let text), + .quote(_, _, let text), + .footnote(_, _, let text), + .code(_, let text): + text + case .listItem(_, _, let text, _, _): + text + case .image(_, _, let alt): + alt ?? "" + case .table: + "" + } + } + + var readerParagraph: ReaderParagraph? { + switch self { + case .paragraph(_, let index, let text), + .quote(_, let index, let text), + .footnote(_, let index, let text): + ReaderParagraph(idx: index, text: text) + case .listItem(_, let index, let text, _, _): + ReaderParagraph(idx: index, text: text) + case .heading, .image, .code, .table: + nil + } + } + + var isReadableText: Bool { + switch self { + case .heading, .paragraph, .quote, .listItem, .footnote, .code: + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .image, .table: + return false + } + } +} + +/// Parses EPUB XHTML into `NativeChapterDocument` without executing script or +/// relying on WebKit layout. EPUB spine documents are XHTML, so XMLParser is a +/// reliable fast path for normal books; on malformed input we fall back to the +/// existing plain-text extraction instead of yielding an empty reader. +/// +/// Results memoize: reader views re-init on every parent render (notes +/// arriving, chrome fades), and re-parsing a whole chapter each time is +/// what made the app feel slow. +nonisolated enum NativeChapterParser { + private final class DocumentBox { + let document: NativeChapterDocument + init(_ document: NativeChapterDocument) { self.document = document } + } + + private final class SpansBox { + let spans: [String: NativeTextBlockSpan] + init(_ spans: [String: NativeTextBlockSpan]) { self.spans = spans } + } + + // NSCache is internally thread-safe. + nonisolated(unsafe) private static let documentCache = NSCache() + nonisolated(unsafe) private static let spansCache = NSCache() + + /// Distinguishes chapters cheaply without hashing megabytes: href + + /// length + a content tail (catches the 繁体 conversion, whose length + /// is often identical). + private static func cacheKey(_ chapter: EPUBChapter) -> String { + "\(chapter.href)|\(chapter.content.utf16.count)|\(chapter.content.suffix(24))" + } + + static func parse(_ chapter: EPUBChapter) -> NativeChapterDocument { + let key = cacheKey(chapter) as NSString + if let hit = documentCache.object(forKey: key) { + return hit.document + } + let document = parseUncached(chapter) + documentCache.setObject(DocumentBox(document), forKey: key) + return document + } + + /// Memoized `NativeChapterDocument.resolvedTextSpans` — the per-block + /// chapter-offset resolution is the other per-render hot spot. + static func resolvedSpans( + for chapter: EPUBChapter, + document: NativeChapterDocument, + chapterPlainText: String? + ) -> [String: NativeTextBlockSpan] { + let key = "\(cacheKey(chapter))|\(chapterPlainText?.utf16.count ?? -1)" as NSString + if let hit = spansCache.object(forKey: key) { + return hit.spans + } + let spans = document.resolvedTextSpans(in: chapterPlainText) + spansCache.setObject(SpansBox(spans), forKey: key) + return spans + } + + private static func parseUncached(_ chapter: EPUBChapter) -> NativeChapterDocument { + let parser = NativeChapterXMLParser(chapterHref: chapter.href) + if let document = parser.parse(chapter.content), !document.blocks.isEmpty { + return document + } + + let fallback = fallbackText(from: chapter.content) + let blocks = fallback + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .enumerated() + .map { offset, text in + NativeChapterBlock.paragraph( + id: "fallback-p-\(offset)", + paragraphIndex: offset, + text: text + ) + } + return NativeChapterDocument(blocks: blocks) + } + + private static func fallbackText(from html: String) -> String { + let openedBlocksMarked = html.replacingOccurrences( + of: "(?i)<(p|h1|h2|h3|h4|h5|h6|blockquote|li|div|section|article|tr|pre)\\b", + with: "\n<$1", + options: .regularExpression + ) + return HTMLPlainText.extract(from: openedBlocksMarked) + } +} + +private nonisolated final class NativeChapterXMLParser: NSObject, XMLParserDelegate { + private struct Draft { + var tag: String + var id: String + var kind: Kind + var text = "" + var listLevel = 1 + var listMarker = "•" + var isFootnote = false + } + + private enum Kind { + case heading(level: Int) + case paragraph + case quote + case listItem + case code + case caption + } + + private let chapterHref: String + private var blocks: [NativeChapterBlock] = [] + private var current: Draft? + private var currentBlockDepth = 0 + private var skipDepth = 0 + private var paragraphIndex = 0 + private var blockOrdinal = 0 + + /// Text that appears directly inside containers (div/section/article…) + /// rather than in a recognized block tag — many EPUBs paragraph with + /// bare
s. Flushed into implicit paragraphs at container/block + /// boundaries; `\u{2028}` marks
so source-formatting newlines + /// don't split paragraphs. + private var strayText = "" + private var listStack: [(ordered: Bool, count: Int)] = [] + private var asideStack: [Bool] = [] + private var footnoteDepth = 0 + private var tableDepth = 0 + private var tableRows: [[String]] = [] + private var tableRow: [String]? + private var tableCell: String? + private var tableID = "" + private var figureDepth = 0 + private var figureImageIndex: Int? + private var figureCaption: String? + + init(chapterHref: String) { + self.chapterHref = chapterHref + } + + func parse(_ html: String) -> NativeChapterDocument? { + guard let data = sanitize(html).data(using: .utf8) else { return nil } + let xmlParser = XMLParser(data: data) + xmlParser.delegate = self + guard xmlParser.parse() else { return nil } + finishCurrentBlock() + if tableDepth > 0 { + tableDepth = 0 + flushTable() + } + flushStray() + return NativeChapterDocument(blocks: blocks) + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let tag = localName(elementName) + if skipDepth > 0 || Self.skippedTags.contains(tag) { + skipDepth += 1 + return + } + + if tableDepth > 0 { + handleTableStart(tag, attributes: attributeDict) + return + } + + switch tag { + case "br": + if current != nil { + appendText("\n") + } else { + strayText += "\u{2028}" + } + return + case "img", "image": + appendImage(attributeDict) + return + case "table": + flushStray() + finishCurrentBlock() + tableDepth = 1 + tableRows = [] + tableRow = nil + tableCell = nil + tableID = attributeDict["id"] ?? generatedID(prefix: "table") + return + case "ul", "ol": + if let kind = current?.kind, case .listItem = kind { + finishCurrentBlock() + } + flushStray() + listStack.append((ordered: tag == "ol", count: 0)) + return + case "aside": + finishCurrentBlock() + flushStray() + let epubType = (attributeDict["epub:type"] ?? attributeDict["type"] ?? "").lowercased() + let isFootnote = epubType.contains("note") + asideStack.append(isFootnote) + if isFootnote { footnoteDepth += 1 } + return + case "figure": + flushStray() + figureDepth += 1 + figureImageIndex = nil + figureCaption = nil + return + default: + break + } + + guard let kind = Self.kind(for: tag) else { return } + flushStray() + if case .listItem = kind, let openKind = current?.kind, case .listItem = openKind { + finishCurrentBlock() + } + if current == nil { + var draft = Draft( + tag: tag, + id: attributeDict["id"] ?? generatedID(prefix: tag), + kind: kind + ) + if case .listItem = kind { + if !listStack.isEmpty { + listStack[listStack.count - 1].count += 1 + } + draft.listLevel = max(listStack.count, 1) + let ordered = listStack.last?.ordered ?? false + draft.listMarker = ordered ? "\(listStack.last?.count ?? 1)." : "•" + } + draft.isFootnote = footnoteDepth > 0 + current = draft + currentBlockDepth = 1 + } else { + currentBlockDepth += 1 + appendText("\n") + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let tag = localName(elementName) + if skipDepth > 0 { + skipDepth -= 1 + return + } + + if tableDepth > 0 { + handleTableEnd(tag) + return + } + + switch tag { + case "ul", "ol": + if let kind = current?.kind, case .listItem = kind { + finishCurrentBlock() + } + if current == nil { flushStray() } + if !listStack.isEmpty { listStack.removeLast() } + return + case "aside": + if current == nil { flushStray() } + if let counted = asideStack.popLast(), counted { + footnoteDepth = max(0, footnoteDepth - 1) + } + return + case "figure": + if current == nil { flushStray() } + if let caption = figureCaption, figureImageIndex == nil { + appendFootnoteBlock(caption, id: generatedID(prefix: "caption")) + } + figureDepth = max(0, figureDepth - 1) + figureImageIndex = nil + figureCaption = nil + return + case "div", "section", "article", "body", "main", "header", "footer", "dl": + if current == nil { flushStray() } + return + default: + break + } + + guard current != nil, Self.kind(for: tag) != nil else { return } + currentBlockDepth -= 1 + if currentBlockDepth <= 0 { + finishCurrentBlock() + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + appendCharacters(string) + } + + func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { + guard let string = String(data: CDATABlock, encoding: .utf8) else { return } + appendCharacters(string) + } + + private func appendCharacters(_ string: String) { + guard skipDepth == 0 else { return } + if tableDepth > 0 { + tableCell? += string + return + } + if current != nil { + current?.text += string + } else { + strayText += string + } + } + + private func appendText(_ text: String) { + guard skipDepth == 0, current != nil else { return } + current?.text += text + } + + private func appendImage(_ attributes: [String: String]) { + let source = attributes["src"] + ?? attributes["href"] + ?? attributes["xlink:href"] + guard let source, !source.isEmpty else { return } + var alt = attributes["alt"]?.trimmingCharacters(in: .whitespacesAndNewlines) + if current != nil, let inlineAlt = alt, !inlineAlt.isEmpty { + appendText(" " + inlineAlt + " ") + return + } + flushStray() + if figureDepth > 0, let caption = figureCaption, !caption.isEmpty { + alt = caption + figureCaption = nil + } + blocks.append(.image(id: generatedID(prefix: "img"), source: source, alt: alt)) + if figureDepth > 0 { + figureImageIndex = blocks.count - 1 + } + } + + private func flushStray() { + guard !strayText.isEmpty else { return } + let raw = strayText + strayText = "" + for line in raw.components(separatedBy: "\u{2028}") { + let text = normalize(line) + guard !text.isEmpty else { continue } + if footnoteDepth > 0 { + appendFootnoteBlock(text, id: generatedID(prefix: "note")) + } else { + blocks.append(.paragraph( + id: generatedID(prefix: "p"), + paragraphIndex: paragraphIndex, + text: text + )) + paragraphIndex += 1 + } + } + } + + private func appendFootnoteBlock(_ text: String, id: String) { + blocks.append(.footnote(id: id, paragraphIndex: paragraphIndex, text: text)) + paragraphIndex += 1 + } + + private func finishCurrentBlock() { + guard let draft = current else { return } + current = nil + currentBlockDepth = 0 + + if case .code = draft.kind { + let text = normalizeCode(draft.text) + guard !text.isEmpty else { return } + blocks.append(.code(id: draft.id, text: text)) + return + } + + let text = normalize(draft.text) + guard !text.isEmpty else { return } + + if draft.isFootnote { + switch draft.kind { + case .paragraph, .quote, .listItem: + appendFootnoteBlock(text, id: draft.id) + return + default: + break + } + } + + switch draft.kind { + case .heading(let level): + blocks.append(.heading(id: draft.id, level: level, text: text)) + case .paragraph: + blocks.append(.paragraph(id: draft.id, paragraphIndex: paragraphIndex, text: text)) + paragraphIndex += 1 + case .quote: + blocks.append(.quote(id: draft.id, paragraphIndex: paragraphIndex, text: text)) + paragraphIndex += 1 + case .listItem: + blocks.append(.listItem( + id: draft.id, + paragraphIndex: paragraphIndex, + text: text, + level: draft.listLevel, + marker: draft.listMarker + )) + paragraphIndex += 1 + case .code: + break + case .caption: + if figureDepth > 0, let index = figureImageIndex, + blocks.indices.contains(index), + case .image(let imageID, let source, _) = blocks[index] { + blocks[index] = .image(id: imageID, source: source, alt: text) + } else if figureDepth > 0 { + figureCaption = text + } else { + appendFootnoteBlock(text, id: draft.id) + } + } + } + + // MARK: Tables + + private func handleTableStart(_ tag: String, attributes: [String: String]) { + switch tag { + case "table": + tableDepth += 1 + case "tr" where tableDepth == 1: + tableRow = [] + case "td", "th", "caption": + if tableDepth == 1, tableCell == nil { + tableCell = "" + } else { + tableCell? += " " + } + case "br": + tableCell? += " " + case "img", "image": + let alt = attributes["alt"]?.trimmingCharacters(in: .whitespacesAndNewlines) + if let alt, !alt.isEmpty { + tableCell? += " \(alt) " + } + default: + if Self.kind(for: tag) != nil { + tableCell? += " " + } + } + } + + private func handleTableEnd(_ tag: String) { + switch tag { + case "table": + tableDepth -= 1 + if tableDepth == 0 { + flushTable() + } + case "caption" where tableDepth == 1: + if let cell = tableCell { + let text = normalize(cell) + if !text.isEmpty { + tableRows.append([text]) + } + } + tableCell = nil + case "td", "th": + guard tableDepth == 1, let cell = tableCell else { return } + tableRow?.append(normalize(cell)) + tableCell = nil + case "tr" where tableDepth == 1: + if let row = tableRow, row.contains(where: { !$0.isEmpty }) { + tableRows.append(row) + } + tableRow = nil + default: + break + } + } + + private func flushTable() { + defer { + tableRows = [] + tableRow = nil + tableCell = nil + } + let rows = tableRows.filter { !$0.isEmpty } + guard !rows.isEmpty else { return } + blocks.append(.table(id: tableID, rows: rows)) + } + + private func generatedID(prefix: String) -> String { + defer { blockOrdinal += 1 } + let chapter = chapterHref + .replacingOccurrences(of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression) + return "native-\(chapter)-\(prefix)-\(blockOrdinal)" + } + + private func localName(_ elementName: String) -> String { + (elementName.components(separatedBy: ":").last ?? elementName).lowercased() + } + + private func normalize(_ text: String) -> String { + HTMLPlainText.extract(from: text) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences( + of: "[ \\t\\r\\n\u{2028}]+", + with: " ", + options: .regularExpression + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Code keeps its line structure and inner indentation; only blank + /// edge lines and per-line trailing whitespace are dropped. + private func normalizeCode(_ text: String) -> String { + var lines = text + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "\r\n", with: "\n") + .components(separatedBy: "\n") + .map { line -> String in + var trimmed = line + while let last = trimmed.last, last == " " || last == "\t" { + trimmed.removeLast() + } + return trimmed + } + while lines.first?.isEmpty == true { lines.removeFirst() } + while lines.last?.isEmpty == true { lines.removeLast() } + return lines.joined(separator: "\n") + } + + private func sanitize(_ html: String) -> String { + var result = html + result = result.replacingOccurrences( + of: "(?is)]*>", + with: "", + options: .regularExpression + ) + let entities: [(String, String)] = [ + (" ", " "), + ("—", "—"), + ("–", "–"), + ("…", "…"), + ("'", "'") + ] + for (entity, replacement) in entities { + result = result.replacingOccurrences(of: entity, with: replacement) + } + return result + } + + private static let skippedTags: Set = [ + "head", "metadata", "nav", "script", "style" + ] + + private static func kind(for tag: String) -> Kind? { + switch tag { + case "h1": return .heading(level: 1) + case "h2": return .heading(level: 2) + case "h3": return .heading(level: 3) + case "h4": return .heading(level: 4) + case "h5": return .heading(level: 5) + case "h6": return .heading(level: 6) + case "p", "dt", "dd": return .paragraph + case "blockquote": return .quote + case "li": return .listItem + case "pre": return .code + case "figcaption": return .caption + default: return nil + } + } +} + +private nonisolated func utf16Slice(_ string: String, range: Range) -> String { + let utf16 = Array(string.utf16) + let lower = max(0, min(range.lowerBound, utf16.count)) + let upper = max(lower, min(range.upperBound, utf16.count)) + return String(decoding: utf16[lower.. +} + +/// Splits text into speakable sentences on CJK/Latin terminators and +/// newlines, preserving exact UTF-16 ranges for follow-along highlights. +nonisolated enum SpeechSentenceSplitter { + private static let terminators: Set = [ + "。", "!", "?", "…", "!", "?", ".", ";", ";", "\n", + ] + + static func split(_ text: String) -> [SpeechSentence] { + var sentences: [SpeechSentence] = [] + var sentenceStartUTF16 = 0 + var currentUTF16 = 0 + var current = "" + + func flush(endUTF16: Int) { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= 2 { + sentences.append(SpeechSentence( + text: trimmed, + utf16Range: sentenceStartUTF16.. Int { + guard !sentences.isEmpty else { return 0 } + for (index, sentence) in sentences.enumerated() + where utf16Offset < sentence.utf16Range.upperBound { + return index + } + return sentences.count - 1 + } +} -/// macOS text-to-speech for the reader's aloud bar. +/// Text-to-speech for the reader's aloud bar: sentence queue, rate +/// control, follow-along range publishing, and chapter hand-off. @MainActor final class ReadingAloud: NSObject, ObservableObject { @Published private(set) var isSpeaking = false @Published private(set) var currentSnippet = "" + /// The sentence being spoken, in chapter UTF-16 coordinates — the + /// reader paints this as the follow-along highlight. + @Published private(set) var currentSentenceRange: Range? + /// 0.75–1.5×, multiplied onto the system default rate. + @Published var rate: Double = 1.0 { + didSet { rate = min(max(rate, 0.75), 1.5) } + } + + /// Called after the queue's last sentence finishes (自动下一章). + var onQueueFinished: (() -> Void)? private let synthesizer = AVSpeechSynthesizer() + private var queue: [SpeechSentence] = [] + private var queueIndex = 0 + private var language = "en-US" override init() { super.init() synthesizer.delegate = self } - func speak(_ text: String, language: String = "en-US") { + /// Speaks `text` from the sentence containing `utf16Offset` (从当前 + /// 位置接着读). Empty/short tails fall back to the first sentence. + func speak(_ text: String, fromUTF16Offset utf16Offset: Int = 0, language: String? = nil) { stop() - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - currentSnippet = String(trimmed.prefix(48)) - let utterance = AVSpeechUtterance(string: trimmed) - utterance.voice = AVSpeechSynthesisVoice(language: language) - utterance.rate = AVSpeechUtteranceDefaultSpeechRate - synthesizer.speak(utterance) - isSpeaking = true + let sentences = SpeechSentenceSplitter.split(text) + guard !sentences.isEmpty else { return } + self.language = language ?? Self.detectLanguage(of: text) + queue = sentences + queueIndex = SpeechSentenceSplitter.sentenceIndex(at: utf16Offset, in: sentences) + speakCurrent() } func togglePause() { @@ -43,7 +119,39 @@ final class ReadingAloud: NSObject, ObservableObject { func stop() { synthesizer.stopSpeaking(at: .immediate) + queue = [] + queueIndex = 0 isSpeaking = false + currentSentenceRange = nil + currentSnippet = "" + } + + nonisolated static func detectLanguage(of text: String) -> String { + text.unicodeScalars.contains { (0x4E00...0x9FFF).contains($0.value) } + ? "zh-CN" + : "en-US" + } + + private func speakCurrent() { + guard queue.indices.contains(queueIndex) else { + let finished = onQueueFinished + stop() + finished?() + return + } + let sentence = queue[queueIndex] + currentSentenceRange = sentence.utf16Range + currentSnippet = String(sentence.text.prefix(48)) + let utterance = AVSpeechUtterance(string: sentence.text) + utterance.voice = AVSpeechSynthesisVoice(language: language) + utterance.rate = AVSpeechUtteranceDefaultSpeechRate * Float(rate) + synthesizer.speak(utterance) + isSpeaking = true + } + + private func advance() { + queueIndex += 1 + speakCurrent() } } @@ -53,7 +161,9 @@ extension ReadingAloud: AVSpeechSynthesizerDelegate { didFinish utterance: AVSpeechUtterance ) { Task { @MainActor in - isSpeaking = false + // `stop()` empties the queue; only a live queue advances. + guard !queue.isEmpty else { return } + advance() } } -} \ No newline at end of file +} diff --git a/Empty/Services/ReadingStats.swift b/Empty/Services/ReadingStats.swift new file mode 100644 index 0000000..736cdcd --- /dev/null +++ b/Empty/Services/ReadingStats.swift @@ -0,0 +1,161 @@ +// +// ReadingStats.swift +// Empty +// +// P1 阅读统计 per the handoff: time only counts while the reader is +// actually moving through text (scroll/page activity, idle gaps +// dropped), speed is a rolling average of characters per active +// minute, and the remaining-time estimate divides what's left of the +// book by that speed. Everything computes locally. +// + +import Foundation +import SwiftData + +/// Accumulates *effective* reading time from activity pings (position +/// reports, page turns). Gaps longer than `maxGap` don't count — leaving +/// the book open on the desk isn't reading. +@MainActor +final class ReadingActivityMeter { + /// The longest silence still considered "reading" (a slow page). + static let maxGap: TimeInterval = 90 + + private var lastActivity: Date? + private(set) var accumulated: TimeInterval = 0 + + func ping(now: Date = Date()) { + if let last = lastActivity { + let gap = now.timeIntervalSince(last) + if gap > 0 { + accumulated += min(gap, Self.maxGap) + } + } + lastActivity = now + } + + /// Returns and resets the accumulated active time (drained into + /// `ReadingSession.activeSeconds` on each progress save). + func drain() -> TimeInterval { + defer { accumulated = 0 } + return accumulated + } +} + +nonisolated struct ReadingDayStat: Equatable, Identifiable { + var day: Date + var minutes: Double + + var id: Date { day } +} + +/// Local aggregation over `ReadingSession` rows. +@MainActor +struct ReadingStatsStore { + let modelContext: ModelContext + var calendar: Calendar = .current + + /// A session's effective seconds: metered active time when present; + /// legacy sessions (recorded before metering) fall back to wall-clock + /// capped at two hours so an overnight forgotten window can't + /// dominate the chart. + nonisolated static func effectiveSeconds( + activeSeconds: Double, + startedAt: Date, + endedAt: Date? + ) -> Double { + if activeSeconds > 0 { return activeSeconds } + guard let endedAt else { return 0 } + return min(max(endedAt.timeIntervalSince(startedAt), 0), 2 * 3600) + } + + /// Per-day active minutes for the `days` ending on `endingOn` + /// (inclusive), zero-filled so charts get a full axis. + func dailyMinutes(days: Int, endingOn: Date = Date()) throws -> [ReadingDayStat] { + let end = calendar.startOfDay(for: endingOn) + guard let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) else { + return [] + } + let sessions = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.startedAt >= start } + )) + + var buckets: [Date: Double] = [:] + for session in sessions { + let day = calendar.startOfDay(for: session.startedAt) + buckets[day, default: 0] += Self.effectiveSeconds( + activeSeconds: session.activeSeconds, + startedAt: session.startedAt, + endedAt: session.endedAt + ) + } + + return (0.. Int { + let stats = try dailyMinutes(days: 60, endingOn: today).reversed() + var streak = 0 + for (index, stat) in stats.enumerated() { + if stat.minutes >= 1 { + streak += 1 + } else if index == 0 { + // No reading yet *today* — the streak survives until + // tomorrow; keep counting from yesterday. + continue + } else { + break + } + } + return streak + } + + /// Rolling average reading speed in UTF-16 characters per active + /// minute, from recent same-chapter sessions (cross-chapter spans + /// don't know the chapter lengths without joining the local store). + func averageCharsPerMinute(recentSessions: Int = 30) throws -> Double? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.startedAt, order: .reverse)] + ) + descriptor.fetchLimit = recentSessions * 3 + let sessions = try modelContext.fetch(descriptor) + + var totalChars = 0.0 + var totalMinutes = 0.0 + var counted = 0 + for session in sessions where counted < recentSessions { + guard session.endChapterIndex == session.startChapterIndex else { continue } + let chars = Double(session.endUTF16Offset - session.startUTF16Offset) + let minutes = Self.effectiveSeconds( + activeSeconds: session.activeSeconds, + startedAt: session.startedAt, + endedAt: session.endedAt + ) / 60 + guard chars > 40, minutes > 0.2 else { continue } + totalChars += chars + totalMinutes += minutes + counted += 1 + } + guard totalMinutes > 0 else { return nil } + return totalChars / totalMinutes + } + + /// Estimated minutes to finish `book` at the reader's measured speed + /// (nil without enough data — UI falls back to the fixed estimate). + func remainingMinutes(for book: Book) throws -> Double? { + guard let speed = try averageCharsPerMinute(), speed > 0 else { return nil } + let bookID = book.id + let chapters = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.bookID == bookID } + )) + guard !chapters.isEmpty else { return nil } + let totalChars = chapters.reduce(0.0) { $0 + Double($1.utf16Length) } + let remaining = totalChars * max(0, 1 - book.progressFraction) + return remaining / speed + } +} diff --git a/Empty/Services/ScreenshotSeeder.swift b/Empty/Services/ScreenshotSeeder.swift index d5bc47e..064a71f 100644 --- a/Empty/Services/ScreenshotSeeder.swift +++ b/Empty/Services/ScreenshotSeeder.swift @@ -10,11 +10,28 @@ import Foundation import SwiftData enum ScreenshotSeeder { + @discardableResult @MainActor - static func seedDemoBookIfNeeded(modelContext: ModelContext) throws { - guard ProcessInfo.processInfo.arguments.contains("-ScreenshotSeed") else { return } - guard try modelContext.fetchCount(FetchDescriptor()) == 0 else { return } + static func seedDemoBookIfNeeded(modelContext: ModelContext) throws -> Book? { + let args = ProcessInfo.processInfo.arguments + guard args.contains("-ScreenshotSeed") else { return nil } + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\Book.lastOpenedAt, order: .reverse)] + ) + let existing = try modelContext.fetch(descriptor) + let book = try existing.first(where: isDemoBook) ?? importDemoBook(modelContext: modelContext) + if args.contains("-ScreenshotSeedHighlight") { + try seedDemoHighlightIfNeeded(book: book, modelContext: modelContext) + } + return book + } + private static func isDemoBook(_ book: Book) -> Bool { + book.title == "思维之书" && book.author == "测试作者" + } + + @MainActor + private static func importDemoBook(modelContext: ModelContext) throws -> Book { let temp = FileManager.default.temporaryDirectory .appending(path: "EmptyScreenshot-\(UUID().uuidString)", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) @@ -24,9 +41,26 @@ enum ScreenshotSeeder { try DemoEPUB.data().write(to: epubURL) let store = try BookFileStore.makeDefault() - _ = try Library(modelContext: modelContext, fileStore: store) + return try Library(modelContext: modelContext, fileStore: store) .importBook(from: epubURL) } + + @MainActor + private static func seedDemoHighlightIfNeeded( + book: Book, + modelContext: ModelContext + ) throws { + let store = HighlightStore(modelContext: modelContext) + if try store.highlights(for: book).contains(where: { $0.textSnapshot.contains("深读始于空白") }) { + return + } + let highlight = try store.createHighlight( + book: book, + chapterIndex: 0, + selection: "深读始于空白" + ) + try store.updateNote(highlight, note: "第一条测试批注。") + } } // MARK: - Minimal EPUB bytes (mirrors EmptyTests fixture) @@ -52,21 +86,39 @@ private enum DemoEPUB { + + """ + // Long enough that the paged reader produces several pages — + // page-turn smoke tests and screenshots need real pagination. + let fillerParagraphs = (1...18).map { index in + """ +

第\(index)节。读书不是把字看完,而是让一段文字在心里站住。空白处\ + 才放得下批注,节奏慢下来,问题才会浮出。这一段是排版与翻页的演示文本,\ + 用来撑起完整的一页,再多读一行,就再翻一页。

+ """ + }.joined() let chapter = """ 第一章\ -

第一章

深读始于空白。导入一本书,朱批落在页边。

+

第一章

深读始于空白。导入一本书,朱批落在页边。

\ + \(fillerParagraphs)

第一章在此收束:合上扉页,故事才真正开始。

+ """ + let chapter2 = """ + 第二章\ +

第二章

第二章自此开始:换一页纸,换一种问法。

\ +

翻页跨过章节边界时,阅读器应当无缝接上,而不是把读者丢回开头。

""" return storedZIP(entries: [ ("mimetype", Data("application/epub+zip".utf8)), ("META-INF/container.xml", Data(containerXML.utf8)), ("OEBPS/content.opf", Data(opf.utf8)), ("OEBPS/ch1.xhtml", Data(chapter.utf8)), + ("OEBPS/ch2.xhtml", Data(chapter2.utf8)), ]) } diff --git a/Empty/Services/SelectionChangeDebouncer.swift b/Empty/Services/SelectionChangeDebouncer.swift new file mode 100644 index 0000000..127c058 --- /dev/null +++ b/Empty/Services/SelectionChangeDebouncer.swift @@ -0,0 +1,32 @@ +import Foundation + +@MainActor +final class SelectionChangeDebouncer { + private let delay: Duration + private var task: Task? + + init(delay: Duration = .milliseconds(180)) { + self.delay = delay + } + + func submit( + _ selection: Range?, + deliver: @escaping (Range?) -> Void + ) { + task?.cancel() + task = Task { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + deliver(selection) + } + } + + func cancel() { + task?.cancel() + task = nil + } + + deinit { + task?.cancel() + } +} diff --git a/Empty/Services/StateBoxes.swift b/Empty/Services/StateBoxes.swift new file mode 100644 index 0000000..ae1089d --- /dev/null +++ b/Empty/Services/StateBoxes.swift @@ -0,0 +1,33 @@ +// +// StateBoxes.swift +// Empty +// +// Reference boxes for values that live inside `@State` but must NOT +// invalidate the view when they change. Assigning a new value to a +// `@State var task: Task?` re-renders the whole view tree — fatal when +// it happens per mouse-move or per scroll frame; mutating a class's +// contents does not. +// + +import Foundation + +@MainActor +final class TaskBox { + var task: Task? + + func replace(_ newTask: Task?) { + task?.cancel() + task = newTask + } + + func cancel() { + task?.cancel() + task = nil + } +} + +/// Mutable dictionary cache held by reference (writes don't re-render). +@MainActor +final class DictionaryBox { + var values: [Key: Value] = [:] +} diff --git a/Empty/Services/StudyCardStore.swift b/Empty/Services/StudyCardStore.swift index 1050cf0..7d067f2 100644 --- a/Empty/Services/StudyCardStore.swift +++ b/Empty/Services/StudyCardStore.swift @@ -15,7 +15,7 @@ struct StudyCardStore { book: Book, maxCount: Int = 3 ) async throws -> [StudyCardEntry] { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .vocab) let cards = try await resolution.service.flashcards( from: highlight.textSnapshot, maxCount: maxCount diff --git a/Empty/Services/ThoughtLinkFinder.swift b/Empty/Services/ThoughtLinkFinder.swift index 69ac4ed..03e67aa 100644 --- a/Empty/Services/ThoughtLinkFinder.swift +++ b/Empty/Services/ThoughtLinkFinder.swift @@ -61,7 +61,7 @@ struct ThoughtLinkFinder { } func explainLink(_ link: ThoughtLink) async throws -> String { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) let question = """ Two passages from a reader's library may be thematically linked. \ In 2-3 Chinese sentences, explain why they connect. Start with \ diff --git a/Empty/Services/VocabStore.swift b/Empty/Services/VocabStore.swift index b3b60e9..3fbfca9 100644 --- a/Empty/Services/VocabStore.swift +++ b/Empty/Services/VocabStore.swift @@ -57,7 +57,7 @@ struct VocabStore { sentence: String, source: String ) async throws -> VocabEntry { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .vocab) let service = resolution.service let question = """ Explain the word "\(word)" as used in the sentence below. Reply in Chinese \ diff --git a/Empty/Views/AIDiagnosticsView.swift b/Empty/Views/AIDiagnosticsView.swift index 7eaf3c0..98a1f5f 100644 --- a/Empty/Views/AIDiagnosticsView.swift +++ b/Empty/Views/AIDiagnosticsView.swift @@ -15,6 +15,7 @@ struct AIDiagnosticsView: View { @Environment(\.emptyPalette) private var palette @State private var settings = AIProviderSettings.load() + @State private var registry = AIProviderRegistry.load() @State private var apiKey = KeychainStore.read(account: AIProviderSettings.apiKeyAccount) ?? "" @State private var sampleText = "" @@ -38,6 +39,9 @@ struct AIDiagnosticsView: View { statusRow + sectionLabel("按功能分配") + featureRouting + sectionLabel("连通性测试") testCard @@ -55,12 +59,88 @@ struct AIDiagnosticsView: View { #endif .onChange(of: settings) { _, newValue in newValue.save() + // The registry mirrors the legacy cloud config (linked entry) + // and the default provider follows the mode picker. + var next = AIProviderRegistry.load() + next.defaultProviderID = newValue.mode == .cloud + ? AIProviderRegistry.linkedCloudID + : AIProviderRegistry.localID + next.save() + registry = next } .onChange(of: apiKey) { _, newValue in persistAPIKey(newValue) } } + // MARK: 按功能分配 (feature → provider routing) + + /// The handoff's FeatureRoute: each feature's capsule cycles through + /// the provider list; unset features follow the默认 provider. + private var featureRouting: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(AIFeature.allCases, id: \.self) { feature in + HStack(spacing: 10) { + Text(feature.title) + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink2) + .frame(width: 92, alignment: .leading) + Button { + cycleRoute(for: feature) + } label: { + Text(routeLabel(for: feature)) + .font(.system(size: 11.5, weight: .bold)) + .foregroundStyle( + registry.routes[feature.rawValue] == nil + ? palette.ink3 : palette.accent + ) + .padding(.horizontal, 11) + .padding(.vertical, 5) + .background(palette.side, in: Capsule()) + .overlay( + Capsule().strokeBorder( + registry.routes[feature.rawValue] == nil + ? palette.line2 : palette.accentSoft2, + lineWidth: 1 + ) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + Spacer() + } + } + Text("点胶囊循环切换提供商;「默认」跟随上方选择的提供商。云端不可用时自动回退本机,正文阅读永不阻塞。") + .font(.system(size: 10.5)) + .foregroundStyle(palette.ink3) + } + .padding(14) + .emptyCard(palette, radius: 12) + } + + private func routeLabel(for feature: AIFeature) -> String { + guard let routed = registry.routes[feature.rawValue], + let provider = registry.provider(id: routed) else { + return "默认" + } + return provider.name + } + + private func cycleRoute(for feature: AIFeature) { + var next = registry + let current = next.routes[feature.rawValue] + if current == nil { + next.route(feature, to: next.providers.first?.id) + } else if let index = next.providers.firstIndex(where: { $0.id == current }), + index + 1 < next.providers.count { + next.route(feature, to: next.providers[index + 1].id) + } else { + next.route(feature, to: nil) + } + next.save() + registry = next + } + // MARK: Header private var header: some View { diff --git a/Empty/Views/BookExportView.swift b/Empty/Views/BookExportView.swift new file mode 100644 index 0000000..6935107 --- /dev/null +++ b/Empty/Views/BookExportView.swift @@ -0,0 +1,149 @@ +// +// BookExportView.swift +// Empty +// +// P1 导出: toggle 高亮/批注/书签, pick Markdown ⇄ 纯文本, watch the +// preview re-render live, copy in one tap. Every entry carries an +// empty:// link back to its exact passage. +// + +import SwiftUI + +struct BookExportView: View { + let book: Book + + @Environment(\.dismiss) private var dismiss + @Environment(\.emptyPalette) private var palette + @Environment(\.modelContext) private var modelContext + + @State private var options = BookExportOptions() + @State private var rendered = "" + @State private var copied = false + + var body: some View { + VStack(spacing: 0) { + header + Rectangle().fill(palette.line).frame(height: 1) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + toggleChip("高亮", isOn: $options.includeHighlights) + toggleChip("批注", isOn: $options.includeNotes) + toggleChip("书签", isOn: $options.includeBookmarks) + Spacer() + Picker("", selection: $options.format) { + ForEach(BookExportOptions.Format.allCases, id: \.self) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 170) + } + + ScrollView { + Text(rendered) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(palette.ink2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .textSelection(.enabled) + } + .background(palette.card, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(palette.line, lineWidth: 1) + ) + + HStack { + Text("每条都带 empty:// 回链,可跳回原文段落。") + .font(.system(size: 10.5)) + .foregroundStyle(palette.ink3) + Spacer() + Button { + copyRendered() + } label: { + Text(copied ? "已复制 ✓" : "复制全部") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(palette.accent, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(EdgeInsets(top: 14, leading: 18, bottom: 16, trailing: 18)) + } + .background(palette.window) + #if os(macOS) + .frame(minWidth: 520, minHeight: 560) + #endif + #if os(iOS) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + #endif + .task(id: options) { + render() + } + } + + private var header: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text("导出摘录") + .font(.system(size: 17, weight: .black, design: .serif)) + .foregroundStyle(palette.ink) + Text(book.title) + .font(.system(size: 11)) + .foregroundStyle(palette.ink3) + .lineLimit(1) + } + Spacer() + Button { + dismiss() + } label: { + Text("×") + .font(.system(size: 14)) + .foregroundStyle(palette.ink3) + .frame(width: 28, height: 28) + .background(palette.accentSoft, in: Circle()) + } + .buttonStyle(.plain) + } + .padding(EdgeInsets(top: 16, leading: 18, bottom: 12, trailing: 14)) + } + + private func toggleChip(_ title: String, isOn: Binding) -> some View { + Button { + isOn.wrappedValue.toggle() + } label: { + Text(title) + .font(.system(size: 12, weight: isOn.wrappedValue ? .bold : .regular)) + .foregroundStyle(isOn.wrappedValue ? palette.onAccent : palette.ink2) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background( + isOn.wrappedValue ? palette.accent : palette.side, + in: Capsule() + ) + } + .buttonStyle(.plain) + } + + private func render() { + rendered = (try? BookExporter(modelContext: modelContext) + .export(book: book, options: options)) ?? "" + copied = false + } + + private func copyRendered() { + #if os(iOS) + UIPasteboard.general.string = rendered + #elseif os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(rendered, forType: .string) + #endif + copied = true + } +} diff --git a/Empty/Views/CompanionModel.swift b/Empty/Views/CompanionModel.swift index 9d72ea7..e2044c7 100644 --- a/Empty/Views/CompanionModel.swift +++ b/Empty/Views/CompanionModel.swift @@ -75,7 +75,7 @@ final class CompanionModel { return } - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) // Agent first: the model decides which reading tools to // use. Any failure falls back to plain grounded RAG so the // companion never dead-ends. @@ -89,7 +89,7 @@ final class CompanionModel { let agent = ReadingAgent( toolbox: toolbox, service: resolution.service, - maxSteps: resolution.route == .onDevice ? 3 : 4 + maxSteps: resolution.provider.isLocal ? 3 : 4 ) let reply = try await agent.run(question: question) messages.append(Message( @@ -175,7 +175,7 @@ final class CompanionModel { Task { do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) let toolbox = ReadingToolbox( book: book, position: position, diff --git a/Empty/Views/HighlightsListView.swift b/Empty/Views/HighlightsListView.swift index 150946f..f02e580 100644 --- a/Empty/Views/HighlightsListView.swift +++ b/Empty/Views/HighlightsListView.swift @@ -7,11 +7,11 @@ import SwiftData import SwiftUI /// All highlights of one book, in reading order, as 朱批 quote rows. -/// Tapping jumps the reader to the highlight's chapter; context menu -/// deletes or generates AI flashcards. +/// Tapping jumps the reader to the exact anchored position; note editing, +/// deletion, and flashcard generation stay in-place. struct HighlightsListView: View { let book: Book - let onJump: (Int) -> Void + let onJump: (ReadingPosition) -> Void @Environment(\.dismiss) private var dismiss @Environment(\.emptyPalette) private var palette @@ -21,8 +21,12 @@ struct HighlightsListView: View { @State private var generatingHighlightID: UUID? @State private var statusMessage: String? @State private var showFlashcards = false + @State private var editingHighlight: Highlight? + @State private var noteDraft = "" + @State private var isSavingNote = false + @State private var showExport = false - init(book: Book, onJump: @escaping (Int) -> Void) { + init(book: Book, onJump: @escaping (ReadingPosition) -> Void) { self.book = book self.onJump = onJump let bookID = book.id @@ -88,8 +92,22 @@ struct HighlightsListView: View { .frame(minWidth: 480, minHeight: 420) #endif } + .sheet(isPresented: Binding( + get: { editingHighlight != nil }, + set: { if !$0 { editingHighlight = nil } } + )) { + if let editingHighlight { + HighlightNoteEditorSheet( + highlightText: editingHighlight.textSnapshot, + draft: $noteDraft, + isSaving: isSavingNote, + onCancel: { self.editingHighlight = nil }, + onSave: { saveNote(for: editingHighlight) } + ) + } + } .alert( - "闪卡", + "高亮", isPresented: Binding( get: { statusMessage != nil }, set: { if !$0 { statusMessage = nil } } @@ -107,11 +125,25 @@ struct HighlightsListView: View { Text("高亮 · 朱批") .font(.system(size: 17, weight: .black, design: .serif)) .foregroundStyle(palette.ink) - Text("共 \(highlights.count) 条 · 点击跳转到所在章") + Text("共 \(highlights.count) 条 · 点击跳回高亮位置") .font(.system(size: 11)) .foregroundStyle(palette.ink3) } Spacer() + Button { + showExport = true + } label: { + Text("导出") + .font(.system(size: 11.5)) + .foregroundStyle(palette.ink2) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .overlay(Capsule().strokeBorder(palette.line2, lineWidth: 1)) + } + .buttonStyle(.plain) + .sheet(isPresented: $showExport) { + BookExportView(book: book) + } Button { showFlashcards = true } label: { @@ -138,10 +170,7 @@ struct HighlightsListView: View { } private func row(_ highlight: Highlight) -> some View { - Button { - onJump(highlight.chapterIndex) - dismiss() - } label: { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 2) .fill(palette.highlight) @@ -150,10 +179,10 @@ struct HighlightsListView: View { Text("\u{201C}\(highlight.textSnapshot)\u{201D}") .font(.system(size: 13, design: .serif)) .lineSpacing(5) - .lineLimit(3) + .lineLimit(4) .foregroundStyle(palette.ink) HStack(spacing: 8) { - Text("第 \(highlight.chapterIndex + 1) 章") + Text(positionLine(for: highlight)) .font(.system(size: 10.5)) .foregroundStyle(palette.ink3) if generatingHighlightID == highlight.id { @@ -167,20 +196,47 @@ struct HighlightsListView: View { } } if let note = highlight.note, !note.isEmpty { - Text("你的批注:\(note)") + Text("你的批注:\(note)") .font(.system(size: 11.5)) .foregroundStyle(palette.ink3) - .lineLimit(2) + .lineLimit(3) } } Spacer(minLength: 0) } - .padding(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14)) - .emptyCard(palette, radius: 12) - .contentShape(RoundedRectangle(cornerRadius: 12)) + + HStack(spacing: 10) { + actionButton("跳回原文", systemImage: "arrow.turn.up.backward") { + jump(to: highlight) + } + actionButton( + highlight.note?.isEmpty == false ? "编辑批注" : "写批注", + systemImage: "square.and.pencil" + ) { + startEditing(highlight) + } + actionButton("生成闪卡", systemImage: "rectangle.on.rectangle.angled") { + Task { await generateFlashcards(from: highlight) } + } + } + } + .padding(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14)) + .emptyCard(palette, radius: 12) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { + jump(to: highlight) } - .buttonStyle(.plain) .contextMenu { + Button { + jump(to: highlight) + } label: { + Label("跳回原文", systemImage: "arrow.turn.up.backward") + } + Button { + startEditing(highlight) + } label: { + Label(highlight.note?.isEmpty == false ? "编辑批注" : "写批注", systemImage: "square.and.pencil") + } Button { Task { await generateFlashcards(from: highlight) } } label: { @@ -192,6 +248,49 @@ struct HighlightsListView: View { } } + private func actionButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(palette.ink2) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(palette.side, in: Capsule()) + } + .buttonStyle(.plain) + } + + private func positionLine(for highlight: Highlight) -> String { + if highlight.endUTF16 > highlight.startUTF16 { + return "第 \(highlight.chapterIndex + 1) 章 · 已定位" + } + return "第 \(highlight.chapterIndex + 1) 章 · 仅文本快照" + } + + private func jump(to highlight: Highlight) { + onJump(ReadingPosition(chapterIndex: highlight.chapterIndex, utf16Offset: highlight.startUTF16)) + dismiss() + } + + private func startEditing(_ highlight: Highlight) { + editingHighlight = highlight + noteDraft = highlight.note ?? "" + } + + private func saveNote(for highlight: Highlight) { + isSavingNote = true + defer { isSavingNote = false } + do { + try HighlightStore(modelContext: modelContext).updateNote( + highlight, + note: noteDraft + ) + editingHighlight = nil + } catch { + statusMessage = error.localizedDescription + } + } + private var emptyState: some View { VStack(spacing: 10) { EnsoMark(size: 44) @@ -229,3 +328,80 @@ struct HighlightsListView: View { try? modelContext.save() } } + +private struct HighlightNoteEditorSheet: View { + let highlightText: String + @Binding var draft: String + let isSaving: Bool + let onCancel: () -> Void + let onSave: () -> Void + + @Environment(\.emptyPalette) private var palette + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("批注") + .font(.system(size: 18, weight: .bold, design: .serif)) + .foregroundStyle(palette.ink) + Text("给这条高亮补一段你的想法。") + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink3) + } + Spacer() + } + + Text("\u{201C}\(highlightText)\u{201D}") + .font(.system(size: 13, design: .serif)) + .foregroundStyle(palette.ink2) + .lineLimit(4) + .padding(12) + .background(palette.side, in: RoundedRectangle(cornerRadius: 12)) + + TextEditor(text: $draft) + .font(.system(size: 14, design: .serif)) + .scrollContentBackground(.hidden) + .padding(10) + .frame(minHeight: 180) + .background(palette.window, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(palette.line2, lineWidth: 1) + ) + + HStack(spacing: 10) { + Button("取消", action: onCancel) + .buttonStyle(.plain) + .foregroundStyle(palette.ink3) + Spacer() + Button { + onSave() + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } else { + Text("保存批注") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + .buttonStyle(.plain) + .background(palette.accent, in: RoundedRectangle(cornerRadius: 10)) + } + } + .padding(20) + .background(palette.window) + #if os(iOS) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + #else + .frame(minWidth: 520, minHeight: 420) + #endif + } +} diff --git a/Empty/Views/IOSLibraryScreen.swift b/Empty/Views/IOSLibraryScreen.swift index 766d4ce..3b0d694 100644 --- a/Empty/Views/IOSLibraryScreen.swift +++ b/Empty/Views/IOSLibraryScreen.swift @@ -27,6 +27,7 @@ struct IOSLibraryScreen: View { @State private var isImporterPresented = false @State private var isDiagnosticsPresented = false @State private var isSearching = false + @State private var showStats = false @State private var searchText = "" @State private var errorMessage: String? /// "yyyy-MM-dd" of the day the reader skipped the nudge. @@ -139,6 +140,19 @@ struct IOSLibraryScreen: View { .foregroundStyle(palette.ink3) } Spacer() + Button { + showStats = true + } label: { + Image(systemName: "chart.bar") + .font(.system(size: 13)) + .foregroundStyle(palette.ink3) + .frame(width: 36, height: 36) + .background(palette.accentSoft.opacity(0.6), in: Circle()) + } + .buttonStyle(.plain) + .sheet(isPresented: $showStats) { + ReadingStatsView() + } Button { isDiagnosticsPresented = true } label: { diff --git a/Empty/Views/IOSRootView.swift b/Empty/Views/IOSRootView.swift index b8400e3..9a90431 100644 --- a/Empty/Views/IOSRootView.swift +++ b/Empty/Views/IOSRootView.swift @@ -71,12 +71,13 @@ struct IOSRootView: View { } private func applyLaunchOverrides() { - try? ScreenshotSeeder.seedDemoBookIfNeeded(modelContext: modelContext) + let seeded = try? ScreenshotSeeder.seedDemoBookIfNeeded(modelContext: modelContext) let args = ProcessInfo.processInfo.arguments - let seeded = try? modelContext.fetch( + let fallback = try? modelContext.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.lastOpenedAt, order: .reverse)]) ).first - if args.contains("-OpenReader"), let book = seeded { + let book = seeded ?? fallback + if args.contains("-OpenReader"), let book { openBook = book tab = .reader } else if args.contains("-OpenTabReader") { diff --git a/Empty/Views/Mac/MacLibraryScreen.swift b/Empty/Views/Mac/MacLibraryScreen.swift index 224f41d..d1634eb 100644 --- a/Empty/Views/Mac/MacLibraryScreen.swift +++ b/Empty/Views/Mac/MacLibraryScreen.swift @@ -21,6 +21,7 @@ struct MacLibraryScreen: View { @State private var isImporterPresented = false @State private var errorMessage: String? @State private var searchText = "" + @State private var showStats = false @Query(sort: \Highlight.createdAt, order: .reverse) private var highlights: [Highlight] @@ -121,6 +122,17 @@ struct MacLibraryScreen: View { text: $searchText, placeholder: "搜索书名、高亮或问过的问题…" ) + Button { + showStats = true + } label: { + Text("统计") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(palette.ink2) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .overlay(Capsule().strokeBorder(palette.line2, lineWidth: 1)) + } + .buttonStyle(.plain) Button { isImporterPresented = true } label: { @@ -133,6 +145,9 @@ struct MacLibraryScreen: View { } .buttonStyle(.plain) } + .sheet(isPresented: $showStats) { + ReadingStatsView() + } } private var shelfHeader: some View { @@ -356,7 +371,7 @@ private struct ContinueReadingHero: View { || chapter.utf16Length < RecapBuilder.inlineSummaryThreshold }) else { return } - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .recap) guard resolution.service.availability.isAvailable else { return } do { let recap = try await RecapBuilder( diff --git a/Empty/Views/Mac/MacNotesScreen.swift b/Empty/Views/Mac/MacNotesScreen.swift index 1b154ef..291dcd9 100644 --- a/Empty/Views/Mac/MacNotesScreen.swift +++ b/Empty/Views/Mac/MacNotesScreen.swift @@ -212,7 +212,7 @@ struct MacNotesScreen: View { let fallback = "AI 建议:你的 \(min(visibleHighlights.count, 3)) 条高亮已收录。继续阅读时留意跨书呼应,图谱会随笔记生长。" let samples = visibleHighlights.prefix(5).map(\.textSnapshot).joined(separator: "\n") - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) guard resolution.service.availability.isAvailable else { graphSuggestion = fallback return diff --git a/Empty/Views/Mac/MacReaderScreen.swift b/Empty/Views/Mac/MacReaderScreen.swift index 8ae6909..bf6536c 100644 --- a/Empty/Views/Mac/MacReaderScreen.swift +++ b/Empty/Views/Mac/MacReaderScreen.swift @@ -45,14 +45,19 @@ struct MacReaderScreen: View { @State private var showChapterList = false @State private var showSettings = false @State private var showRecap = false + @State private var showHighlights = false @State private var recapCache: RecapCache? - @State private var fontSize: Double = 18 - @State private var lineSpacing: Double = 1.8 + @AppStorage("reader.fontSize") private var fontSize: Double = 18 + @AppStorage("reader.lineSpacing") private var lineSpacing: Double = 1.8 + @AppStorage("reader.theme") private var readerTheme: ReaderTheme = .paper + @AppStorage("reader.font") private var readerFont: ReaderFont = .serif + @AppStorage("reader.pageturn.mac") private var pageTurn: ReaderPageTurn = .paged @State private var pendingSelection: ReaderSelection? @State private var chapterHighlights: [HighlightPaint] = [] + @State private var showChapterSelection = false @State private var isCompanionOpen = false @State private var companion: CompanionModel - @State private var readingMode: MacReadingMode = .original + @AppStorage("reader.mode.mac") private var readingMode: MacReadingMode = .original @State private var summaryOpen = true @State private var chapterSummary = "" @State private var isSummaryLoading = false @@ -70,9 +75,21 @@ struct MacReaderScreen: View { @State private var chapterPageInfo: (page: Int, count: Int)? @State private var inlineNotes: [InlineNotePaint] = [] @State private var inlineCache: [String: String] = [:] + @State private var inlineRetryCounts: [String: Int] = [:] @State private var inlineInFlight: Set = [] @State private var inlineAIUnavailable = false @State private var isTocOpen = false + @State private var chromeHidden = false + /// Reference box: re-arming the timer per mouse move must NOT + /// invalidate the view (that re-parsed the chapter on every move). + @State private var chromeTimer = TaskBox() + @State private var bookmarkedHere = false + @State private var activityMeter = ReadingActivityMeter() + @AppStorage("reader.aloud.autonext") private var aloudAutoNext = false + @AppStorage("reader.pdf.invert") private var pdfNightInverted = false + @AppStorage("reader.pdf.twoup") private var pdfTwoUp = false + @AppStorage("reader.traditional") private var traditionalChinese = false + @State private var traditionalCache = DictionaryBox() /// Per-chapter pre-translation activity for the TOC chips. @State private var pretransProgress: [Int: MacChapterTransStatus] = [:] /// Translated chapter titles for the bilingual TOC (kind `.title`). @@ -159,11 +176,32 @@ struct MacReaderScreen: View { } } + /// 沉浸态: the mouse resting ~2.5s fades the reader chrome out; + /// any movement brings it back, and open overlays pin it visible. + private var immersionBlocked: Bool { + showSettings || showChapterList || showRecap || showHighlights + || showChapterSelection || isCompanionOpen || isTocOpen + || pendingSelection != nil || marginNote != nil || glossEntry != nil + } + + private func wakeChrome() { + if chromeHidden { + withAnimation(.easeInOut(duration: 0.25)) { chromeHidden = false } + } + chromeTimer.replace(Task { + try? await Task.sleep(for: .milliseconds(2500)) + guard !Task.isCancelled, !immersionBlocked else { return } + withAnimation(.easeInOut(duration: 0.4)) { chromeHidden = true } + }) + } + @ViewBuilder private func readerContent(_ epub: EPUBBook) -> some View { ZStack(alignment: .bottom) { VStack(spacing: 0) { topBar(epub) + .opacity(chromeHidden ? 0 : 1) + .allowsHitTesting(!chromeHidden) Rectangle().fill(palette.line).frame(height: 1) if summaryOpen { @@ -225,6 +263,9 @@ struct MacReaderScreen: View { withAnimation(.easeInOut(duration: 0.2)) { isTocOpen = false } + }, + onJump: { position in + jumpToHighlight(position) } ) .frame(width: 272) @@ -234,42 +275,70 @@ struct MacReaderScreen: View { VStack(spacing: 0) { ZStack(alignment: .bottom) { - ChapterWebView( - chapter: epub.chapters[currentChapterIndex], - basePath: epub.basePath, - fontSize: fontSize, - isDarkMode: palette.isDark, - lineSpacing: lineSpacing, - landing: chapterLanding, - resumeUTF16Offset: resumeUTF16Offset, - chapterPlainText: currentChapterPlainText(), - highlights: chapterHighlights, - inlineMode: inlineNoteKind, - inlineLayout: readingMode == .bilingual ? .parallel : .stacked, - inlineNotes: inlineNotes, - onTap: { pendingSelection = nil }, - onChapterBoundary: { direction in - crossChapterBoundary(direction, chapterCount: epub.chapters.count) - }, - onSelectionChange: { selection in - pendingSelection = selection - marginNote = nil - glossEntry = nil - if let selection { - Task { await detectThoughtLink(for: selection.text) } - } - }, - onPositionChange: { updateUTF16Offset(domPrefix: $0) }, - onVisibleParagraphs: { handleVisibleParagraphs($0) }, - onPageInfo: { page, count in - chapterPageInfo = (page: page, count: count) + Group { + if pageTurn == .paged { + MacPagedChapterReaderView( + chapter: displayChapter(epub.chapters[currentChapterIndex]), + basePath: epub.basePath, + fontSize: fontSize, + lineSpacing: lineSpacing, + landing: chapterLanding, + resumeUTF16Offset: resumeUTF16Offset, + chapterPlainText: displayChapterPlainText(), + highlights: chapterHighlights, + inlineMode: inlineNoteKind, + inlineNotes: inlineNotes, + appearance: ReaderAppearance(theme: readerTheme, font: readerFont), + selectionActive: pendingSelection != nil, + onTap: { pendingSelection = nil }, + onChapterBoundary: { direction in + crossChapterBoundary(direction, chapterCount: epub.chapters.count) + }, + onSelectionChange: { applySelection($0) }, + onPositionChange: { updateUTF16Offset(domPrefix: $0) }, + onVisibleParagraphs: { handleVisibleParagraphs($0) }, + onPageInfo: { page, count in + chapterPageInfo = (page: page, count: count) + } + ) + } else { + NativeChapterReaderView( + chapter: displayChapter(epub.chapters[currentChapterIndex]), + basePath: epub.basePath, + fontSize: fontSize, + lineSpacing: lineSpacing, + landing: chapterLanding, + resumeUTF16Offset: resumeUTF16Offset, + chapterPlainText: displayChapterPlainText(), + highlights: chapterHighlights, + inlineMode: inlineNoteKind, + inlineLayout: readingMode == .bilingual ? .parallel : .stacked, + inlineNotes: inlineNotes, + appearance: ReaderAppearance(theme: readerTheme, font: readerFont), + speechRange: aloud.currentSentenceRange, + selectionActive: pendingSelection != nil, + onTap: { pendingSelection = nil }, + onChapterBoundary: { direction in + crossChapterBoundary(direction, chapterCount: epub.chapters.count) + }, + onSelectionChange: { applySelection($0) }, + onPositionChange: { updateUTF16Offset(domPrefix: $0) }, + onVisibleParagraphs: { handleVisibleParagraphs($0) }, + onPageInfo: { page, count in + chapterPageInfo = (page: page, count: count) + } + ) } - ) + } + .id(currentChapterIndex) selectionOverlay } + .environment(\.emptyPalette, readerTheme.palette(base: palette)) Rectangle().fill(palette.line).frame(height: 1) bottomBar(epub) + .opacity(chromeHidden ? 0 : 1) + .allowsHitTesting(!chromeHidden) } if isCompanionOpen { @@ -297,7 +366,11 @@ struct MacReaderScreen: View { MacReadingAloudBar( snippet: aloud.currentSnippet, onToggle: { aloud.togglePause() }, - isPaused: !aloud.isSpeaking + isPaused: !aloud.isSpeaking, + rate: aloud.rate, + onCycleRate: { cycleAloudRate() }, + autoNext: aloudAutoNext, + onToggleAutoNext: { aloudAutoNext.toggle() } ) .padding(.bottom, 22) } @@ -305,9 +378,12 @@ struct MacReaderScreen: View { .sheet(isPresented: $showSettings) { ReadingSettingsView( fontSize: $fontSize, - lineSpacing: $lineSpacing + lineSpacing: $lineSpacing, + theme: $readerTheme, + font: $readerFont, + pageTurn: $pageTurn ) - .frame(minWidth: 320, minHeight: 280) + .frame(minWidth: 340, minHeight: 460) } .sheet(isPresented: $showRecap) { RecapView( @@ -317,25 +393,70 @@ struct MacReaderScreen: View { ) .frame(minWidth: 440, minHeight: 480) } + .sheet(isPresented: $showChapterSelection) { + NativeChapterSelectionSheet( + title: sectionTitles.indices.contains(currentChapterIndex) + ? sectionTitles[currentChapterIndex] + : "当前章节", + chapterText: currentChapterPlainText() ?? "", + highlights: chapterHighlights, + fontSize: fontSize, + lineSpacing: lineSpacing, + focusUTF16Offset: currentUTF16Offset, + initialSelection: pendingSelection + ) { selection in + applySelection(selection) + } + } + .sheet(isPresented: $showHighlights) { + HighlightsListView(book: book) { position in + jumpToHighlight(position) + } + .frame(minWidth: 420, minHeight: 460) + } .onChange(of: currentChapterIndex) { _, _ in pendingSelection = nil refreshChapterHighlights() resetChapterArtifacts() + refreshBookmarkState() Task { await loadChapterSummary() } // Shift the 预译 window (current + next two chapters). startPretranslation() } + .onChange(of: traditionalChinese) { _, _ in + traditionalCache.values = [:] + inlineNotes = [] + } .onChange(of: readingMode) { _, newMode in // The chapter page clears and re-requests notes for the new // mode; cached translations rejoin instantly. inlineNotes = [] inlineAIUnavailable = newMode != .original - && !AIProviderSettings.load().resolveUsableService() + && !AIProviderRegistry.load().resolveUsableService(feature: .translate) .service.availability.isAvailable startPretranslation() } + .onChange(of: showHighlights) { _, isShowing in + if !isShowing { refreshChapterHighlights() } + } + .onContinuousHover(coordinateSpace: .local) { phase in + if case .active = phase { + wakeChrome() + } + } + .onChange(of: immersionBlocked) { _, blocked in + if blocked { + chromeTimer.cancel() + if chromeHidden { + withAnimation(.easeInOut(duration: 0.25)) { chromeHidden = false } + } + } else { + wakeChrome() + } + } .onDisappear { pretransTask?.cancel() + chromeTimer.cancel() } } @@ -408,15 +529,11 @@ struct MacReaderScreen: View { documentURL: documentURL, pageIndex: $currentChapterIndex, highlights: chapterHighlights, + nightInverted: pdfNightInverted, + zoomMemoryKey: "pdf.zoom.\(book.id.uuidString)", + twoUp: pdfTwoUp, onPageChange: syncPageProgress, - onSelectionChange: { selection in - pendingSelection = selection - marginNote = nil - glossEntry = nil - if let selection { - Task { await detectThoughtLink(for: selection.text) } - } - } + onSelectionChange: { applySelection($0) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -452,7 +569,11 @@ struct MacReaderScreen: View { MacReadingAloudBar( snippet: aloud.currentSnippet, onToggle: { aloud.togglePause() }, - isPaused: !aloud.isSpeaking + isPaused: !aloud.isSpeaking, + rate: aloud.rate, + onCycleRate: { cycleAloudRate() }, + autoNext: aloudAutoNext, + onToggleAutoNext: { aloudAutoNext.toggle() } ) .padding(.bottom, 22) } @@ -473,9 +594,12 @@ struct MacReaderScreen: View { .sheet(isPresented: $showSettings) { ReadingSettingsView( fontSize: $fontSize, - lineSpacing: $lineSpacing + lineSpacing: $lineSpacing, + theme: $readerTheme, + font: $readerFont, + pageTurn: $pageTurn ) - .frame(minWidth: 320, minHeight: 280) + .frame(minWidth: 340, minHeight: 460) } .sheet(isPresented: $showRecap) { RecapView( @@ -485,6 +609,12 @@ struct MacReaderScreen: View { ) .frame(minWidth: 440, minHeight: 480) } + .sheet(isPresented: $showHighlights) { + HighlightsListView(book: book) { position in + jumpToHighlight(position) + } + .frame(minWidth: 420, minHeight: 460) + } .onChange(of: currentChapterIndex) { _, newIndex in syncPageProgress(at: newIndex) resetChapterArtifacts() @@ -496,6 +626,9 @@ struct MacReaderScreen: View { .onChange(of: readingMode) { _, _ in Task { await loadModeGuide() } } + .onChange(of: showHighlights) { _, isShowing in + if !isShowing { refreshChapterHighlights() } + } } } @@ -516,6 +649,18 @@ struct MacReaderScreen: View { refreshChapterHighlights() } + private func jumpToHighlight(_ position: ReadingPosition) { + if pdfDocumentURL != nil { + currentChapterIndex = position.chapterIndex + syncPageProgress(at: position.chapterIndex) + } else { + currentChapterIndex = position.chapterIndex + currentUTF16Offset = position.utf16Offset + chapterLanding = .start + refreshChapterHighlights() + } + } + private var pdfTopBar: some View { HStack(spacing: 14) { Button { @@ -573,7 +718,24 @@ struct MacReaderScreen: View { } .buttonStyle(.plain) - pillButton("目录") { showChapterList = true } + pillButton("目录", identifier: "reader.chapterList") { showChapterList = true } + pillButton("高亮", identifier: "reader.highlights") { showHighlights = true } + pillButton("设置", identifier: "reader.settings") { showSettings = true } + + Button { + toggleBookmark() + } label: { + Image(systemName: bookmarkedHere ? "bookmark.fill" : "bookmark") + .font(.system(size: 12)) + .foregroundStyle(bookmarkedHere ? palette.accent : palette.ink2) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .overlay(Capsule().strokeBorder(palette.line2, lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .keyboardShortcut("d", modifiers: .command) + .help("书签(⌘D)") Button { withAnimation(.easeInOut(duration: 0.2)) { @@ -710,7 +872,10 @@ struct MacReaderScreen: View { onExplain: { runSelectionAction(.explain) }, onTranslate: { runSelectionAction(.translate) }, onAsk: { runSelectionAction(.ask) }, - onHighlight: saveHighlight, + onExpandSelection: { showChapterSelection = true }, + onHighlight: { saveHighlight(color: $0) }, + onCopy: copySelection, + onDictionary: lookUpSelectionInDictionary, onVocab: { runSelectionAction(.vocab) }, isLoading: isSelectionWorking ) @@ -868,6 +1033,9 @@ struct MacReaderScreen: View { } .buttonStyle(.plain) + pillButton("高亮", identifier: "reader.highlights") { showHighlights = true } + pillButton("设置", identifier: "reader.settings") { showSettings = true } + Button { withAnimation(.easeInOut(duration: 0.2)) { isCompanionOpen.toggle() @@ -902,7 +1070,11 @@ struct MacReaderScreen: View { return nil } - private func pillButton(_ title: String, action: @escaping () -> Void) -> some View { + private func pillButton( + _ title: String, + identifier: String, + action: @escaping () -> Void + ) -> some View { Button(action: action) { Text(title) .font(.system(size: 12)) @@ -913,6 +1085,7 @@ struct MacReaderScreen: View { .contentShape(Capsule()) } .buttonStyle(.plain) + .accessibilityIdentifier(identifier) } private func bottomBar(_ epub: EPUBBook) -> some View { @@ -991,7 +1164,7 @@ struct MacReaderScreen: View { Task { defer { isSelectionWorking = false } do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) let service = resolution.service let passage = GroundedPassage(id: 0, text: selection.text) let source = chapterSourceLabel @@ -1064,6 +1237,7 @@ struct MacReaderScreen: View { marginNoteSaved = false glossEntry = nil pendingSelection = nil + showChapterSelection = false inlineNotes = [] } @@ -1084,7 +1258,7 @@ struct MacReaderScreen: View { } guard let text = currentChapterRecord?.text, !text.isEmpty else { return } do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .recap) let summary = try await resolution.service.summarize( String(text.prefix(6_000)), focus: .digest @@ -1109,7 +1283,7 @@ struct MacReaderScreen: View { return } guard let text = currentChapterRecord?.text, !text.isEmpty else { return } - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .recap) guard resolution.service.availability.isAvailable else { return } do { let answer = try await resolution.service.answer( @@ -1149,9 +1323,16 @@ struct MacReaderScreen: View { for paragraph in paragraphs { let key = inlineKey(mode, chapter, paragraph.idx) if let cached = inlineCache[key] { - if !cached.isEmpty, - !inlineNotes.contains(where: { $0.idx == paragraph.idx }) { - inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: cached)) + if !cached.isEmpty { + if !inlineNotes.contains(where: { $0.idx == paragraph.idx }) { + inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: cached)) + } + } else if inlineRetryCounts[key, default: 0] < 3, + !inlineInFlight.contains(key) { + // An empty/errored result is not a verdict — retry the + // paragraph while it's on screen (capped per visit). + inlineInFlight.insert(key) + missing.append(paragraph) } } else if let persisted = store.lookup( bookID: book.id, @@ -1176,7 +1357,7 @@ struct MacReaderScreen: View { mode: MacReadingMode, chapter: Int ) async { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .translate) guard resolution.service.availability.isAvailable else { for paragraph in paragraphs { inlineInFlight.remove(inlineKey(mode, chapter, paragraph.idx)) @@ -1207,8 +1388,11 @@ struct MacReaderScreen: View { text: paragraph.text ) cacheStatsTick += 1 + } else { + inlineRetryCounts[key, default: 0] += 1 } if readingMode == mode, currentChapterIndex == chapter, !text.isEmpty { + inlineNotes.removeAll { $0.idx == paragraph.idx } inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: text)) } } catch { @@ -1218,7 +1402,9 @@ struct MacReaderScreen: View { // permanent failure marker. guard !AITransientRetry.isTransient(error) else { continue } inlineCache[key] = "" + inlineRetryCounts[key, default: 0] += 1 if readingMode == mode, currentChapterIndex == chapter { + inlineNotes.removeAll { $0.idx == paragraph.idx } inlineNotes.append( InlineNotePaint(idx: paragraph.idx, text: "", failed: true) ) @@ -1241,8 +1427,12 @@ struct MacReaderScreen: View { } private func pretranslate(from startChapter: Int) async { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .translate) guard resolution.service.availability.isAvailable else { return } + // Whole-chapter pretranslation saturates the machine when the + // model runs locally — reading janks. On-device stays + // per-viewport; only cloud providers cache ahead. + guard !resolution.provider.isLocal else { return } let store = TranslationStore(modelContext: modelContext) let bookID = book.id let chapters = (try? modelContext.fetch( @@ -1284,24 +1474,30 @@ struct MacReaderScreen: View { } for chapter in window { guard !Task.isCancelled, readingMode == .bilingual else { return } - guard chapter.pretranslatedAt == nil else { continue } + // Even chapters stamped pretranslated can carry holes (a + // provider hiccup skipped paragraphs in an earlier pass) — + // the cheap local lookups below re-check and backfill them. let paragraphs = TranslationStore.paragraphs(in: chapter.text) - guard !paragraphs.isEmpty else { - chapter.pretranslatedAt = Date() - try? modelContext.save() + let missing = paragraphs.filter { + store.lookup(bookID: bookID, kind: .bilingual, text: $0) == nil + } + guard !missing.isEmpty else { + if chapter.pretranslatedAt == nil { + chapter.pretranslatedAt = Date() + try? modelContext.save() + } + pretransProgress[chapter.index] = nil continue } - var done = 0 - pretransProgress[chapter.index] = .translating(done: 0, total: paragraphs.count) - for paragraph in paragraphs { + var done = paragraphs.count - missing.count + pretransProgress[chapter.index] = .translating(done: done, total: paragraphs.count) + for paragraph in missing { guard !Task.isCancelled, readingMode == .bilingual else { return } - if store.lookup(bookID: bookID, kind: .bilingual, text: paragraph) == nil { - guard let text = try? await resolution.service.inlineNote( - for: paragraph, - kind: .bilingual - ).trimmingCharacters(in: .whitespacesAndNewlines) - else { continue } - guard !text.isEmpty else { continue } + if let text = try? await resolution.service.inlineNote( + for: paragraph, + kind: .bilingual + ).trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { store.store( text, bookID: bookID, @@ -1362,7 +1558,7 @@ struct MacReaderScreen: View { defer { isGuideLoading = false } guard let text = currentChapterRecord?.text, !text.isEmpty else { return } do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .recap) let clipped = String(text.prefix(4_000)) if readingMode == .bilingual { modeGuideText = try await AITransientRetry.run { @@ -1379,6 +1575,15 @@ struct MacReaderScreen: View { } } + private func applySelection(_ selection: ReaderSelection?) { + pendingSelection = selection + marginNote = nil + glossEntry = nil + if let selection { + Task { await detectThoughtLink(for: selection.text) } + } + } + private func detectThoughtLink(for passage: String) async { do { if var link = try ThoughtLinkFinder(modelContext: modelContext).findLink( @@ -1408,15 +1613,37 @@ struct MacReaderScreen: View { ).first } + private func cycleAloudRate() { + let steps: [Double] = [0.75, 1.0, 1.25, 1.5] + let index = steps.firstIndex(where: { abs($0 - aloud.rate) < 0.01 }) ?? 1 + aloud.rate = steps[(index + 1) % steps.count] + } + private func toggleReadingAloud() { if aloud.isSpeaking { aloud.stop() return } - if let text = pendingSelection?.text ?? currentChapterRecord?.text { - let lang = book.languageTag?.hasPrefix("zh") == true ? "zh-CN" : "en-US" - aloud.speak(String(text.prefix(800)), language: lang) + let lang = book.languageTag?.hasPrefix("zh") == true ? "zh-CN" : "en-US" + if let selection = pendingSelection { + aloud.speak(selection.text, language: lang) + return } + guard let text = currentChapterRecord?.text else { return } + // 从当前位置接着读; finishing the chapter rolls into the next + // when 自动下一章 is on. + aloud.onQueueFinished = { [self] in + guard aloudAutoNext, + let chapterCount = epubBook?.chapters.count, + currentChapterIndex < chapterCount - 1 else { return } + crossChapterBoundary(.forward, chapterCount: chapterCount) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + if let next = currentChapterRecord?.text { + aloud.speak(next, language: lang) + } + } + } + aloud.speak(text, fromUTF16Offset: currentUTF16Offset, language: lang) } // MARK: Loading & progress @@ -1446,13 +1673,41 @@ struct MacReaderScreen: View { ).first?.text } + // MARK: 繁体显示 (render-layer, EPUB only) + + private func displayChapter(_ chapter: EPUBChapter) -> EPUBChapter { + guard traditionalChinese else { return chapter } + if let cached = traditionalCache.values[currentChapterIndex] { + return cached.chapter + } + let converted = EPUBChapter( + title: ChineseVariant.traditional(chapter.title), + href: chapter.href, + content: ChineseVariant.traditional(chapter.content) + ) + let plain = currentChapterPlainText().map(ChineseVariant.traditional) + traditionalCache.values[currentChapterIndex] = (converted, plain) + return converted + } + + private func displayChapterPlainText() -> String? { + guard traditionalChinese else { return currentChapterPlainText() } + if let cached = traditionalCache.values[currentChapterIndex] { + return cached.plain + } + return currentChapterPlainText().map(ChineseVariant.traditional) + } + private func updateUTF16Offset(domPrefix: String) { - guard let plainText = currentChapterPlainText() else { return } + guard let plainText = displayChapterPlainText() else { return } let offset = PlainTextSearch.utf16Offset( afterNormalizedPrefix: domPrefix, in: plainText ) currentUTF16Offset = min(offset, plainText.utf16.count) + // Position reports only arrive on real scroll/page activity — + // exactly what the 统计 spec counts as reading time. + activityMeter.ping() } private func loadBook() { @@ -1493,6 +1748,9 @@ struct MacReaderScreen: View { syncPageProgress(at: currentChapterIndex) } isLoading = false + inlineAIUnavailable = readingMode != .original + && !AIProviderRegistry.load().resolveUsableService(feature: .translate) + .service.availability.isAvailable startSession() startPretranslation() await loadChapterSummary() @@ -1512,7 +1770,7 @@ struct MacReaderScreen: View { refreshChapterHighlights() } - private func saveHighlight() { + private func saveHighlight(color: HighlightColor = .yellow) { guard let selection = pendingSelection else { return } do { try HighlightStore(modelContext: modelContext).createHighlight( @@ -1520,7 +1778,8 @@ struct MacReaderScreen: View { chapterIndex: currentChapterIndex, selection: selection.text, prefix: selection.prefix, - suffix: selection.suffix + suffix: selection.suffix, + color: color ) pendingSelection = nil refreshChapterHighlights() @@ -1529,11 +1788,65 @@ struct MacReaderScreen: View { } } + private func copySelection() { + guard let selection = pendingSelection else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selection.text, forType: .string) + pendingSelection = nil + } + + // MARK: 书签 (⌘D) + + private func refreshBookmarkState() { + bookmarkedHere = ((try? BookmarkStore(modelContext: modelContext) + .bookmarks(for: book)) ?? []) + .contains { + $0.chapterIndex == currentChapterIndex + && abs($0.utf16Offset - currentUTF16Offset) < 600 + } + } + + private func toggleBookmark() { + let chapterText = currentChapterPlainText() ?? "" + let utf16 = Array(chapterText.utf16) + let start = max(0, min(currentUTF16Offset, max(0, utf16.count - 1))) + let end = min(utf16.count, start + 80) + let snippet = String(decoding: utf16[start.. Void var onClose: () -> Void + /// Jump to an exact in-chapter position (bookmark / search hit). + var onJump: (ReadingPosition) -> Void = { _ in } @Environment(\.emptyPalette) private var palette @Environment(\.modelContext) private var modelContext + private enum DrawerTab: String, CaseIterable { + case toc = "目录" + case bookmarks = "书签" + case search = "搜索" + } + + @State private var tab: DrawerTab = .toc + @State private var bookmarks: [Bookmark] = [] + @State private var searchQuery = "" + @State private var searchRead: [BookSearchHit] = [] + @State private var searchUnread: [BookSearchHit] = [] + @State private var unreadExpanded = false + private struct ChapterFacts { var utf16Length = 0 var pretranslated = false @@ -1616,13 +1945,23 @@ private struct MacTOCPanel: View { var body: some View { VStack(spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("目录") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(palette.ink) - Text("\(bookTitle) · \(titles.count) 章") - .font(.system(size: 11)) - .foregroundStyle(palette.ink3) - .lineLimit(1) + ForEach(DrawerTab.allCases, id: \.self) { choice in + Button { + tab = choice + } label: { + Text(choice.rawValue) + .font(.system(size: 12, weight: tab == choice ? .bold : .regular)) + .foregroundStyle(tab == choice ? palette.accent : palette.ink3) + .padding(.vertical, 2) + .overlay(alignment: .bottom) { + if tab == choice { + Rectangle().fill(palette.accent).frame(height: 2) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } Spacer() Button(action: onClose) { Text("×") @@ -1635,29 +1974,222 @@ private struct MacTOCPanel: View { } .padding(EdgeInsets(top: 16, leading: 18, bottom: 10, trailing: 14)) - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 2) { - ForEach(Array(titles.enumerated()), id: \.offset) { index, title in - row(index: index, title: title) - .id(index) - } + switch tab { + case .toc: + tocList + footer + case .bookmarks: + bookmarkList + case .search: + searchPane + } + } + .background(palette.side) + .task(id: statsTick) { + refreshFacts() + } + } + + private var tocList: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 2) { + ForEach(Array(titles.enumerated()), id: \.offset) { index, title in + row(index: index, title: title) + .id(index) } - .padding(EdgeInsets(top: 2, leading: 10, bottom: 10, trailing: 10)) } - .onAppear { - proxy.scrollTo(currentIndex, anchor: .center) + .padding(EdgeInsets(top: 2, leading: 10, bottom: 10, trailing: 10)) + } + .onAppear { + proxy.scrollTo(currentIndex, anchor: .center) + } + } + } + + // MARK: 书签 + + private var bookmarkList: some View { + ScrollView { + VStack(spacing: 4) { + if bookmarks.isEmpty { + Text("还没有书签 — 阅读时按 ⌘D 在当前位置留一枚。") + .font(.system(size: 11.5)) + .foregroundStyle(palette.ink3) + .multilineTextAlignment(.center) + .padding(.top, 28) + .padding(.horizontal, 16) + } + ForEach(bookmarks) { bookmark in + bookmarkRow(bookmark) } } + .padding(EdgeInsets(top: 2, leading: 10, bottom: 10, trailing: 10)) + } + .task(id: statsTick) { reloadBookmarks() } + .onAppear { reloadBookmarks() } + } - footer + private func bookmarkRow(_ bookmark: Bookmark) -> some View { + Button { + onJump(ReadingPosition( + chapterIndex: bookmark.chapterIndex, + utf16Offset: bookmark.utf16Offset + )) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "bookmark.fill") + .font(.system(size: 10)) + .foregroundStyle(palette.accent) + Text(chapterLabel(bookmark.chapterIndex)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(palette.ink2) + Spacer() + Button { + try? BookmarkStore(modelContext: modelContext).delete(bookmark) + reloadBookmarks() + } label: { + Image(systemName: "trash") + .font(.system(size: 10)) + .foregroundStyle(palette.ink3) + } + .buttonStyle(.plain) + } + Text(bookmark.snippet) + .font(.system(size: 12, design: .serif)) + .foregroundStyle(palette.ink) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.card.opacity(0.6), in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) } - .background(palette.side) - .task(id: statsTick) { - refreshFacts() + .buttonStyle(.plain) + } + + private func reloadBookmarks() { + bookmarks = (try? BookmarkStore(modelContext: modelContext).bookmarks(for: book)) ?? [] + } + + // MARK: 搜索(未读折叠防剧透) + + private var searchPane: some View { + VStack(spacing: 0) { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(palette.ink3) + TextField("全文搜索…", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(palette.card, in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 12) + .padding(.bottom, 8) + + ScrollView { + VStack(spacing: 4) { + ForEach(searchRead) { hit in + searchHitRow(hit) + } + if !searchUnread.isEmpty { + if unreadExpanded { + Text("未读章节(小心剧透)") + .font(.system(size: 10.5, weight: .bold)) + .foregroundStyle(palette.accent) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.top, 8) + ForEach(searchUnread) { hit in + searchHitRow(hit) + } + } else { + Button { + unreadExpanded = true + } label: { + Text("未读章节中还有 \(searchUnread.count) 处 · 点开(可能剧透)") + .font(.system(size: 11.5)) + .foregroundStyle(palette.ink3) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(palette.line2, style: StrokeStyle(dash: [4, 4])) + ) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .padding(.top, 6) + } + } + if searchRead.isEmpty && searchUnread.isEmpty + && searchQuery.trimmingCharacters(in: .whitespaces).count >= 2 { + Text("没有找到「\(searchQuery)」") + .font(.system(size: 11.5)) + .foregroundStyle(palette.ink3) + .padding(.top, 24) + } + } + .padding(EdgeInsets(top: 2, leading: 10, bottom: 10, trailing: 10)) + } + } + .task(id: searchQuery) { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + runSearch() } } + private func searchHitRow(_ hit: BookSearchHit) -> some View { + Button { + onJump(ReadingPosition( + chapterIndex: hit.chapterIndex, + utf16Offset: hit.utf16Offset + )) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(chapterLabel(hit.chapterIndex)) + .font(.system(size: 10.5, weight: .bold)) + .foregroundStyle(palette.ink3) + Text(hit.snippet) + .font(.system(size: 12, design: .serif)) + .foregroundStyle(palette.ink) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.card.opacity(0.6), in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + } + + private func runSearch() { + unreadExpanded = false + let result = (try? BookTextSearch(modelContext: modelContext).search( + book: book, + query: searchQuery, + maxReadChapter: max(currentIndex, book.position.chapterIndex) + )) ?? (read: [], unread: []) + searchRead = result.read + searchUnread = result.unread + } + + private func chapterLabel(_ index: Int) -> String { + let title = titles.indices.contains(index) ? titles[index] : "" + let display = title.trimmingCharacters(in: .whitespaces).isEmpty + ? "第 \(index + 1) 章" + : title + return "\(RomanNumeral.format(index + 1)) · \(display)" + } + private func row(index: Int, title: String) -> some View { let isCurrent = index == currentIndex let chapterFacts = facts[index] ?? ChapterFacts() diff --git a/Empty/Views/Mac/MacRootView.swift b/Empty/Views/Mac/MacRootView.swift index 7d1f894..5a48350 100644 --- a/Empty/Views/Mac/MacRootView.swift +++ b/Empty/Views/Mac/MacRootView.swift @@ -54,13 +54,17 @@ struct MacRootView: View { .onAppear(perform: applyLaunchOverrides) } - /// `-OpenReader` opens the most recently read book (screenshot automation). + /// `-ScreenshotSeed` imports the demo book, `-ScreenshotSeedHighlight` + /// adds one deterministic note, and `-OpenReader` opens that seeded book + /// (falling back to the most recently read one). private func applyLaunchOverrides() { + let seeded = try? ScreenshotSeeder.seedDemoBookIfNeeded(modelContext: modelContext) guard ProcessInfo.processInfo.arguments.contains("-OpenReader") else { return } let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\Book.lastOpenedAt, order: .reverse)] ) - guard let book = try? modelContext.fetch(descriptor).first else { return } + let book = seeded ?? (try? modelContext.fetch(descriptor).first) + guard let book else { return } open(book) } diff --git a/Empty/Views/Mac/MacUIComponents.swift b/Empty/Views/Mac/MacUIComponents.swift index 0889d5b..cd5c4cd 100644 --- a/Empty/Views/Mac/MacUIComponents.swift +++ b/Empty/Views/Mac/MacUIComponents.swift @@ -67,31 +67,59 @@ struct MacSegmentedPills: View { // MARK: - Selection popover +/// 划词菜单 per the P0 spec: non-AI actions lead — three marker dots, +/// 复制 / 词典 / 跨段 — and the AI verbs live behind the trailing 朱 › +/// group so the everyday menu stays quiet. struct MacSelectionPopover: View { var onExplain: () -> Void var onTranslate: () -> Void var onAsk: () -> Void - var onHighlight: () -> Void + var onExpandSelection: () -> Void + var onHighlight: (HighlightColor) -> Void + var onCopy: () -> Void + var onDictionary: () -> Void var onVocab: () -> Void var isLoading: Bool @Environment(\.emptyPalette) private var palette + @State private var zhuOpen = false var body: some View { HStack(spacing: 2) { - popButton("解释", action: onExplain) - popButton("翻译", action: onTranslate) - Button(action: onAsk) { - Text("追问 ↩") - .font(.system(size: 12.5)) - .foregroundStyle(palette.onAccent) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(palette.accent, in: RoundedRectangle(cornerRadius: 8)) + ForEach(HighlightColor.readerChoices, id: \.self) { color in + colorDot(color) + } + divider + popButton("复制", action: onCopy) + popButton("词典", action: onDictionary) + popButton("跨段", action: onExpandSelection) + divider + if zhuOpen { + popButton("解释", action: onExplain) + popButton("翻译", action: onTranslate) + popButton("生词", action: onVocab) + Button(action: onAsk) { + Text("追问 ↩") + .font(.system(size: 12.5)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(palette.accent, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } else { + Button { + withAnimation(.easeOut(duration: 0.15)) { zhuOpen = true } + } label: { + Text("朱 ›") + .font(.system(size: 12.5, weight: .bold, design: .serif)) + .foregroundStyle(Color(hex: 0xD86B47)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - popButton("高亮", action: onHighlight) - popButton("生词", action: onVocab) if isLoading { ProgressView() .controlSize(.small) @@ -103,6 +131,28 @@ struct MacSelectionPopover: View { .shadow(color: .black.opacity(0.3), radius: 16, y: 8) } + private var divider: some View { + Rectangle() + .fill(Color.white.opacity(0.16)) + .frame(width: 1, height: 16) + .padding(.horizontal, 3) + } + + private func colorDot(_ color: HighlightColor) -> some View { + Button { + onHighlight(color) + } label: { + Circle() + .fill(Color(hex: color.hex)) + .frame(width: 15, height: 15) + .overlay(Circle().strokeBorder(Color.white.opacity(0.35), lineWidth: 1)) + .padding(5) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("高亮(\(color.title))") + } + private func popButton(_ title: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) @@ -226,6 +276,10 @@ struct MacReadingAloudBar: View { let snippet: String var onToggle: () -> Void var isPaused: Bool + var rate: Double = 1.0 + var onCycleRate: () -> Void = {} + var autoNext: Bool = false + var onToggleAutoNext: () -> Void = {} @Environment(\.emptyPalette) private var palette @@ -252,9 +306,21 @@ struct MacReadingAloudBar: View { Text("正在朗读 · \"\(snippet)…\"") .font(.system(size: 12.5)) .lineLimit(1) - Text("1.0×") - .font(.system(size: 11.5)) - .opacity(0.6) + Button(action: onCycleRate) { + Text(String(format: "%.2g×", rate)) + .font(.system(size: 11.5, weight: .bold)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("语速(点击循环 0.75–1.5×)") + Button(action: onToggleAutoNext) { + Text("连读") + .font(.system(size: 11)) + .opacity(autoNext ? 1 : 0.45) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("读完本章自动续读下一章") } .foregroundStyle(palette.window) .padding(.horizontal, 18) diff --git a/Empty/Views/NativeChapterReaderView.swift b/Empty/Views/NativeChapterReaderView.swift new file mode 100644 index 0000000..2a0f711 --- /dev/null +++ b/Empty/Views/NativeChapterReaderView.swift @@ -0,0 +1,726 @@ +// +// NativeChapterReaderView.swift +// Empty +// + +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Pure SwiftUI EPUB chapter renderer used by the native reader path. +/// It reports visible paragraphs through preferences instead of DOM queries, +/// so inserting translations or guide notes never asks WebKit to reflow/scroll. +struct NativeChapterReaderView: View { + let chapter: EPUBChapter + let basePath: URL + let fontSize: Double + let lineSpacing: Double + let landing: ChapterLanding + let resumeUTF16Offset: Int + let chapterPlainText: String? + let highlights: [HighlightPaint] + let inlineMode: InlineNoteKind + let inlineLayout: InlineNoteLayout + let inlineNotes: [InlineNotePaint] + var appearance: ReaderAppearance = ReaderAppearance() + /// The sentence being read aloud (chapter UTF-16) — painted as a + /// vermilion follow-along underline. + var speechRange: Range? = nil + var selectionActive: Bool = false + var onTap: () -> Void = {} + var onChapterBoundary: (PageTurnDirection) -> Void = { _ in } + var onSelectionChange: (ReaderSelection?) -> Void = { _ in } + var onPositionChange: (String) -> Void = { _ in } + var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } + var onPageInfo: (Int, Int) -> Void = { _, _ in } + + private let document: NativeChapterDocument + private let blockSpans: [String: NativeTextBlockSpan] + private let paragraphByID: [String: ReaderParagraph] + private let orderedTextSpans: [NativeTextBlockSpan] + private let coordinateSpace = "NativeChapterReaderView.scroll" + + @Environment(\.emptyPalette) private var palette + @State private var viewportHeight: CGFloat = 0 + @State private var lastVisibleParagraphs: [ReaderParagraph] = [] + @State private var lastPositionPrefix = "" + @State private var activeSelectionBlockID: String? + /// Reference box: per-scroll-frame re-arming must not invalidate. + @State private var settleTask = TaskBox() + + init( + chapter: EPUBChapter, + basePath: URL, + fontSize: Double, + lineSpacing: Double, + landing: ChapterLanding, + resumeUTF16Offset: Int, + chapterPlainText: String?, + highlights: [HighlightPaint], + inlineMode: InlineNoteKind, + inlineLayout: InlineNoteLayout = .stacked, + inlineNotes: [InlineNotePaint], + appearance: ReaderAppearance = ReaderAppearance(), + speechRange: Range? = nil, + selectionActive: Bool = false, + onTap: @escaping () -> Void = {}, + onChapterBoundary: @escaping (PageTurnDirection) -> Void = { _ in }, + onSelectionChange: @escaping (ReaderSelection?) -> Void = { _ in }, + onPositionChange: @escaping (String) -> Void = { _ in }, + onVisibleParagraphs: @escaping ([ReaderParagraph]) -> Void = { _ in }, + onPageInfo: @escaping (Int, Int) -> Void = { _, _ in } + ) { + self.chapter = chapter + self.basePath = basePath + self.fontSize = fontSize + self.lineSpacing = lineSpacing + self.landing = landing + self.resumeUTF16Offset = resumeUTF16Offset + self.chapterPlainText = chapterPlainText + self.highlights = highlights + self.inlineMode = inlineMode + self.inlineLayout = inlineLayout + self.inlineNotes = inlineNotes + self.appearance = appearance + self.speechRange = speechRange + self.selectionActive = selectionActive + self.onTap = onTap + self.onChapterBoundary = onChapterBoundary + self.onSelectionChange = onSelectionChange + self.onPositionChange = onPositionChange + self.onVisibleParagraphs = onVisibleParagraphs + self.onPageInfo = onPageInfo + + let parsed = NativeChapterParser.parse(chapter) + let spans = NativeChapterParser.resolvedSpans( + for: chapter, document: parsed, chapterPlainText: chapterPlainText + ) + self.document = parsed + self.blockSpans = spans + self.paragraphByID = Dictionary( + uniqueKeysWithValues: parsed.blocks.compactMap { block in + guard let paragraph = block.readerParagraph else { return nil } + return (block.id, paragraph) + } + ) + self.orderedTextSpans = spans.values.sorted { + $0.chapterRange.lowerBound < $1.chapterRange.lowerBound + } + } + + var body: some View { + GeometryReader { outer in + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + chapterBoundaryButton(.backward) + .padding(.bottom, 18) + + ForEach(document.blocks) { block in + blockView(block) + .id(block.id) + } + + chapterBoundaryButton(.forward) + .padding(.top, 22) + } + .frame(maxWidth: 760, alignment: .leading) + .padding(.horizontal, horizontalPadding(for: outer.size.width)) + .padding(.top, 34) + .padding(.bottom, 96) + .frame(maxWidth: .infinity, alignment: .center) + } + .coordinateSpace(name: coordinateSpace) + .background(palette.window) + .contentShape(Rectangle()) + .onTapGesture { + activeSelectionBlockID = nil + onTap() + onSelectionChange(nil) + } + .onAppear { + viewportHeight = outer.size.height + onPageInfo(0, 1) + DispatchQueue.main.async { + scrollToLanding(with: proxy) + } + } + .onChange(of: outer.size.height) { _, height in + viewportHeight = height + } + .onChange(of: selectionActive) { _, isActive in + if !isActive { + activeSelectionBlockID = nil + } + } + .onPreferenceChange(NativeParagraphFrameKey.self) { frames in + updateVisibleParagraphs(frames) + } + } + } + } + + @ViewBuilder + private func blockView(_ block: NativeChapterBlock) -> some View { + switch block { + case .heading(_, let level, _): + selectableText( + for: block, + fontSize: headingSize(level), + weight: .bold, + tone: .primary, + bullet: nil + ) + .padding(.top, level <= 2 ? 22 : 16) + .padding(.bottom, level <= 2 ? 12 : 8) + + case .paragraph: + paragraphBlock(block, quoteStyle: false, bullet: nil) + + case .quote: + paragraphBlock(block, quoteStyle: true, bullet: nil) + + case .listItem(_, _, _, let level, let marker): + paragraphBlock(block, quoteStyle: false, bullet: marker) + .padding(.leading, CGFloat(max(0, level - 1)) * 18) + + case .footnote: + footnoteBlock(block) + + case .code(_, let text): + codeBlock(text, block: block) + + case .table(_, let rows): + tableBlock(rows) + .padding(.vertical, 10) + + case .image(_, let source, let alt): + NativeReaderImageView(url: resourceURL(for: source), alt: alt) + .padding(.vertical, 16) + } + } + + private func footnoteBlock(_ block: NativeChapterBlock) -> some View { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 9) { + Text("注") + .font(.system(size: 10, weight: .bold)) + .kerning(1) + .foregroundStyle(palette.accent) + .padding(.horizontal, 5) + .padding(.vertical, 2.5) + .background(palette.accentSoft, in: RoundedRectangle(cornerRadius: 4)) + NativeSelectableTextBlockView( + text: block.text, + fontSize: max(12, fontSize - 2.5), + lineSpacing: textLineSpacing(for: max(12, fontSize - 2.5)), + weight: .regular, + tone: .secondary, + highlightRanges: localHighlightRanges(for: block), + isDark: palette.isDark, + fontFamily: appearance.font.familyName, + useSerifDesign: appearance.font.usesSerifDesign, + inkPrimaryHex: inkHexes.primary, + inkSecondaryHex: inkHexes.secondary, + clearSelection: !selectionActive || activeSelectionBlockID != block.id, + onSelectionChange: { updateSelection(for: block, localRange: $0) } + ) + } + if inlineMode != .none, let paragraph = block.readerParagraph { + inlineNoteView(for: paragraph.idx) + } + } + .padding(.vertical, 6) + .background(visibilityProbe(for: block)) + } + + private func codeBlock(_ text: String, block: NativeChapterBlock) -> some View { + NativeSelectableTextBlockView( + text: text, + fontSize: max(12, fontSize - 3), + lineSpacing: 2.5, + weight: .regular, + tone: .primary, + highlightRanges: localHighlightRanges(for: block), + isDark: palette.isDark, + monospaced: true, + inkPrimaryHex: inkHexes.primary, + inkSecondaryHex: inkHexes.secondary, + clearSelection: !selectionActive || activeSelectionBlockID != block.id, + onSelectionChange: { updateSelection(for: block, localRange: $0) } + ) + .padding(13) + .background(palette.side, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(palette.line, lineWidth: 1) + ) + .padding(.vertical, 8) + } + + private func tableBlock(_ rows: [[String]]) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + Grid(alignment: .topLeading, horizontalSpacing: 20, verticalSpacing: 0) { + ForEach(rows.indices, id: \.self) { rowIndex in + GridRow { + ForEach(rows[rowIndex].indices, id: \.self) { cellIndex in + Text(rows[rowIndex][cellIndex]) + .font(.system( + size: max(12, fontSize - 3), + weight: rowIndex == 0 ? .semibold : .regular, + design: .serif + )) + .foregroundStyle(rowIndex == 0 ? palette.ink : palette.ink2) + .padding(.vertical, 7) + } + } + if rowIndex < rows.count - 1 { + Divider() + .gridCellUnsizedAxes(.horizontal) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 5) + } + .background(palette.side.opacity(0.6), in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(palette.line, lineWidth: 1) + ) + } + + private func paragraphBlock( + _ block: NativeChapterBlock, + quoteStyle: Bool, + bullet: String? + ) -> some View { + Group { + if inlineMode != .none, inlineLayout == .parallel, let paragraph = block.readerParagraph { + HStack(alignment: .top, spacing: 22) { + selectableText( + for: block, + fontSize: fontSize, + weight: .regular, + tone: quoteStyle ? .secondary : .primary, + bullet: bullet + ) + .frame(maxWidth: .infinity, alignment: .leading) + inlineNoteView(for: paragraph.idx) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + VStack(alignment: .leading, spacing: 9) { + selectableText( + for: block, + fontSize: fontSize, + weight: .regular, + tone: quoteStyle ? .secondary : .primary, + bullet: bullet + ) + if inlineMode != .none, let paragraph = block.readerParagraph { + inlineNoteView(for: paragraph.idx) + } + } + } + } + .padding(.horizontal, quoteStyle ? 16 : 0) + .padding(.vertical, 8) + .overlay(alignment: .leading) { + if quoteStyle { + Rectangle() + .fill(palette.accentSoft2) + .frame(width: 3) + } + } + .background(visibilityProbe(for: block)) + } + + private var inkHexes: (primary: UInt32, secondary: UInt32) { + appearance.theme.inkHexes(baseIsDark: palette.isDark) + } + + private var inlineNotesByIndex: [Int: InlineNotePaint] { + var notesByIndex: [Int: InlineNotePaint] = [:] + for note in inlineNotes { + notesByIndex[note.idx] = note + } + return notesByIndex + } + + private func selectableText( + for block: NativeChapterBlock, + fontSize: Double, + weight: NativeTextWeight, + tone: NativeTextTone, + bullet: String? + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + if let bullet { + Text(bullet) + .font(.system(size: fontSize, weight: .bold, design: .serif)) + .foregroundStyle(palette.accent) + } + NativeSelectableTextBlockView( + text: block.text, + fontSize: fontSize, + lineSpacing: textLineSpacing(for: fontSize), + weight: weight, + tone: tone, + highlightRanges: localHighlightRanges(for: block), + isDark: palette.isDark, + fontFamily: appearance.font.familyName, + useSerifDesign: appearance.font.usesSerifDesign, + inkPrimaryHex: inkHexes.primary, + inkSecondaryHex: inkHexes.secondary, + clearSelection: !selectionActive || activeSelectionBlockID != block.id, + onSelectionChange: { updateSelection(for: block, localRange: $0) } + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func updateSelection( + for block: NativeChapterBlock, + localRange: Range? + ) { + guard let localRange, + let selection = document.selection( + for: block.id, + localUTF16Range: localRange, + chapterPlainText: chapterPlainText, + spans: blockSpans + ) else { + if activeSelectionBlockID == block.id { + activeSelectionBlockID = nil + } + onSelectionChange(nil) + return + } + activeSelectionBlockID = block.id + onSelectionChange(selection) + } + + private func localHighlightRanges(for block: NativeChapterBlock) -> [NativeHighlightRange] { + var localRanges: [NativeHighlightRange] = [] + if let span = blockSpans[block.id] { + for highlight in highlights { + let color = highlight.colorHex ?? HighlightColor.yellow.hex + if let start = highlight.startUTF16, + let end = highlight.endUTF16, + end > start, + let local = span.localRange(intersecting: start.. some View { + let note = inlineNotesByIndex[index] + let title = inlineMode == .bilingual ? "译" : "导读" + let body = note?.failed == true + ? "暂不可用,稍后会自动重试。" + : (note?.text.isEmpty == false ? note?.text ?? "" : "生成中…") + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.system(size: 10.5, weight: .bold)) + .kerning(1.1) + .foregroundStyle(palette.accent) + Text(body) + .font(.system(size: max(13, fontSize - 2), weight: .regular, design: .serif)) + .lineSpacing(max(3, textLineSpacing(for: max(13, fontSize - 2)) * 0.8)) + .foregroundStyle(note == nil ? palette.ink3 : palette.ink2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(palette.accentSoft, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(palette.accentSoft2, lineWidth: 1) + ) + } + + private func chapterBoundaryButton(_ direction: PageTurnDirection) -> some View { + Button { + onChapterBoundary(direction) + } label: { + HStack(spacing: 8) { + Text(direction == .backward ? "上一章" : "下一章") + Text(direction == .backward ? "↑" : "↓") + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(palette.ink3) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(palette.line, style: StrokeStyle(dash: [4, 4])) + ) + } + .buttonStyle(.plain) + } + + private func visibilityProbe(for block: NativeChapterBlock) -> some View { + GeometryReader { geometry in + let frame = geometry.frame(in: .named(coordinateSpace)) + Color.clear.preference( + key: NativeParagraphFrameKey.self, + value: block.readerParagraph == nil ? [] : [ + NativeParagraphFrame( + blockID: block.id, + minY: frame.minY, + maxY: frame.maxY + ) + ] + ) + } + } + + /// Preference changes arrive on every scroll frame; the downstream + /// work (translation-cache lookups, chapter-text position math) is + /// far too heavy for that, so reports wait for the scroll to settle. + private func updateVisibleParagraphs(_ frames: [NativeParagraphFrame]) { + guard viewportHeight > 0 else { return } + settleTask.replace(Task { + try? await Task.sleep(for: .milliseconds(180)) + guard !Task.isCancelled else { return } + reportVisibleParagraphs(frames) + }) + } + + private func reportVisibleParagraphs(_ frames: [NativeParagraphFrame]) { + let visible = frames + .filter { $0.maxY >= 0 && $0.minY <= viewportHeight } + .sorted { lhs, rhs in lhs.minY < rhs.minY } + let paragraphs = visible.compactMap { paragraphByID[$0.blockID] } + guard !paragraphs.isEmpty else { return } + + if paragraphs != lastVisibleParagraphs { + lastVisibleParagraphs = paragraphs + onVisibleParagraphs(paragraphs) + } + + guard let first = visible.first else { return } + let prefix = document.prefix(before: first.blockID) + if prefix != lastPositionPrefix { + lastPositionPrefix = prefix + onPositionChange(prefix) + } + } + + private func scrollToLanding(with proxy: ScrollViewProxy) { + if let target = preciseLandingTarget() { + proxy.scrollTo( + target.blockID, + anchor: UnitPoint(x: 0.5, y: target.localProgress) + ) + return + } + guard let blockID = document.blockIDForLanding( + landing, + resumeUTF16Offset: resumeUTF16Offset, + chapterPlainText: chapterPlainText + ) else { return } + proxy.scrollTo(blockID, anchor: landing == .end ? .bottom : .top) + } + + private func preciseLandingTarget() -> (blockID: String, localProgress: CGFloat)? { + guard landing == .start, resumeUTF16Offset > 0 else { return nil } + guard let span = orderedTextSpans.first(where: { span in + resumeUTF16Offset >= span.chapterRange.lowerBound + && resumeUTF16Offset < span.chapterRange.upperBound + }) else { return nil } + let progress = min(max(span.localProgress(for: resumeUTF16Offset), 0.05), 0.95) + return (span.blockID, progress) + } + + private func resourceURL(for source: String) -> URL { + let cleaned = source.components(separatedBy: "#").first ?? source + if let absolute = URL(string: cleaned), absolute.scheme != nil { + return absolute + } + let chapterDirectory = basePath + .appendingPathComponent(chapter.href) + .deletingLastPathComponent() + if cleaned.hasPrefix("/") { + return basePath.appendingPathComponent(String(cleaned.dropFirst())) + } + return chapterDirectory.appendingPathComponent(cleaned) + } + + private func headingSize(_ level: Int) -> Double { + switch level { + case 1: fontSize + 10 + case 2: fontSize + 6 + case 3: fontSize + 3 + default: fontSize + 1 + } + } + + private func textLineSpacing(for size: Double) -> CGFloat { + max(3, CGFloat((lineSpacing - 1) * size)) + } + + private func horizontalPadding(for width: CGFloat) -> CGFloat { + #if os(macOS) + max(46, min(96, width * 0.12)) + #else + max(22, min(34, width * 0.08)) + #endif + } +} + +private struct NativeParagraphFrame: Equatable { + var blockID: String + var minY: CGFloat + var maxY: CGFloat +} + +private struct NativeParagraphFrameKey: PreferenceKey { + static var defaultValue: [NativeParagraphFrame] = [] + + static func reduce( + value: inout [NativeParagraphFrame], + nextValue: () -> [NativeParagraphFrame] + ) { + value.append(contentsOf: nextValue()) + } +} + +private struct NativeReaderImageView: View { + let url: URL + let alt: String? + + @Environment(\.emptyPalette) private var palette + @State private var loadTask: Task? + @State private var loadFailed = false + #if canImport(UIKit) + @State private var image: UIImage? + #elseif canImport(AppKit) + @State private var image: NSImage? + #endif + + var body: some View { + VStack(spacing: 7) { + Group { + #if canImport(UIKit) + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else { + placeholder + } + #elseif canImport(AppKit) + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + } else { + placeholder + } + #else + placeholder + #endif + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 14)) + + if isLoaded, let alt, !alt.isEmpty { + Text(alt) + .font(.system(size: 11.5, design: .serif)) + .foregroundStyle(palette.ink3) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + .onAppear(perform: loadImage) + .onDisappear { loadTask?.cancel() } + } + + private var isLoaded: Bool { + #if canImport(UIKit) || canImport(AppKit) + image != nil + #else + false + #endif + } + + private var placeholder: some View { + VStack(spacing: 6) { + Image(systemName: loadFailed ? "photo.badge.exclamationmark" : "photo") + .font(.system(size: 22)) + if loadFailed { + Text("插图缺失:EPUB 内未找到该图片") + .font(.system(size: 11)) + } + if let alt, !alt.isEmpty { + Text(alt) + .font(.system(size: 11)) + } + } + .foregroundStyle(palette.ink3) + .frame(maxWidth: .infinity, minHeight: 120) + .background(palette.side, in: RoundedRectangle(cornerRadius: 14)) + } + + private func loadImage() { + guard loadTask == nil else { return } + #if canImport(UIKit) + guard image == nil else { return } + #elseif canImport(AppKit) + guard image == nil else { return } + #endif + + loadTask = Task(priority: .utility) { + guard let data = try? Data(contentsOf: url), !Task.isCancelled else { + await MainActor.run { + if !Task.isCancelled { + loadFailed = true + } + loadTask = nil + } + return + } + #if canImport(UIKit) + let loadedImage = UIImage(data: data) + await MainActor.run { + if !Task.isCancelled { + image = loadedImage + loadFailed = loadedImage == nil + } + loadTask = nil + } + #elseif canImport(AppKit) + let loadedImage = NSImage(data: data) + await MainActor.run { + if !Task.isCancelled { + image = loadedImage + loadFailed = loadedImage == nil + } + loadTask = nil + } + #endif + } + } +} diff --git a/Empty/Views/NativeChapterSelectionSheet.swift b/Empty/Views/NativeChapterSelectionSheet.swift new file mode 100644 index 0000000..e8f4088 --- /dev/null +++ b/Empty/Views/NativeChapterSelectionSheet.swift @@ -0,0 +1,160 @@ +// +// NativeChapterSelectionSheet.swift +// Empty +// + +import Foundation +import SwiftUI + +struct NativeChapterSelectionSheet: View { + let title: String + let chapterText: String + let highlights: [HighlightPaint] + let fontSize: Double + let lineSpacing: Double + let focusUTF16Offset: Int + let initialSelection: ReaderSelection? + let onApply: (ReaderSelection) -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.emptyPalette) private var palette + @State private var draftSelection: ReaderSelection? + @State private var requestedSelectionRange: Range? + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { + NativeSelectableTextBlockView( + text: chapterText, + fontSize: max(16, fontSize), + lineSpacing: max(4, CGFloat((lineSpacing - 1) * max(16, fontSize))), + weight: .regular, + tone: .primary, + highlightRanges: chapterHighlightRanges(), + isDark: palette.isDark, + selectedRange: requestedSelectionRange, + scrollTargetOffset: requestedSelectionRange == nil ? focusUTF16Offset : nil, + clearSelection: false, + onSelectionChange: { range in + requestedSelectionRange = range + guard let range else { + draftSelection = nil + return + } + draftSelection = ReaderSelectionContext.selection( + in: chapterText, + utf16Range: range + ) + } + ) + .padding(.horizontal, 22) + .padding(.vertical, 20) + } + footer + } + .background(palette.window) + .onAppear { + draftSelection = initialSelection + requestedSelectionRange = initialSelection.flatMap { + ReaderSelectionContext.utf16Range(of: $0, in: chapterText) + } + } + #if os(iOS) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + #else + .frame(minWidth: 720, minHeight: 620) + #endif + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("跨段选取") + .font(.system(size: 18, weight: .bold, design: .serif)) + .foregroundStyle(palette.ink) + Text(title) + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink3) + } + Spacer() + Button("关闭") { dismiss() } + .buttonStyle(.plain) + .foregroundStyle(palette.ink3) + } + Text("在整章文本里连续选取,完成后回到阅读器继续解释、翻译或高亮。") + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink3) + } + .padding(.horizontal, 22) + .padding(.top, 18) + .padding(.bottom, 14) + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 10) { + if let draftSelection { + Text(draftSelection.text) + .font(.system(size: 13.5, weight: .medium, design: .serif)) + .foregroundStyle(palette.ink) + .lineLimit(3) + } else { + Text( + initialSelection == nil + ? "会自动定位到你刚才的阅读位置;再拖拽或长按即可跨段选择。" + : "已带入你当前的选区,可继续扩展或改选。" + ) + .font(.system(size: 12.5)) + .foregroundStyle(palette.ink3) + } + + HStack(spacing: 10) { + Button("取消") { dismiss() } + .buttonStyle(.plain) + .foregroundStyle(palette.ink3) + Spacer() + Button { + guard let draftSelection else { return } + onApply(draftSelection) + dismiss() + } label: { + Text("使用选区") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(palette.accent, in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(draftSelection == nil) + .opacity(draftSelection == nil ? 0.45 : 1) + } + } + .padding(.horizontal, 22) + .padding(.top, 14) + .padding(.bottom, 18) + .background(palette.side) + } + + private func chapterHighlightRanges() -> [NativeHighlightRange] { + highlights.compactMap { highlight -> NativeHighlightRange? in + let color = highlight.colorHex ?? HighlightColor.yellow.hex + if let start = highlight.startUTF16, + let end = highlight.endUTF16, + end > start { + return NativeHighlightRange( + range: start.. + var colorHex: UInt32 + + init(range: Range, colorHex: UInt32 = 0xE5C55E) { + self.range = range + self.colorHex = colorHex + } +} + +private nonisolated struct NativeHighlightSegment: Equatable { + var startUTF16: Int + var endUTF16: Int + var colorHex: UInt32 +} + +private nonisolated struct NativeTextSelectionRequest: Equatable { + var startUTF16: Int + var endUTF16: Int +} + +private nonisolated struct NativeTextRenderModel: Equatable { + var text: String + var fontSize: Double + var lineSpacing: CGFloat + var weight: NativeTextWeight + var tone: NativeTextTone + var isDark: Bool + var monospaced = false + var fontFamily: String? = nil + var useSerifDesign = true + var inkPrimaryHex: UInt32? = nil + var inkSecondaryHex: UInt32? = nil + var highlightSegments: [NativeHighlightSegment] +} + +struct NativeSelectableTextBlockView: View { + let text: String + let fontSize: Double + let lineSpacing: CGFloat + let weight: NativeTextWeight + let tone: NativeTextTone + let highlightRanges: [NativeHighlightRange] + let isDark: Bool + var monospaced: Bool = false + var fontFamily: String? = nil + var useSerifDesign: Bool = true + var inkPrimaryHex: UInt32? = nil + var inkSecondaryHex: UInt32? = nil + var selectedRange: Range? = nil + var scrollTargetOffset: Int? = nil + var clearSelection: Bool = false + var onSelectionChange: (Range?) -> Void = { _ in } + + var body: some View { + NativeSelectableTextRepresentable( + model: NativeTextRenderModel( + text: text, + fontSize: fontSize, + lineSpacing: lineSpacing, + weight: weight, + tone: tone, + isDark: isDark, + monospaced: monospaced, + fontFamily: fontFamily, + useSerifDesign: useSerifDesign, + inkPrimaryHex: inkPrimaryHex, + inkSecondaryHex: inkSecondaryHex, + highlightSegments: highlightRanges.map { + NativeHighlightSegment( + startUTF16: $0.range.lowerBound, + endUTF16: $0.range.upperBound, + colorHex: $0.colorHex + ) + } + ), + selectedRange: selectedRange.map { + NativeTextSelectionRequest(startUTF16: $0.lowerBound, endUTF16: $0.upperBound) + }, + scrollTargetOffset: scrollTargetOffset, + clearSelection: clearSelection, + onSelectionChange: onSelectionChange + ) + } +} + +private func makeAttributedText(from model: NativeTextRenderModel) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = model.lineSpacing + paragraph.paragraphSpacing = 0 + + let attributes: [NSAttributedString.Key: Any] = [ + .font: nativeFont( + size: model.fontSize, + weight: model.weight, + monospaced: model.monospaced, + family: model.fontFamily, + useSerifDesign: model.useSerifDesign + ), + .foregroundColor: nativeTextColor(model: model), + .paragraphStyle: paragraph, + ] + let attributed = NSMutableAttributedString(string: model.text, attributes: attributes) + let textLength = model.text.utf16.count + for segment in model.highlightSegments { + let clampedStart = max(0, min(segment.startUTF16, textLength)) + let clampedEnd = max(clampedStart, min(segment.endUTF16, textLength)) + guard clampedEnd > clampedStart else { continue } + let range = NSRange(location: clampedStart, length: clampedEnd - clampedStart) + // 视觉精修: highlights are an underline wash (底线染色), not a + // block of background — the text face stays uninterrupted. + attributed.addAttribute( + .underlineStyle, + value: NSUnderlineStyle.thick.rawValue, + range: range + ) + attributed.addAttribute( + .underlineColor, + value: NativePlatformColor( + hex: segment.colorHex, + alpha: model.isDark ? 0.66 : 0.82 + ), + range: range + ) + } + return attributed +} + +private func nativeTextColor(model: NativeTextRenderModel) -> NativePlatformColor { + switch model.tone { + case .primary: + if let hex = model.inkPrimaryHex { return NativePlatformColor(hex: hex) } + return NativePlatformColor(hex: model.isDark ? 0xEDE5D4 : 0x2A2419) + case .secondary: + if let hex = model.inkSecondaryHex { return NativePlatformColor(hex: hex) } + return NativePlatformColor(hex: model.isDark ? 0xC4B9A4 : 0x5C5443) + } +} + +#if canImport(UIKit) +private func nativeFont( + size: Double, + weight: NativeTextWeight, + monospaced: Bool = false, + family: String? = nil, + useSerifDesign: Bool = true +) -> UIFont { + let uiWeight: UIFont.Weight = weight == .bold ? .bold : .regular + if monospaced { + return UIFont.monospacedSystemFont(ofSize: size, weight: uiWeight) + } + if let family { + var descriptor = UIFontDescriptor(fontAttributes: [.family: family]) + if weight == .bold, + let bold = descriptor.withSymbolicTraits(.traitBold) { + descriptor = bold + } + let font = UIFont(descriptor: descriptor, size: size) + if font.familyName == family || font.fontName.contains(family.replacingOccurrences(of: " ", with: "")) { + return font + } + } + let base = UIFont.systemFont(ofSize: size, weight: uiWeight) + guard useSerifDesign, + let descriptor = base.fontDescriptor.withDesign(.serif) else { return base } + return UIFont(descriptor: descriptor, size: size) +} + +private struct NativeSelectableTextRepresentable: UIViewRepresentable { + let model: NativeTextRenderModel + let selectedRange: NativeTextSelectionRequest? + let scrollTargetOffset: Int? + let clearSelection: Bool + let onSelectionChange: (Range?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onSelectionChange: onSelectionChange) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView(frame: .zero) + textView.backgroundColor = .clear + textView.delegate = context.coordinator + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.maximumNumberOfLines = 0 + textView.textContainer.lineBreakMode = .byWordWrapping + textView.adjustsFontForContentSizeCategory = false + textView.dataDetectorTypes = [] + textView.contentInset = .zero + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.attributedText = makeAttributedText(from: model) + context.coordinator.model = model + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + if context.coordinator.model != model { + context.coordinator.programmaticChange = true + textView.attributedText = makeAttributedText(from: model) + context.coordinator.programmaticChange = false + context.coordinator.model = model + } + + if clearSelection, textView.selectedRange.length > 0 { + context.coordinator.selectionDebouncer.cancel() + context.coordinator.programmaticChange = true + textView.selectedRange = NSRange(location: NSNotFound, length: 0) + context.coordinator.programmaticChange = false + context.coordinator.appliedSelection = nil + } else if let selectedRange, + context.coordinator.appliedSelection != selectedRange { + context.coordinator.selectionDebouncer.cancel() + let range = clampedNSRange(for: selectedRange, textLength: model.text.utf16.count) + context.coordinator.programmaticChange = true + textView.selectedRange = range + textView.scrollRangeToVisible(range) + context.coordinator.programmaticChange = false + context.coordinator.appliedSelection = selectedRange + } else if let scrollTargetOffset, + context.coordinator.appliedScrollTarget != scrollTargetOffset { + let location = max(0, min(scrollTargetOffset, model.text.utf16.count)) + textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + context.coordinator.appliedScrollTarget = scrollTargetOffset + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UITextView, + context: Context + ) -> CGSize? { + let width = proposal.width ?? uiView.bounds.width + guard width > 0 else { return nil } + let size = uiView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + return CGSize(width: width, height: ceil(size.height)) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var model: NativeTextRenderModel? + var programmaticChange = false + var appliedSelection: NativeTextSelectionRequest? + var appliedScrollTarget: Int? + let onSelectionChange: (Range?) -> Void + let selectionDebouncer = SelectionChangeDebouncer() + + init(onSelectionChange: @escaping (Range?) -> Void) { + self.onSelectionChange = onSelectionChange + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !programmaticChange else { return } + let selection = textView.selectedRange + let range: Range? = + if selection.location != NSNotFound, selection.length > 0 { + selection.location..<(selection.location + selection.length) + } else { + nil + } + selectionDebouncer.submit(range, deliver: onSelectionChange) + } + } +} + +#elseif canImport(AppKit) +private func nativeFont( + size: Double, + weight: NativeTextWeight, + monospaced: Bool = false, + family: String? = nil, + useSerifDesign: Bool = true +) -> NSFont { + let nsWeight: NSFont.Weight = weight == .bold ? .bold : .regular + if monospaced { + return NSFont.monospacedSystemFont(ofSize: size, weight: nsWeight) + } + if let family { + var descriptor = NSFontDescriptor(fontAttributes: [.family: family]) + if weight == .bold { + descriptor = descriptor.withSymbolicTraits(.bold) + } + if let font = NSFont(descriptor: descriptor, size: size), + font.familyName == family { + return font + } + } + let base = NSFont.systemFont(ofSize: size, weight: nsWeight) + guard useSerifDesign, + let descriptor = base.fontDescriptor.withDesign(.serif), + let font = NSFont(descriptor: descriptor, size: size) else { + return base + } + return font +} + +private struct NativeSelectableTextRepresentable: NSViewRepresentable { + let model: NativeTextRenderModel + let selectedRange: NativeTextSelectionRequest? + let scrollTargetOffset: Int? + let clearSelection: Bool + let onSelectionChange: (Range?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onSelectionChange: onSelectionChange) + } + + func makeNSView(context: Context) -> NSTextView { + let textView = NSTextView(frame: .zero) + textView.delegate = context.coordinator + textView.drawsBackground = false + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.usesFindBar = false + textView.textContainerInset = NSSize(width: 0, height: 0) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.textContainer?.containerSize = NSSize( + width: 1, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.lineBreakMode = .byWordWrapping + textView.textStorage?.setAttributedString(makeAttributedText(from: model)) + context.coordinator.model = model + return textView + } + + func updateNSView(_ textView: NSTextView, context: Context) { + if context.coordinator.model != model { + context.coordinator.programmaticChange = true + textView.textStorage?.setAttributedString(makeAttributedText(from: model)) + context.coordinator.programmaticChange = false + context.coordinator.model = model + } + + if clearSelection, textView.selectedRange().length > 0 { + context.coordinator.selectionDebouncer.cancel() + context.coordinator.programmaticChange = true + textView.setSelectedRange(NSRange(location: NSNotFound, length: 0)) + context.coordinator.programmaticChange = false + context.coordinator.appliedSelection = nil + } else if let selectedRange, + context.coordinator.appliedSelection != selectedRange { + context.coordinator.selectionDebouncer.cancel() + let range = clampedNSRange(for: selectedRange, textLength: model.text.utf16.count) + context.coordinator.programmaticChange = true + textView.setSelectedRange(range) + textView.scrollRangeToVisible(range) + context.coordinator.programmaticChange = false + context.coordinator.appliedSelection = selectedRange + } else if let scrollTargetOffset, + context.coordinator.appliedScrollTarget != scrollTargetOffset { + let location = max(0, min(scrollTargetOffset, model.text.utf16.count)) + textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + context.coordinator.appliedScrollTarget = scrollTargetOffset + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: NSTextView, + context: Context + ) -> CGSize? { + let width = proposal.width ?? nsView.bounds.width + guard width > 0, + let textContainer = nsView.textContainer, + let layoutManager = nsView.layoutManager else { + return nil + } + textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + let used = layoutManager.usedRect(for: textContainer) + return CGSize( + width: width, + height: ceil(used.height + nsView.textContainerInset.height * 2 + 1) + ) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var model: NativeTextRenderModel? + var programmaticChange = false + var appliedSelection: NativeTextSelectionRequest? + var appliedScrollTarget: Int? + let onSelectionChange: (Range?) -> Void + let selectionDebouncer = SelectionChangeDebouncer() + + init(onSelectionChange: @escaping (Range?) -> Void) { + self.onSelectionChange = onSelectionChange + } + + func textViewDidChangeSelection(_ notification: Notification) { + guard !programmaticChange, + let textView = notification.object as? NSTextView else { return } + let selection = textView.selectedRange() + let range: Range? = + if selection.location != NSNotFound, selection.length > 0 { + selection.location..<(selection.location + selection.length) + } else { + nil + } + selectionDebouncer.submit(range, deliver: onSelectionChange) + } + } +} +#endif + +private func clampedNSRange( + for request: NativeTextSelectionRequest, + textLength: Int +) -> NSRange { + let lower = max(0, min(request.startUTF16, textLength)) + let upper = max(lower, min(request.endUTF16, textLength)) + return NSRange(location: lower, length: upper - lower) +} + +private extension NativePlatformColor { + convenience init(hex: UInt32, alpha: Double = 1) { + self.init( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: alpha + ) + } +} diff --git a/Empty/Views/PDFReaderView.swift b/Empty/Views/PDFReaderView.swift index 80f748c..ffa32f5 100644 --- a/Empty/Views/PDFReaderView.swift +++ b/Empty/Views/PDFReaderView.swift @@ -24,6 +24,12 @@ struct PDFReaderView: View { let documentURL: URL @Binding var pageIndex: Int var highlights: [HighlightPaint] = [] + /// 夜间反色 (smart-ish: hue-rotated so colors keep their identity). + var nightInverted: Bool = false + /// Per-book zoom memory: UserDefaults key, nil disables. + var zoomMemoryKey: String? = nil + /// 双页 spread (Mac). + var twoUp: Bool = false var onPageChange: (Int) -> Void = { _ in } var onSelectionChange: (ReaderSelection?) -> Void = { _ in } @@ -32,9 +38,23 @@ struct PDFReaderView: View { documentURL: documentURL, pageIndex: $pageIndex, highlights: highlights, + zoomMemoryKey: zoomMemoryKey, + twoUp: twoUp, onPageChange: onPageChange, onSelectionChange: onSelectionChange ) + .colorInvert(enabled: nightInverted) + } +} + +private extension View { + @ViewBuilder + func colorInvert(enabled: Bool) -> some View { + if enabled { + self.colorInvert().hueRotation(.degrees(180)) + } else { + self + } } } @@ -90,6 +110,8 @@ nonisolated enum PDFSelectionContext { final class PDFReaderCoordinator: NSObject { var pageIndex: Int = 0 var paints: [HighlightPaint] = [] + /// 按书缩放记忆: persisted scale factor under this defaults key. + var zoomMemoryKey: String? var onPageChange: (Int) -> Void = { _ in } var onSelectionChange: (ReaderSelection?) -> Void = { _ in } @@ -115,6 +137,14 @@ final class PDFReaderCoordinator: NSObject { self.pageIndex = index self.onPageChange(index) }) + observations.append(NotificationCenter.default.addObserver( + forName: .PDFViewScaleChanged, + object: pdfView, + queue: .main + ) { [weak self, weak pdfView] _ in + guard let self, let pdfView, let key = self.zoomMemoryKey else { return } + UserDefaults.standard.set(Double(pdfView.scaleFactor), forKey: key) + }) observations.append(NotificationCenter.default.addObserver( forName: .PDFViewSelectionChanged, object: pdfView, @@ -141,6 +171,16 @@ final class PDFReaderCoordinator: NSObject { selectionDebounce = nil } + /// Restores the book's remembered zoom (must run after the document + /// is set; `autoScales` wins until a stored value exists). + func restoreZoom(in pdfView: PDFView) { + guard let key = zoomMemoryKey else { return } + let stored = UserDefaults.standard.double(forKey: key) + guard stored > 0.05 else { return } + pdfView.autoScales = false + pdfView.scaleFactor = CGFloat(stored) + } + func applyPage(in pdfView: PDFView, index: Int) { guard let document = pdfView.document, index >= 0, @@ -221,6 +261,8 @@ struct PDFReaderRepresentable: UIViewRepresentable { let documentURL: URL @Binding var pageIndex: Int let highlights: [HighlightPaint] + var zoomMemoryKey: String? = nil + var twoUp: Bool = false let onPageChange: (Int) -> Void let onSelectionChange: (ReaderSelection?) -> Void @@ -231,17 +273,19 @@ struct PDFReaderRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> PDFView { let pdfView = PDFView() pdfView.autoScales = true - pdfView.displayMode = .singlePage + pdfView.displayMode = twoUp ? .twoUp : .singlePage pdfView.displayDirection = .horizontal pdfView.usePageViewController(true, withViewOptions: nil) pdfView.backgroundColor = .clear pdfView.document = PDFDocument(url: documentURL) context.coordinator.pageIndex = pageIndex context.coordinator.paints = highlights + context.coordinator.zoomMemoryKey = zoomMemoryKey syncCallbacks(context.coordinator) context.coordinator.attach(to: pdfView) context.coordinator.applyPage(in: pdfView, index: pageIndex) context.coordinator.applyHighlights(on: pdfView) + context.coordinator.restoreZoom(in: pdfView) return pdfView } @@ -249,6 +293,10 @@ struct PDFReaderRepresentable: UIViewRepresentable { if pdfView.document?.documentURL != documentURL { pdfView.document = PDFDocument(url: documentURL) } + let mode: PDFDisplayMode = twoUp ? .twoUp : .singlePage + if pdfView.displayMode != mode { + pdfView.displayMode = mode + } syncCallbacks(context.coordinator) if context.coordinator.pageIndex != pageIndex { context.coordinator.pageIndex = pageIndex @@ -277,6 +325,8 @@ struct PDFReaderRepresentable: NSViewRepresentable { let documentURL: URL @Binding var pageIndex: Int let highlights: [HighlightPaint] + var zoomMemoryKey: String? = nil + var twoUp: Bool = false let onPageChange: (Int) -> Void let onSelectionChange: (ReaderSelection?) -> Void @@ -287,16 +337,18 @@ struct PDFReaderRepresentable: NSViewRepresentable { func makeNSView(context: Context) -> PDFView { let pdfView = PDFView() pdfView.autoScales = true - pdfView.displayMode = .singlePage + pdfView.displayMode = twoUp ? .twoUp : .singlePage pdfView.displayDirection = .horizontal pdfView.backgroundColor = .clear pdfView.document = PDFDocument(url: documentURL) context.coordinator.pageIndex = pageIndex context.coordinator.paints = highlights + context.coordinator.zoomMemoryKey = zoomMemoryKey syncCallbacks(context.coordinator) context.coordinator.attach(to: pdfView) context.coordinator.applyPage(in: pdfView, index: pageIndex) context.coordinator.applyHighlights(on: pdfView) + context.coordinator.restoreZoom(in: pdfView) DispatchQueue.main.async { pdfView.window?.makeFirstResponder(pdfView) } @@ -307,6 +359,10 @@ struct PDFReaderRepresentable: NSViewRepresentable { if pdfView.document?.documentURL != documentURL { pdfView.document = PDFDocument(url: documentURL) } + let mode: PDFDisplayMode = twoUp ? .twoUp : .singlePage + if pdfView.displayMode != mode { + pdfView.displayMode = mode + } syncCallbacks(context.coordinator) if context.coordinator.pageIndex != pageIndex { context.coordinator.pageIndex = pageIndex diff --git a/Empty/Views/PagedChapterReaderView.swift b/Empty/Views/PagedChapterReaderView.swift new file mode 100644 index 0000000..baa0871 --- /dev/null +++ b/Empty/Views/PagedChapterReaderView.swift @@ -0,0 +1,1250 @@ +// +// PagedChapterReaderView.swift +// Empty +// +// 微信读书-style horizontal paging for the EPUB reader: the chapter lays +// out once into fixed-size TextKit pages (measured offscreen, one text +// container per page), and the reader swipes, edge-taps or arrow-keys +// between pages. Selection, highlights, 双语/导读 notes and position +// reporting ride the same chapter-offset pipeline as the scrolling +// reader. iOS pages with a TabView; macOS pages a single text view with +// click zones and keyboard navigation. +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +private typealias PagedFont = UIFont +private typealias PagedColor = UIColor +private typealias PagedImage = UIImage +#elseif canImport(AppKit) +import AppKit +private typealias PagedFont = NSFont +private typealias PagedColor = NSColor +private typealias PagedImage = NSImage +#endif + +// MARK: - Composition + +/// One block's footprint inside the composed attributed string. +private struct PagedRun { + var blockID: String + var paragraph: ReaderParagraph? + /// Full range in the attributed string (marker included). + var attrRange: NSRange + /// Where the block's own text starts (after any list marker). + var textLocation: Int + /// The block's exact UTF-16 range in the chapter plain text. + var chapterRange: Range? +} + +/// Page boundaries are measured ONCE with an offscreen layout manager; +/// each visible page then gets its own independent UITextView over the +/// page's substring. Sharing live text containers with SwiftUI-managed +/// text views breaks as TabView creates and destroys pages. +private final class PaginatedChapter { + let storage: NSTextStorage + let pageRanges: [NSRange] + let runs: [PagedRun] + let pageSize: CGSize + let version: Int + + init( + storage: NSTextStorage, + pageRanges: [NSRange], + runs: [PagedRun], + pageSize: CGSize, + version: Int + ) { + self.storage = storage + self.pageRanges = pageRanges + self.runs = runs + self.pageSize = pageSize + self.version = version + } + + var pageCount: Int { pageRanges.count } + + func characterRange(forPage index: Int) -> NSRange { + guard pageRanges.indices.contains(index) else { return NSRange(location: 0, length: 0) } + return pageRanges[index] + } + + func pageText(_ index: Int) -> NSAttributedString { + let range = characterRange(forPage: index) + guard range.length > 0, + range.location + range.length <= storage.length else { + return NSAttributedString(string: " ") + } + return storage.attributedSubstring(from: range) + } + + /// Chapter UTF-16 offset of the first mapped character on a page. + func chapterOffset(forPage index: Int) -> Int? { + let pageRange = characterRange(forPage: index) + for run in runs { + guard let chapterRange = run.chapterRange else { continue } + let textEnd = run.attrRange.location + run.attrRange.length + guard textEnd > pageRange.location else { continue } + let attrStart = max(run.textLocation, pageRange.location) + let delta = max(0, attrStart - run.textLocation) + return min(chapterRange.lowerBound + delta, chapterRange.upperBound) + } + return nil + } + + /// First page whose content reaches the chapter offset. + func page(forChapterOffset offset: Int) -> Int? { + guard let run = runs.last(where: { run in + guard let range = run.chapterRange else { return false } + return range.lowerBound <= offset + }), let chapterRange = run.chapterRange else { return nil } + let delta = max(0, min(offset, chapterRange.upperBound) - chapterRange.lowerBound) + let attrLocation = run.textLocation + delta + for (index, pageRange) in pageRanges.enumerated() { + if attrLocation < pageRange.location + pageRange.length { + return index + } + } + return pageRanges.indices.last + } + + /// Paragraphs whose runs intersect the page. + func paragraphs(onPage index: Int) -> [ReaderParagraph] { + let pageRange = characterRange(forPage: index) + return runs.compactMap { run in + guard let paragraph = run.paragraph, + NSIntersectionRange(run.attrRange, pageRange).length > 0 else { + return nil + } + return paragraph + } + } + + /// Maps a storage-coordinate selection to the chapter's UTF-16 range. + func chapterRange(forAttrRange selection: NSRange) -> Range? { + let selectionEnd = selection.location + selection.length + let mapped = runs.compactMap { run -> Range? in + guard let chapterRange = run.chapterRange else { return nil } + let runTextEnd = run.attrRange.location + run.attrRange.length + let lower = max(selection.location, run.textLocation) + let upper = min(selectionEnd, runTextEnd) + guard upper > lower else { return nil } + let start = chapterRange.lowerBound + (lower - run.textLocation) + let end = min(chapterRange.lowerBound + (upper - run.textLocation), chapterRange.upperBound) + guard end > start else { return nil } + return start.. PaginatedChapter { + let (attributed, runs) = buildAttributed() + paintHighlights(on: attributed, runs: runs) + + // Offscreen measurement pass: page boundaries only. + let measureStorage = NSTextStorage(attributedString: attributed) + let layoutManager = NSLayoutManager() + measureStorage.addLayoutManager(layoutManager) + + var pageRanges: [NSRange] = [] + repeat { + let container = NSTextContainer(size: pageSize) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + let glyphRange = layoutManager.glyphRange(for: container) + let charRange = layoutManager.characterRange( + forGlyphRange: glyphRange, + actualGlyphRange: nil + ) + pageRanges.append(charRange) + if glyphRange.location + glyphRange.length >= layoutManager.numberOfGlyphs { + break + } + } while pageRanges.count < 1200 + + if pageRanges.isEmpty { + pageRanges = [NSRange(location: 0, length: attributed.length)] + } + + return PaginatedChapter( + storage: NSTextStorage(attributedString: attributed), + pageRanges: pageRanges, + runs: runs, + pageSize: pageSize, + version: version + ) + } + + // MARK: Attributed text + + private var inkPrimary: PagedColor { + let hexes = appearance.theme.inkHexes(baseIsDark: isDarkCanvas) + return PagedColor(hex: hexes.primary) + } + + private var inkSecondary: PagedColor { + let hexes = appearance.theme.inkHexes(baseIsDark: isDarkCanvas) + return PagedColor(hex: hexes.secondary) + } + + private var accent: PagedColor { + PagedColor(hex: appearance.theme.isDarkCanvas(baseIsDark: isDarkCanvas) ? 0xD86B47 : 0xB5482A) + } + + private func bodyFont(size: Double, bold: Bool = false) -> PagedFont { + #if canImport(UIKit) + if let family = appearance.font.familyName { + var descriptor = UIFontDescriptor(fontAttributes: [.family: family]) + if bold, let boldDescriptor = descriptor.withSymbolicTraits(.traitBold) { + descriptor = boldDescriptor + } + let font = UIFont(descriptor: descriptor, size: size) + if font.familyName == family { return font } + } + let base = UIFont.systemFont(ofSize: size, weight: bold ? .bold : .regular) + guard appearance.font.usesSerifDesign, + let descriptor = base.fontDescriptor.withDesign(.serif) else { + return base + } + return UIFont(descriptor: descriptor, size: size) + #else + if let family = appearance.font.familyName { + var descriptor = NSFontDescriptor(fontAttributes: [.family: family]) + if bold { + descriptor = descriptor.withSymbolicTraits(.bold) + } + if let font = NSFont(descriptor: descriptor, size: size), + font.familyName == family { + return font + } + } + let base = NSFont.systemFont(ofSize: size, weight: bold ? .bold : .regular) + guard appearance.font.usesSerifDesign, + let descriptor = base.fontDescriptor.withDesign(.serif), + let font = NSFont(descriptor: descriptor, size: size) else { + return base + } + return font + #endif + } + + private func paragraphStyle( + spacingBefore: CGFloat = 0, + spacing: CGFloat, + headIndent: CGFloat = 0 + ) -> NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.lineSpacing = max(3, CGFloat((lineSpacing - 1) * fontSize)) + style.paragraphSpacing = spacing + style.paragraphSpacingBefore = spacingBefore + style.headIndent = headIndent + style.firstLineHeadIndent = headIndent + return style + } + + private func buildAttributed() -> (NSMutableAttributedString, [PagedRun]) { + let result = NSMutableAttributedString() + var runs: [PagedRun] = [] + + func appendBlockText( + _ block: NativeChapterBlock, + text: String, + marker: String = "", + attributes: [NSAttributedString.Key: Any] + ) { + let start = result.length + let full = marker + text + result.append(NSAttributedString(string: full, attributes: attributes)) + runs.append(PagedRun( + blockID: block.id, + paragraph: block.readerParagraph, + attrRange: NSRange(location: start, length: full.utf16.count), + textLocation: start + marker.utf16.count, + chapterRange: blockSpans[block.id]?.chapterRange + )) + result.append(NSAttributedString(string: "\n", attributes: attributes)) + } + + func appendNote(for paragraph: ReaderParagraph) { + guard inlineMode != .none else { return } + let note = inlineNotes.first(where: { $0.idx == paragraph.idx }) + let label = inlineMode == .bilingual ? "译" : "导读" + + // Untranslated paragraphs show a quiet pending/failed line + // instead of silently nothing — the retry pipeline fills it + // in and the page re-anchors in place. + let body: String + let bodyColor: PagedColor + if let note, !note.failed, !note.text.isEmpty { + body = note.text + bodyColor = inkSecondary + } else if let note, note.failed { + body = "暂不可用,将自动重试。" + bodyColor = inkSecondary.withAlphaComponent(0.7) + } else { + body = "⟳ 生成中…" + bodyColor = inkSecondary.withAlphaComponent(0.55) + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: bodyFont(size: max(13, fontSize - 2.5)), + .foregroundColor: bodyColor, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.7, headIndent: 14), + ] + let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: PagedFont.systemFont(ofSize: max(10, fontSize - 7), weight: .bold), + .foregroundColor: accent, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.7, headIndent: 14), + ] + result.append(NSAttributedString(string: "\(label) · ", attributes: labelAttributes)) + result.append(NSAttributedString(string: body + "\n", attributes: attributes)) + } + + for block in document.blocks { + switch block { + case .heading(_, let level, let text): + let size = fontSize + [10.0, 6, 3, 1, 1, 1][min(max(level - 1, 0), 5)] + appendBlockText(block, text: text, attributes: [ + .font: bodyFont(size: size, bold: true), + .foregroundColor: inkPrimary, + .paragraphStyle: paragraphStyle( + spacingBefore: fontSize * (level <= 2 ? 1.1 : 0.7), + spacing: fontSize * 0.7 + ), + ]) + + case .paragraph(_, _, let text): + appendBlockText(block, text: text, attributes: [ + .font: bodyFont(size: fontSize), + .foregroundColor: inkPrimary, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.62), + ]) + if let paragraph = block.readerParagraph { appendNote(for: paragraph) } + + case .quote(_, _, let text): + appendBlockText(block, text: text, attributes: [ + .font: bodyFont(size: fontSize), + .foregroundColor: inkSecondary, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.62, headIndent: 16), + ]) + if let paragraph = block.readerParagraph { appendNote(for: paragraph) } + + case .listItem(_, _, let text, let level, let marker): + appendBlockText(block, text: text, marker: marker + " ", attributes: [ + .font: bodyFont(size: fontSize), + .foregroundColor: inkPrimary, + .paragraphStyle: paragraphStyle( + spacing: fontSize * 0.45, + headIndent: CGFloat(max(0, level - 1)) * 18 + 4 + ), + ]) + if let paragraph = block.readerParagraph { appendNote(for: paragraph) } + + case .footnote(_, _, let text): + appendBlockText(block, text: text, marker: "注 · ", attributes: [ + .font: bodyFont(size: max(12, fontSize - 2.5)), + .foregroundColor: inkSecondary, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.55, headIndent: 10), + ]) + if let paragraph = block.readerParagraph { appendNote(for: paragraph) } + + case .code(_, let text): + appendBlockText(block, text: text, attributes: [ + .font: PagedFont.monospacedSystemFont(ofSize: max(12, fontSize - 3), weight: .regular), + .foregroundColor: inkPrimary, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.62, headIndent: 8), + ]) + + case .table(_, let rows): + let text = rows + .map { $0.joined(separator: " · ") } + .joined(separator: "\n") + guard !text.isEmpty else { continue } + result.append(NSAttributedString( + string: text + "\n", + attributes: [ + .font: PagedFont.monospacedSystemFont(ofSize: max(11, fontSize - 4), weight: .regular), + .foregroundColor: inkSecondary, + .paragraphStyle: paragraphStyle(spacing: fontSize * 0.62, headIndent: 8), + ] + )) + + case .image(_, let source, let alt): + appendImage(source: source, alt: alt, into: result) + } + } + + if result.length == 0 { + result.append(NSAttributedString( + string: " ", + attributes: [.font: bodyFont(size: fontSize)] + )) + } + return (result, runs) + } + + private func appendImage(source: String, alt: String?, into result: NSMutableAttributedString) { + let centered = NSMutableParagraphStyle() + centered.alignment = .center + centered.paragraphSpacing = fontSize * 0.8 + centered.paragraphSpacingBefore = fontSize * 0.5 + + if let image = loadImage(source: source) { + let maxWidth = max(40, pageSize.width - 2) + let maxHeight = max(80, pageSize.height * 0.62) + let scale = min(1, min(maxWidth / image.size.width, maxHeight / image.size.height)) + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: 0, + width: image.size.width * scale, + height: image.size.height * scale + ) + let attachmentString = NSMutableAttributedString(attachment: attachment) + attachmentString.addAttribute( + .paragraphStyle, + value: centered, + range: NSRange(location: 0, length: attachmentString.length) + ) + result.append(attachmentString) + result.append(NSAttributedString(string: "\n", attributes: [.paragraphStyle: centered])) + } + + if let alt, !alt.isEmpty { + result.append(NSAttributedString( + string: alt + "\n", + attributes: [ + .font: bodyFont(size: max(11, fontSize - 5)), + .foregroundColor: inkSecondary, + .paragraphStyle: centered, + ] + )) + } + } + + private func loadImage(source: String) -> PagedImage? { + let cleaned = source.components(separatedBy: "#").first ?? source + let chapterDirectory = basePath + .appendingPathComponent(chapterHref) + .deletingLastPathComponent() + let url: URL + if cleaned.hasPrefix("/") { + url = basePath.appendingPathComponent(String(cleaned.dropFirst())) + } else { + url = chapterDirectory.appendingPathComponent(cleaned) + } + return PagedImage(contentsOfFile: url.path) + } + + private func paintHighlights(on attributed: NSMutableAttributedString, runs: [PagedRun]) { + let darkCanvas = appearance.theme.isDarkCanvas(baseIsDark: isDarkCanvas) + for highlight in highlights { + guard let start = highlight.startUTF16, + let end = highlight.endUTF16, + end > start else { continue } + let tint = PagedColor(hex: highlight.colorHex ?? HighlightColor.yellow.hex) + .withAlphaComponent(darkCanvas ? 0.66 : 0.82) + for run in runs { + guard let chapterRange = run.chapterRange, + let local = NativeTextBlockSpan( + blockID: run.blockID, + chapterRange: chapterRange, + paragraphInfo: nil + ).localRange(intersecting: start.. lower else { continue } + let range = NSRange(location: run.textLocation + lower, length: upper - lower) + // 底线染色 per the visual-polish spec, never a block. + attributed.addAttribute( + .underlineStyle, + value: NSUnderlineStyle.thick.rawValue, + range: range + ) + attributed.addAttribute(.underlineColor, value: tint, range: range) + } + } + } +} + +// MARK: - SwiftUI view (iOS pager) + +#if os(iOS) +struct PagedChapterReaderView: View { + let chapter: EPUBChapter + let basePath: URL + let fontSize: Double + let lineSpacing: Double + let landing: ChapterLanding + let resumeUTF16Offset: Int + let chapterPlainText: String? + let highlights: [HighlightPaint] + let inlineMode: InlineNoteKind + let inlineNotes: [InlineNotePaint] + var appearance: ReaderAppearance = ReaderAppearance() + var selectionActive: Bool = false + var onTap: () -> Void = {} + var onChapterBoundary: (PageTurnDirection) -> Void = { _ in } + var onSelectionChange: (ReaderSelection?) -> Void = { _ in } + var onPositionChange: (String) -> Void = { _ in } + var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } + var onPageInfo: (Int, Int) -> Void = { _, _ in } + + private let document: NativeChapterDocument + private let blockSpans: [String: NativeTextBlockSpan] + + @Environment(\.emptyPalette) private var palette + @State private var paginated: PaginatedChapter? + @State private var pageIndex = 0 + @State private var composeVersion = 0 + @State private var lastComposeKey: ComposeKey? + + private struct ComposeKey: Equatable { + var width: CGFloat + var height: CGFloat + var fontSize: Double + var lineSpacing: Double + var appearance: ReaderAppearance + var isDark: Bool + var inlineMode: InlineNoteKind + var noteFingerprint: Int + var highlightFingerprint: Int + } + + init( + chapter: EPUBChapter, + basePath: URL, + fontSize: Double, + lineSpacing: Double, + landing: ChapterLanding, + resumeUTF16Offset: Int, + chapterPlainText: String?, + highlights: [HighlightPaint], + inlineMode: InlineNoteKind, + inlineNotes: [InlineNotePaint], + appearance: ReaderAppearance = ReaderAppearance(), + selectionActive: Bool = false, + onTap: @escaping () -> Void = {}, + onChapterBoundary: @escaping (PageTurnDirection) -> Void = { _ in }, + onSelectionChange: @escaping (ReaderSelection?) -> Void = { _ in }, + onPositionChange: @escaping (String) -> Void = { _ in }, + onVisibleParagraphs: @escaping ([ReaderParagraph]) -> Void = { _ in }, + onPageInfo: @escaping (Int, Int) -> Void = { _, _ in } + ) { + self.chapter = chapter + self.basePath = basePath + self.fontSize = fontSize + self.lineSpacing = lineSpacing + self.landing = landing + self.resumeUTF16Offset = resumeUTF16Offset + self.chapterPlainText = chapterPlainText + self.highlights = highlights + self.inlineMode = inlineMode + self.inlineNotes = inlineNotes + self.appearance = appearance + self.selectionActive = selectionActive + self.onTap = onTap + self.onChapterBoundary = onChapterBoundary + self.onSelectionChange = onSelectionChange + self.onPositionChange = onPositionChange + self.onVisibleParagraphs = onVisibleParagraphs + self.onPageInfo = onPageInfo + + let parsed = NativeChapterParser.parse(chapter) + self.document = parsed + self.blockSpans = NativeChapterParser.resolvedSpans( + for: chapter, document: parsed, chapterPlainText: chapterPlainText + ) + } + + var body: some View { + GeometryReader { geometry in + let textSize = CGSize( + width: max(40, geometry.size.width - horizontalInset * 2), + height: max(80, geometry.size.height - verticalInset * 2) + ) + ZStack { + palette.window.ignoresSafeArea() + + if let paginated { + TabView(selection: $pageIndex) { + ForEach(0.. 0 ? location.x / geometry.size.width : 0.5) + } + .onAppear { + recomposeIfNeeded(textSize: textSize) + } + .onChange(of: composeKey(textSize: textSize)) { _, _ in + recomposeIfNeeded(textSize: textSize) + } + .onChange(of: pageIndex) { _, newIndex in + reportPage(newIndex) + } + } + } + + private var horizontalInset: CGFloat { 24 } + private var verticalInset: CGFloat { 14 } + + /// 微信读书-style tap navigation: left quarter back, right quarter + /// forward, middle toggles the chrome. A pending selection makes the + /// first tap dismiss it instead of turning a page. + private func handleTap(fraction: CGFloat) { + if selectionActive { + onSelectionChange(nil) + return + } + if fraction < 0.26 { + turnPage(-1) + } else if fraction > 0.74 { + turnPage(1) + } else { + onSelectionChange(nil) + onTap() + } + } + + private func turnPage(_ delta: Int) { + guard let paginated else { return } + let target = pageIndex + delta + if target < 0 { + onChapterBoundary(.backward) + return + } + if target >= paginated.pageCount { + onChapterBoundary(.forward) + return + } + withAnimation(.easeInOut(duration: 0.18)) { + pageIndex = target + } + } + + private func composeKey(textSize: CGSize) -> ComposeKey { + ComposeKey( + width: textSize.width, + height: textSize.height, + fontSize: fontSize, + lineSpacing: lineSpacing, + appearance: appearance, + isDark: palette.isDark, + inlineMode: inlineMode, + noteFingerprint: inlineNotes.reduce(0) { partial, note in + partial &+ note.idx &* 31 &+ note.text.utf16.count &+ (note.failed ? 7 : 0) + }, + highlightFingerprint: highlights.reduce(0) { partial, paint in + partial &+ (paint.startUTF16 ?? 0) &* 31 &+ (paint.endUTF16 ?? 0) + } + ) + } + + private func recomposeIfNeeded(textSize: CGSize) { + let key = composeKey(textSize: textSize) + guard key != lastComposeKey else { return } + lastComposeKey = key + + // Keep the reader's place across re-layout (notes arriving, + // font/theme changes, rotation). + let anchorOffset = paginated.flatMap { $0.chapterOffset(forPage: pageIndex) } + + let composer = PageComposer( + document: document, + blockSpans: blockSpans, + chapterPlainText: chapterPlainText, + basePath: basePath, + chapterHref: chapter.href, + fontSize: fontSize, + lineSpacing: lineSpacing, + appearance: appearance, + isDarkCanvas: palette.isDark, + inlineMode: inlineMode, + inlineNotes: inlineNotes, + highlights: highlights, + pageSize: textSize + ) + composeVersion += 1 + let next = composer.compose(version: composeVersion) + paginated = next + + let landingPage: Int + if let anchorOffset, let page = next.page(forChapterOffset: anchorOffset) { + landingPage = page + } else { + switch landing { + case .end: + landingPage = max(0, next.pageCount - 1) + case .start: + if resumeUTF16Offset > 0, + let page = next.page(forChapterOffset: resumeUTF16Offset) { + landingPage = page + } else { + landingPage = 0 + } + } + } + pageIndex = min(landingPage, max(0, next.pageCount - 1)) + reportPage(pageIndex) + } + + private func reportPage(_ index: Int) { + guard let paginated else { return } + onPageInfo(index, paginated.pageCount) + + let paragraphs = paginated.paragraphs(onPage: index) + if !paragraphs.isEmpty { + onVisibleParagraphs(paragraphs) + } + + if let offset = paginated.chapterOffset(forPage: index) { + let source = chapterPlainText ?? document.plainText + let utf16 = Array(source.utf16) + let clamped = max(0, min(offset, utf16.count)) + onPositionChange(String(decoding: utf16[0.. 0, let paginated else { + onSelectionChange(nil) + return + } + guard let chapterRange = paginated.chapterRange(forAttrRange: attrRange) else { + onSelectionChange(nil) + return + } + let source = chapterPlainText ?? document.plainText + onSelectionChange( + ReaderSelectionContext.selection(in: source, utf16Range: chapterRange) + ) + } +} + +// MARK: - Page text view + +private struct PageTextView: UIViewRepresentable { + /// The page's own slice of the chapter — every page view is fully + /// independent, so TabView can create/destroy pages freely. + let text: NSAttributedString + /// Where this page starts in the composed chapter string; selection + /// ranges are reported back in chapter-storage coordinates. + let globalLocation: Int + let clearSelection: Bool + let onSelectionChange: (NSRange?) -> Void + /// Tap location as an x-fraction of the page width (page turning). + let onTapAt: (CGFloat) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + globalLocation: globalLocation, + onSelectionChange: onSelectionChange, + onTapAt: onTapAt + ) + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView(frame: .zero) + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.dataDetectorTypes = [] + textView.clipsToBounds = true + textView.delegate = context.coordinator + textView.attributedText = text + + // UITextView's own recognizers swallow single taps, so page + // turning listens directly on the view; touches still reach the + // text interactions (long-press selection keeps working). + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.cancelsTouchesInView = false + textView.addGestureRecognizer(tap) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + context.coordinator.globalLocation = globalLocation + context.coordinator.onSelectionChange = onSelectionChange + context.coordinator.onTapAt = onTapAt + if !textView.attributedText.isEqual(to: text) { + context.coordinator.programmatic = true + textView.attributedText = text + context.coordinator.programmatic = false + } + if clearSelection, textView.selectedRange.length > 0 { + context.coordinator.programmatic = true + textView.selectedRange = NSRange(location: NSNotFound, length: 0) + context.coordinator.programmatic = false + } + } + + final class Coordinator: NSObject, UITextViewDelegate { + var globalLocation: Int + var onSelectionChange: (NSRange?) -> Void + var onTapAt: (CGFloat) -> Void + var programmatic = false + + init( + globalLocation: Int, + onSelectionChange: @escaping (NSRange?) -> Void, + onTapAt: @escaping (CGFloat) -> Void + ) { + self.globalLocation = globalLocation + self.onSelectionChange = onSelectionChange + self.onTapAt = onTapAt + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !programmatic else { return } + let range = textView.selectedRange + guard range.location != NSNotFound, range.length > 0 else { + onSelectionChange(nil) + return + } + onSelectionChange(NSRange(location: globalLocation + range.location, length: range.length)) + } + + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard let view = recognizer.view, view.bounds.width > 0 else { return } + let fraction = recognizer.location(in: view).x / view.bounds.width + onTapAt(fraction) + } + } +} +#endif + +// MARK: - macOS pager + +#if os(macOS) +struct MacPagedChapterReaderView: View { + let chapter: EPUBChapter + let basePath: URL + let fontSize: Double + let lineSpacing: Double + let landing: ChapterLanding + let resumeUTF16Offset: Int + let chapterPlainText: String? + let highlights: [HighlightPaint] + let inlineMode: InlineNoteKind + let inlineNotes: [InlineNotePaint] + var appearance: ReaderAppearance = ReaderAppearance() + var selectionActive: Bool = false + var onTap: () -> Void = {} + var onChapterBoundary: (PageTurnDirection) -> Void = { _ in } + var onSelectionChange: (ReaderSelection?) -> Void = { _ in } + var onPositionChange: (String) -> Void = { _ in } + var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } + var onPageInfo: (Int, Int) -> Void = { _, _ in } + + private let document: NativeChapterDocument + private let blockSpans: [String: NativeTextBlockSpan] + + @Environment(\.emptyPalette) private var palette + @State private var paginated: PaginatedChapter? + @State private var pageIndex = 0 + @State private var composeVersion = 0 + @State private var lastComposeKey: ComposeKey? + @FocusState private var pageFocused: Bool + + private struct ComposeKey: Equatable { + var width: CGFloat + var height: CGFloat + var fontSize: Double + var lineSpacing: Double + var appearance: ReaderAppearance + var isDark: Bool + var inlineMode: InlineNoteKind + var noteFingerprint: Int + var highlightFingerprint: Int + } + + init( + chapter: EPUBChapter, + basePath: URL, + fontSize: Double, + lineSpacing: Double, + landing: ChapterLanding, + resumeUTF16Offset: Int, + chapterPlainText: String?, + highlights: [HighlightPaint], + inlineMode: InlineNoteKind, + inlineNotes: [InlineNotePaint], + appearance: ReaderAppearance = ReaderAppearance(), + selectionActive: Bool = false, + onTap: @escaping () -> Void = {}, + onChapterBoundary: @escaping (PageTurnDirection) -> Void = { _ in }, + onSelectionChange: @escaping (ReaderSelection?) -> Void = { _ in }, + onPositionChange: @escaping (String) -> Void = { _ in }, + onVisibleParagraphs: @escaping ([ReaderParagraph]) -> Void = { _ in }, + onPageInfo: @escaping (Int, Int) -> Void = { _, _ in } + ) { + self.chapter = chapter + self.basePath = basePath + self.fontSize = fontSize + self.lineSpacing = lineSpacing + self.landing = landing + self.resumeUTF16Offset = resumeUTF16Offset + self.chapterPlainText = chapterPlainText + self.highlights = highlights + self.inlineMode = inlineMode + self.inlineNotes = inlineNotes + self.appearance = appearance + self.selectionActive = selectionActive + self.onTap = onTap + self.onChapterBoundary = onChapterBoundary + self.onSelectionChange = onSelectionChange + self.onPositionChange = onPositionChange + self.onVisibleParagraphs = onVisibleParagraphs + self.onPageInfo = onPageInfo + + let parsed = NativeChapterParser.parse(chapter) + self.document = parsed + self.blockSpans = NativeChapterParser.resolvedSpans( + for: chapter, document: parsed, chapterPlainText: chapterPlainText + ) + } + + var body: some View { + GeometryReader { geometry in + let textSize = CGSize( + width: min(700, max(200, geometry.size.width - horizontalInset * 2)), + height: max(120, geometry.size.height - verticalInset * 2) + ) + ZStack { + palette.window + + if let paginated { + MacPageTextView( + text: paginated.pageText(pageIndex), + globalLocation: paginated.characterRange(forPage: pageIndex).location, + pageSize: textSize, + clearSelection: !selectionActive, + onSelectionChange: { handleSelection($0) }, + onClickAt: { handleTap(fraction: $0) } + ) + .frame(width: textSize.width, height: textSize.height) + .id("\(paginated.version)-\(pageIndex)") + .transition(.opacity) + + pageArrows + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { location in + handleTap(fraction: geometry.size.width > 0 ? location.x / geometry.size.width : 0.5) + } + .focusable() + .focusEffectDisabled() + .focused($pageFocused) + .onKeyPress(.leftArrow) { turnPage(-1); return .handled } + .onKeyPress(.rightArrow) { turnPage(1); return .handled } + .onKeyPress(.space) { turnPage(1); return .handled } + .onAppear { + recomposeIfNeeded(textSize: textSize) + pageFocused = true + } + .onChange(of: composeKey(textSize: textSize)) { _, _ in + recomposeIfNeeded(textSize: textSize) + } + .onChange(of: pageIndex) { _, newIndex in + reportPage(newIndex) + } + .animation(.easeInOut(duration: 0.15), value: pageIndex) + } + } + + private var horizontalInset: CGFloat { 72 } + private var verticalInset: CGFloat { 36 } + + private var pageArrows: some View { + HStack { + arrowButton("chevron.left") { turnPage(-1) } + Spacer() + arrowButton("chevron.right") { turnPage(1) } + } + .padding(.horizontal, 16) + } + + private func arrowButton(_ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(palette.ink3) + .frame(width: 32, height: 56) + .background(palette.side.opacity(0.75), in: RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + } + + private func handleTap(fraction: CGFloat) { + if selectionActive { + onSelectionChange(nil) + return + } + if fraction < 0.18 { + turnPage(-1) + } else if fraction > 0.82 { + turnPage(1) + } else { + onTap() + } + } + + private func turnPage(_ delta: Int) { + guard let paginated else { return } + let target = pageIndex + delta + if target < 0 { + onChapterBoundary(.backward) + return + } + if target >= paginated.pageCount { + onChapterBoundary(.forward) + return + } + pageIndex = target + } + + private func composeKey(textSize: CGSize) -> ComposeKey { + ComposeKey( + width: textSize.width, + height: textSize.height, + fontSize: fontSize, + lineSpacing: lineSpacing, + appearance: appearance, + isDark: palette.isDark, + inlineMode: inlineMode, + noteFingerprint: inlineNotes.reduce(0) { partial, note in + partial &+ note.idx &* 31 &+ note.text.utf16.count &+ (note.failed ? 7 : 0) + }, + highlightFingerprint: highlights.reduce(0) { partial, paint in + partial &+ (paint.startUTF16 ?? 0) &* 31 &+ (paint.endUTF16 ?? 0) + } + ) + } + + private func recomposeIfNeeded(textSize: CGSize) { + let key = composeKey(textSize: textSize) + guard key != lastComposeKey else { return } + lastComposeKey = key + + let anchorOffset = paginated.flatMap { $0.chapterOffset(forPage: pageIndex) } + + let composer = PageComposer( + document: document, + blockSpans: blockSpans, + chapterPlainText: chapterPlainText, + basePath: basePath, + chapterHref: chapter.href, + fontSize: fontSize, + lineSpacing: lineSpacing, + appearance: appearance, + isDarkCanvas: palette.isDark, + inlineMode: inlineMode, + inlineNotes: inlineNotes, + highlights: highlights, + pageSize: textSize + ) + composeVersion += 1 + let next = composer.compose(version: composeVersion) + paginated = next + + let landingPage: Int + if let anchorOffset, let page = next.page(forChapterOffset: anchorOffset) { + landingPage = page + } else { + switch landing { + case .end: + landingPage = max(0, next.pageCount - 1) + case .start: + if resumeUTF16Offset > 0, + let page = next.page(forChapterOffset: resumeUTF16Offset) { + landingPage = page + } else { + landingPage = 0 + } + } + } + pageIndex = min(landingPage, max(0, next.pageCount - 1)) + reportPage(pageIndex) + } + + private func reportPage(_ index: Int) { + guard let paginated else { return } + onPageInfo(index, paginated.pageCount) + + let paragraphs = paginated.paragraphs(onPage: index) + if !paragraphs.isEmpty { + onVisibleParagraphs(paragraphs) + } + + if let offset = paginated.chapterOffset(forPage: index) { + let source = chapterPlainText ?? document.plainText + let utf16 = Array(source.utf16) + let clamped = max(0, min(offset, utf16.count)) + onPositionChange(String(decoding: utf16[0.. 0, let paginated else { + onSelectionChange(nil) + return + } + guard let chapterRange = paginated.chapterRange(forAttrRange: attrRange) else { + onSelectionChange(nil) + return + } + let source = chapterPlainText ?? document.plainText + onSelectionChange( + ReaderSelectionContext.selection(in: source, utf16Range: chapterRange) + ) + } +} + +private struct MacPageTextView: NSViewRepresentable { + let text: NSAttributedString + let globalLocation: Int + let pageSize: CGSize + let clearSelection: Bool + let onSelectionChange: (NSRange?) -> Void + let onClickAt: (CGFloat) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + globalLocation: globalLocation, + onSelectionChange: onSelectionChange, + onClickAt: onClickAt + ) + } + + func makeNSView(context: Context) -> NSTextView { + let textView = NSTextView(frame: NSRect(origin: .zero, size: pageSize)) + textView.drawsBackground = false + textView.isEditable = false + textView.isSelectable = true + textView.importsGraphics = false + textView.usesFindBar = false + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = false + textView.delegate = context.coordinator + textView.textStorage?.setAttributedString(text) + + let click = NSClickGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleClick(_:)) + ) + click.delaysPrimaryMouseButtonEvents = false + textView.addGestureRecognizer(click) + return textView + } + + func updateNSView(_ textView: NSTextView, context: Context) { + context.coordinator.globalLocation = globalLocation + context.coordinator.onSelectionChange = onSelectionChange + context.coordinator.onClickAt = onClickAt + if textView.textStorage?.isEqual(to: text) != true { + context.coordinator.programmatic = true + textView.textStorage?.setAttributedString(text) + context.coordinator.programmatic = false + } + if clearSelection, textView.selectedRange().length > 0 { + context.coordinator.programmatic = true + textView.setSelectedRange(NSRange(location: NSNotFound, length: 0)) + context.coordinator.programmatic = false + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var globalLocation: Int + var onSelectionChange: (NSRange?) -> Void + var onClickAt: (CGFloat) -> Void + var programmatic = false + + init( + globalLocation: Int, + onSelectionChange: @escaping (NSRange?) -> Void, + onClickAt: @escaping (CGFloat) -> Void + ) { + self.globalLocation = globalLocation + self.onSelectionChange = onSelectionChange + self.onClickAt = onClickAt + } + + func textViewDidChangeSelection(_ notification: Notification) { + guard !programmatic, + let textView = notification.object as? NSTextView else { return } + let range = textView.selectedRange() + guard range.location != NSNotFound, range.length > 0 else { + onSelectionChange(nil) + return + } + onSelectionChange(NSRange(location: globalLocation + range.location, length: range.length)) + } + + @objc func handleClick(_ recognizer: NSClickGestureRecognizer) { + guard let view = recognizer.view, view.bounds.width > 0 else { return } + let fraction = recognizer.location(in: view).x / view.bounds.width + onClickAt(fraction) + } + } +} +#endif + +private extension PagedColor { + convenience init(hex: UInt32) { + self.init( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } +} diff --git a/Empty/Views/ReadingStatsView.swift b/Empty/Views/ReadingStatsView.swift new file mode 100644 index 0000000..6e4dad5 --- /dev/null +++ b/Empty/Views/ReadingStatsView.swift @@ -0,0 +1,234 @@ +// +// ReadingStatsView.swift +// Empty +// +// P1 阅读统计: weekly/monthly active-minutes bars, streak, measured +// reading speed, and remaining-time estimates for in-progress books. +// 强调全部本地计算 — nothing leaves the device. +// + +import SwiftData +import SwiftUI + +struct ReadingStatsView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.emptyPalette) private var palette + @Environment(\.modelContext) private var modelContext + + private enum Period: String, CaseIterable { + case week = "本周" + case month = "本月" + + var days: Int { + switch self { + case .week: 7 + case .month: 30 + } + } + } + + @State private var period: Period = .week + @State private var dayStats: [ReadingDayStat] = [] + @State private var streak = 0 + @State private var charsPerMinute: Double? + @State private var inProgress: [(title: String, remainingMinutes: Double?)] = [] + + var body: some View { + VStack(spacing: 0) { + header + Rectangle().fill(palette.line).frame(height: 1) + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + periodPicker + chart + tiles + if !inProgress.isEmpty { + remainingSection + } + Text("所有统计在本机计算,不上传任何数据。计时只统计真实滚动 / 翻页的时间,挂机不算。") + .font(.system(size: 10.5)) + .foregroundStyle(palette.ink3) + } + .padding(EdgeInsets(top: 16, leading: 20, bottom: 24, trailing: 20)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(palette.window) + #if os(macOS) + .frame(minWidth: 460, minHeight: 520) + #endif + #if os(iOS) + .presentationDetents([.large, .medium]) + .presentationDragIndicator(.visible) + #endif + .task(id: period) { + reload() + } + } + + private var header: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text("阅读统计") + .font(.system(size: 17, weight: .black, design: .serif)) + .foregroundStyle(palette.ink) + Text("只算真正读着的时间") + .font(.system(size: 11)) + .foregroundStyle(palette.ink3) + } + Spacer() + Button { + dismiss() + } label: { + Text("×") + .font(.system(size: 14)) + .foregroundStyle(palette.ink3) + .frame(width: 28, height: 28) + .background(palette.accentSoft, in: Circle()) + } + .buttonStyle(.plain) + } + .padding(EdgeInsets(top: 16, leading: 20, bottom: 12, trailing: 16)) + } + + private var periodPicker: some View { + HStack(spacing: 8) { + ForEach(Period.allCases, id: \.self) { choice in + Button { + period = choice + } label: { + Text(choice.rawValue) + .font(.system(size: 12, weight: period == choice ? .bold : .regular)) + .foregroundStyle(period == choice ? palette.onAccent : palette.ink2) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + period == choice ? palette.accent : palette.side, + in: Capsule() + ) + } + .buttonStyle(.plain) + } + Spacer() + Text("共 \(Int(dayStats.reduce(0) { $0 + $1.minutes })) 分钟") + .font(.system(size: 11.5)) + .foregroundStyle(palette.ink3) + } + } + + private var chart: some View { + let peak = max(dayStats.map(\.minutes).max() ?? 1, 1) + return VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .bottom, spacing: period == .week ? 10 : 3) { + ForEach(dayStats) { stat in + VStack(spacing: 4) { + Capsule() + .fill(stat.minutes >= 1 ? palette.accent : palette.line) + .frame(height: max(4, CGFloat(stat.minutes / peak) * 110)) + .frame(maxWidth: .infinity) + if period == .week { + Text(weekdayLabel(stat.day)) + .font(.system(size: 9.5)) + .foregroundStyle(palette.ink3) + } + } + } + } + .frame(height: 132, alignment: .bottom) + } + .padding(14) + .emptyCard(palette, radius: 12) + } + + private var tiles: some View { + HStack(spacing: 10) { + tile( + value: streak > 0 ? "\(streak) 天" : "—", + label: "连续阅读" + ) + tile( + value: todayMinutes >= 1 ? "\(Int(todayMinutes)) 分钟" : "—", + label: "今天" + ) + tile( + value: charsPerMinute.map { "\(Int($0)) 字/分" } ?? "读几次就有了", + label: "平均速度" + ) + } + } + + private func tile(value: String, label: String) -> some View { + VStack(spacing: 3) { + Text(value) + .font(.system(size: 15, weight: .black, design: .serif)) + .foregroundStyle(palette.ink) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(label) + .font(.system(size: 10.5)) + .foregroundStyle(palette.ink3) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .emptyCard(palette, radius: 12) + } + + private var remainingSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("在读 · 按你的速度还要") + .font(.system(size: 11, weight: .bold)) + .kerning(1.4) + .foregroundStyle(palette.ink3) + ForEach(inProgress, id: \.title) { entry in + HStack { + Text(entry.title) + .font(.system(size: 12.5, weight: .bold, design: .serif)) + .foregroundStyle(palette.ink) + .lineLimit(1) + Spacer() + Text(remainingLabel(entry.remainingMinutes)) + .font(.system(size: 12)) + .foregroundStyle(palette.accent) + } + .padding(.horizontal, 13) + .padding(.vertical, 9) + .emptyCard(palette, radius: 10) + } + } + } + + private var todayMinutes: Double { + guard let today = dayStats.last else { return 0 } + return today.minutes + } + + private func weekdayLabel(_ day: Date) -> String { + let weekday = Calendar.current.component(.weekday, from: day) + return ["日", "一", "二", "三", "四", "五", "六"][weekday - 1] + } + + private func remainingLabel(_ minutes: Double?) -> String { + guard let minutes else { return "速度测算中" } + if minutes < 60 { return "约 \(max(1, Int(minutes))) 分钟" } + let hours = minutes / 60 + return String(format: "约 %.1f 小时", hours) + } + + private func reload() { + let store = ReadingStatsStore(modelContext: modelContext) + dayStats = (try? store.dailyMinutes(days: period.days)) ?? [] + streak = (try? store.streakDays()) ?? 0 + charsPerMinute = try? store.averageCharsPerMinute() ?? nil + + let books = (try? modelContext.fetch(FetchDescriptor( + sortBy: [SortDescriptor(\Book.lastOpenedAt, order: .reverse)] + ))) ?? [] + inProgress = books + .filter { $0.progressFraction > 0.01 && $0.progressFraction < 0.99 } + .prefix(3) + .map { book in + (title: book.title, remainingMinutes: try? store.remainingMinutes(for: book) ?? nil) + } + } +} diff --git a/Empty/Views/ReadingView.swift b/Empty/Views/ReadingView.swift index 006e0ca..5e24e66 100644 --- a/Empty/Views/ReadingView.swift +++ b/Empty/Views/ReadingView.swift @@ -5,7 +5,6 @@ import SwiftData import SwiftUI -import WebKit /// Where to land inside a chapter after navigating to it. nonisolated enum ChapterLanding { @@ -19,17 +18,21 @@ nonisolated enum PageTurnDirection { case backward } -/// A live text selection reported by the chapter web view. +/// A live native-reader selection, anchored by nearby plain-text context. nonisolated struct ReaderSelection: Equatable { var text: String var prefix: String var suffix: String } -/// What the in-page painter needs to mark one stored highlight. +/// What reader painters need to mark one stored highlight. nonisolated struct HighlightPaint: Codable, Equatable { var id: String var text: String + var startUTF16: Int? = nil + var endUTF16: Int? = nil + /// Underline tint; nil renders as the default gold. + var colorHex: UInt32? = nil } /// Reader text mode — the prototype's 原文 / 双语对照 / 导读 toggle, @@ -40,6 +43,30 @@ nonisolated enum InlineNoteKind: String { case companion = "comp" } +/// iOS reader text mode — the 原文 / 双语对照 / 导读 cycle behind the +/// top-bar mode capsule (Mac has its own three-pill `MacReadingMode`). +nonisolated enum IOSReadingMode: String, CaseIterable { + case original + case bilingual + case companion + + var badge: String { + switch self { + case .original: "原" + case .bilingual: "译" + case .companion: "导" + } + } + + var title: String { + switch self { + case .original: "原文" + case .bilingual: "双语对照" + case .companion: "导读" + } + } +} + /// How bilingual notes lay out: stacked under each paragraph (iOS), or /// the prototype's side-by-side parallel text (Mac 双语对照). nonisolated enum InlineNoteLayout: String { @@ -77,9 +104,15 @@ struct ReadingView: View { var onControlsChange: ((Bool) -> Void)? @Environment(\.dismiss) private var dismiss - @Environment(\.emptyPalette) private var palette + @Environment(\.emptyPalette) private var shellPalette @Environment(\.modelContext) private var modelContext + /// The reader screen runs under its own canvas theme; every existing + /// `palette.` reference below resolves through it. + private var palette: EmptyPalette { + readerTheme.palette(base: shellPalette) + } + @State private var epubBook: EPUBBook? @State private var pdfDocumentURL: URL? @State private var sectionCount: Int = 0 @@ -97,14 +130,23 @@ struct ReadingView: View { @State private var pendingSelection: ReaderSelection? @State private var chapterHighlights: [HighlightPaint] = [] @State private var showHighlights = false + @State private var showChapterSelection = false + @State private var selectionZhuOpen = false + @State private var showDictionary = false + @State private var dictionaryTerm = "" @State private var saveErrorMessage: String? @State private var showControls = true - @State private var fontSize: Double = 18 - @State private var lineSpacing: Double = 1.6 - @State private var isBilingual = false + @AppStorage("reader.fontSize") private var fontSize: Double = 18 + @AppStorage("reader.lineSpacing") private var lineSpacing: Double = 1.6 + @AppStorage("reader.theme") private var readerTheme: ReaderTheme = .paper + @AppStorage("reader.font") private var readerFont: ReaderFont = .serif + @AppStorage("reader.pageturn.ios") private var pageTurn: ReaderPageTurn = .scroll + @AppStorage("reader.mode.ios") private var readingMode: IOSReadingMode = .original @State private var inlineNotes: [InlineNotePaint] = [] @State private var inlineCache: [Int: String] = [:] + @State private var inlineRetryCounts: [Int: Int] = [:] @State private var inlineInFlight: Set = [] + @State private var pretransTask: Task? @State private var marginNote: String? @State private var marginSubject: String? @State private var isSelectionWorking = false @@ -112,6 +154,11 @@ struct ReadingView: View { @State private var thoughtLinkExpanded = false @State private var thoughtLinkSaved = false @State private var chapterPageInfo: (page: Int, count: Int)? + @State private var activityMeter = ReadingActivityMeter() + @AppStorage("reader.aloud.autonext") private var aloudAutoNext = false + @AppStorage("reader.pdf.invert") private var pdfNightInverted = false + @AppStorage("reader.traditional") private var traditionalChinese = false + @State private var traditionalCache = DictionaryBox() @StateObject private var aloud = ReadingAloud() init( @@ -168,6 +215,7 @@ struct ReadingView: View { .onAppear(perform: loadBook) .onDisappear { aloud.stop() + pretransTask?.cancel() saveProgress() } .onChange(of: showControls) { _, visible in @@ -179,6 +227,7 @@ struct ReadingView: View { #if os(iOS) .statusBarHidden(!showControls) #endif + .environment(\.emptyPalette, palette) } private func exitReader() { @@ -190,6 +239,35 @@ struct ReadingView: View { } } + private func scrollingReader(_ book: EPUBBook) -> some View { + NativeChapterReaderView( + chapter: displayChapter(book.chapters[currentChapterIndex]), + basePath: book.basePath, + fontSize: fontSize, + lineSpacing: lineSpacing, + landing: chapterLanding, + resumeUTF16Offset: resumeUTF16Offset, + chapterPlainText: displayChapterPlainText(), + highlights: chapterHighlights, + inlineMode: inlineNoteKind, + inlineLayout: .stacked, + inlineNotes: inlineNotes, + appearance: ReaderAppearance(theme: readerTheme, font: readerFont), + speechRange: aloud.currentSentenceRange, + selectionActive: pendingSelection != nil, + onTap: { withAnimation(.easeInOut(duration: 0.25)) { showControls.toggle() } }, + onChapterBoundary: { direction in + crossChapterBoundary(direction, chapterCount: book.chapters.count) + }, + onSelectionChange: { handleSelectionChange($0) }, + onPositionChange: { updateUTF16Offset(domPrefix: $0) }, + onVisibleParagraphs: { handleVisibleParagraphs($0) }, + onPageInfo: { page, count in + chapterPageInfo = (page: page, count: count) + } + ) + } + @ViewBuilder private func readerContent(_ book: EPUBBook) -> some View { VStack(spacing: 0) { @@ -203,29 +281,41 @@ struct ReadingView: View { } ZStack(alignment: .bottom) { - ChapterWebView( - chapter: book.chapters[currentChapterIndex], - basePath: book.basePath, - fontSize: fontSize, - isDarkMode: palette.isDark, - lineSpacing: lineSpacing, - landing: chapterLanding, - resumeUTF16Offset: resumeUTF16Offset, - chapterPlainText: currentChapterPlainText(), - highlights: chapterHighlights, - inlineMode: isBilingual ? .bilingual : .none, - inlineNotes: inlineNotes, - onTap: { withAnimation(.easeInOut(duration: 0.25)) { showControls.toggle() } }, - onChapterBoundary: { direction in - crossChapterBoundary(direction, chapterCount: book.chapters.count) - }, - onSelectionChange: { handleSelectionChange($0) }, - onPositionChange: { updateUTF16Offset(domPrefix: $0) }, - onVisibleParagraphs: { handleVisibleParagraphs($0) }, - onPageInfo: { page, count in - chapterPageInfo = (page: page, count: count) + Group { + #if os(iOS) + if pageTurn == .paged { + PagedChapterReaderView( + chapter: displayChapter(book.chapters[currentChapterIndex]), + basePath: book.basePath, + fontSize: fontSize, + lineSpacing: lineSpacing, + landing: chapterLanding, + resumeUTF16Offset: resumeUTF16Offset, + chapterPlainText: displayChapterPlainText(), + highlights: chapterHighlights, + inlineMode: inlineNoteKind, + inlineNotes: inlineNotes, + appearance: ReaderAppearance(theme: readerTheme, font: readerFont), + selectionActive: pendingSelection != nil, + onTap: { withAnimation(.easeInOut(duration: 0.25)) { showControls.toggle() } }, + onChapterBoundary: { direction in + crossChapterBoundary(direction, chapterCount: book.chapters.count) + }, + onSelectionChange: { handleSelectionChange($0) }, + onPositionChange: { updateUTF16Offset(domPrefix: $0) }, + onVisibleParagraphs: { handleVisibleParagraphs($0) }, + onPageInfo: { page, count in + chapterPageInfo = (page: page, count: count) + } + ) + } else { + scrollingReader(book) } - ) + #else + scrollingReader(book) + #endif + } + .id(currentChapterIndex) readerOverlay } @@ -249,7 +339,10 @@ struct ReadingView: View { .sheet(isPresented: $showSettings) { ReadingSettingsView( fontSize: $fontSize, - lineSpacing: $lineSpacing + lineSpacing: $lineSpacing, + theme: $readerTheme, + font: $readerFont, + pageTurn: $pageTurn ) #if os(iOS) .presentationDetents([.medium]) @@ -268,27 +361,61 @@ struct ReadingView: View { #endif } .sheet(isPresented: $showHighlights) { - HighlightsListView(book: self.book) { chapterIndex in - currentChapterIndex = chapterIndex - currentUTF16Offset = 0 + HighlightsListView(book: self.book) { position in + currentChapterIndex = position.chapterIndex + currentUTF16Offset = position.utf16Offset chapterLanding = .start } #if os(macOS) .frame(minWidth: 420, minHeight: 460) #endif } + .sheet(isPresented: $showChapterSelection) { + NativeChapterSelectionSheet( + title: sectionTitles.indices.contains(currentChapterIndex) + ? sectionTitles[currentChapterIndex] + : "当前章节", + chapterText: currentChapterPlainText() ?? "", + highlights: chapterHighlights, + fontSize: fontSize, + lineSpacing: lineSpacing, + focusUTF16Offset: currentUTF16Offset, + initialSelection: pendingSelection + ) { selection in + handleSelectionChange(selection) + } + } .onChange(of: currentChapterIndex) { _, _ in resetChapterArtifacts() refreshChapterHighlights() + // Shift the 预译 window (current + next two chapters). + startPretranslation() } - .onChange(of: isBilingual) { _, _ in + .onChange(of: traditionalChinese) { _, _ in + traditionalCache.values = [:] + inlineNotes = [] + inlineCache = [:] + inlineInFlight = [] + } + .onChange(of: readingMode) { _, _ in // The chapter page clears and re-requests notes; cached - // translations rejoin instantly. + // translations rejoin instantly. The in-memory cache is + // per-mode-agnostic by key, so it resets with the mode. inlineNotes = [] + inlineCache = [:] + inlineRetryCounts = [:] + inlineInFlight = [] + startPretranslation() } .onChange(of: showHighlights) { _, isShowing in if !isShowing { refreshChapterHighlights() } } + #if os(iOS) + .sheet(isPresented: $showDictionary, onDismiss: { pendingSelection = nil }) { + DictionaryLookupView(term: dictionaryTerm) + .ignoresSafeArea() + } + #endif .alert( "Couldn't Save Highlight", isPresented: Binding( @@ -320,6 +447,8 @@ struct ReadingView: View { documentURL: documentURL, pageIndex: $currentChapterIndex, highlights: chapterHighlights, + nightInverted: pdfNightInverted, + zoomMemoryKey: "pdf.zoom.\(book.id.uuidString)", onPageChange: syncPageProgress, onSelectionChange: { handleSelectionChange($0) } ) @@ -356,9 +485,9 @@ struct ReadingView: View { #endif } .sheet(isPresented: $showHighlights) { - HighlightsListView(book: self.book) { chapterIndex in - currentChapterIndex = chapterIndex - syncPageProgress(at: chapterIndex) + HighlightsListView(book: self.book) { position in + currentChapterIndex = position.chapterIndex + syncPageProgress(at: position.chapterIndex) } #if os(macOS) .frame(minWidth: 420, minHeight: 460) @@ -412,6 +541,7 @@ struct ReadingView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("reader.back") Spacer(minLength: 0) @@ -429,23 +559,35 @@ struct ReadingView: View { Spacer(minLength: 0) if showsBilingual { - Button { - isBilingual.toggle() + Menu { + ForEach(IOSReadingMode.allCases, id: \.self) { mode in + Button { + readingMode = mode + } label: { + if mode == readingMode { + Label(mode.title, systemImage: "checkmark") + } else { + Text(mode.title) + } + } + } } label: { - Text("译") + Text(readingMode == .original ? "译" : readingMode.badge) .font(.system(size: 12, weight: .bold)) - .foregroundStyle(isBilingual ? palette.onAccent : palette.accent) + .foregroundStyle(readingMode != .original ? palette.onAccent : palette.accent) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(isBilingual ? palette.accent : .clear, in: Capsule()) + .background(readingMode != .original ? palette.accent : .clear, in: Capsule()) .overlay( Capsule().strokeBorder( - isBilingual ? palette.accent : palette.accentSoft2, + readingMode != .original ? palette.accent : palette.accentSoft2, lineWidth: 1 ) ) } .buttonStyle(.plain) + .fixedSize() + .accessibilityIdentifier("reader.toggleBilingual") } Button { @@ -458,12 +600,17 @@ struct ReadingView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("reader.aloud") Menu { Button("目录", systemImage: "list.bullet") { showChapterList = true } + .accessibilityIdentifier("reader.menu.chapterList") Button("高亮", systemImage: "highlighter") { showHighlights = true } + .accessibilityIdentifier("reader.menu.highlights") Button("前情回顾", systemImage: "sparkles") { showRecap = true } + .accessibilityIdentifier("reader.menu.recap") Button("阅读设置", systemImage: "textformat.size") { showSettings = true } + .accessibilityIdentifier("reader.menu.settings") } label: { Text("⋯") .font(.system(size: 15, weight: .bold)) @@ -471,6 +618,7 @@ struct ReadingView: View { .padding(.horizontal, 4) .contentShape(Rectangle()) } + .accessibilityIdentifier("reader.overflow") .menuStyle(.button) .buttonStyle(.plain) } @@ -537,25 +685,57 @@ struct ReadingView: View { .padding(.bottom, 76) } - /// 解释 / 翻译 / 追问 / 高亮 on the prototype's dark pill. + /// 划词菜单 per the P0 spec: marker dots and quiet utilities lead, + /// the AI verbs expand from the trailing 朱 › group. private var selectionBar: some View { HStack(spacing: 2) { - selectionButton("解释") { runSelectionAction(.explain) } - selectionButton("翻译") { runSelectionAction(.translate) } - Button { - if let selection = pendingSelection { - askCompanion(about: selection.text) + ForEach(HighlightColor.readerChoices, id: \.self) { color in + Button { + saveHighlight(color: color) + } label: { + Circle() + .fill(Color(hex: color.hex)) + .frame(width: 15, height: 15) + .overlay(Circle().strokeBorder(Color.white.opacity(0.35), lineWidth: 1)) + .padding(5) + .contentShape(Circle()) } - } label: { - Text("追问 ↩") - .font(.system(size: 12.5, weight: .bold)) - .foregroundStyle(palette.onAccent) - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background(palette.accent, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + } + selectionDivider + selectionButton("复制") { copySelection() } + selectionButton("词典") { lookUpDictionary() } + selectionButton("跨段") { showChapterSelection = true } + selectionDivider + if selectionZhuOpen { + selectionButton("解释") { runSelectionAction(.explain) } + selectionButton("翻译") { runSelectionAction(.translate) } + Button { + if let selection = pendingSelection { + askCompanion(about: selection.text) + } + } label: { + Text("追问 ↩") + .font(.system(size: 12.5, weight: .bold)) + .foregroundStyle(palette.onAccent) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background(palette.accent, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } else { + Button { + withAnimation(.easeOut(duration: 0.15)) { selectionZhuOpen = true } + } label: { + Text("朱 ›") + .font(.system(size: 12.5, weight: .bold, design: .serif)) + .foregroundStyle(Color(hex: 0xD86B47)) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - selectionButton("高亮") { saveHighlight() } if isSelectionWorking { ProgressView() .controlSize(.small) @@ -565,6 +745,43 @@ struct ReadingView: View { .padding(5) .background(palette.ink, in: RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.3), radius: 14, y: 7) + .onChange(of: pendingSelection) { _, selection in + if selection == nil { selectionZhuOpen = false } + } + } + + private var selectionDivider: some View { + Rectangle() + .fill(Color.white.opacity(0.16)) + .frame(width: 1, height: 16) + .padding(.horizontal, 3) + } + + private func copySelection() { + guard let selection = pendingSelection else { return } + #if os(iOS) + UIPasteboard.general.string = selection.text + #elseif os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selection.text, forType: .string) + #endif + pendingSelection = nil + } + + private func lookUpDictionary() { + guard let selection = pendingSelection else { return } + let term = selection.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + #if os(iOS) + dictionaryTerm = term + showDictionary = true + #elseif os(macOS) + if let encoded = term.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "dict://\(encoded)") { + NSWorkspace.shared.open(url) + } + pendingSelection = nil + #endif } private func selectionButton(_ title: String, action: @escaping () -> Void) -> some View { @@ -683,8 +900,24 @@ struct ReadingView: View { } } - Text("正在朗读 · 1.0×") - .font(.system(size: 11.5)) + Button { + cycleAloudRate() + } label: { + Text(String(format: "%.2g×", aloud.rate)) + .font(.system(size: 11.5, weight: .bold)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button { + aloudAutoNext.toggle() + } label: { + Text("连读") + .font(.system(size: 11)) + .opacity(aloudAutoNext ? 1 : 0.45) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } .foregroundStyle(palette.window) .padding(.horizontal, 16) @@ -693,6 +926,12 @@ struct ReadingView: View { .shadow(color: .black.opacity(0.35), radius: 16, y: 8) } + private func cycleAloudRate() { + let steps: [Double] = [0.75, 1.0, 1.25, 1.5] + let index = steps.firstIndex(where: { abs($0 - aloud.rate) < 0.01 }) ?? 1 + aloud.rate = steps[(index + 1) % steps.count] + } + private var backgroundColor: Color { palette.window } @@ -717,7 +956,7 @@ struct ReadingView: View { Task { defer { isSelectionWorking = false } do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .chat) let question = action == .explain ? "Explain this passage to a thoughtful reader. Reply in Chinese with etymology or nuance when helpful." : "Translate this passage into natural Chinese, preserving literary tone." @@ -782,14 +1021,31 @@ struct ReadingView: View { aloud.stop() return } - if let text = pendingSelection?.text ?? currentChapterPlainText() { - let lang = book.languageTag?.hasPrefix("zh") == true ? "zh-CN" : "en-US" - aloud.speak(String(text.prefix(800)), language: lang) + let lang = book.languageTag?.hasPrefix("zh") == true ? "zh-CN" : "en-US" + if let selection = pendingSelection { + aloud.speak(selection.text, language: lang) + return + } + guard let text = currentChapterPlainText() else { return } + // 从当前位置接着读, sentence by sentence; finishing the chapter + // rolls into the next when 自动下一章 is on. + aloud.onQueueFinished = { [self] in + guard aloudAutoNext, + let chapterCount = epubBook?.chapters.count, + currentChapterIndex < chapterCount - 1 else { return } + crossChapterBoundary(.forward, chapterCount: chapterCount) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + if let next = currentChapterPlainText() { + aloud.speak(next, language: lang) + } + } } + aloud.speak(text, fromUTF16Offset: currentUTF16Offset, language: lang) } private func resetChapterArtifacts() { pendingSelection = nil + showChapterSelection = false marginNote = nil marginSubject = nil thoughtLink = nil @@ -799,26 +1055,50 @@ struct ReadingView: View { inlineNotes = [] } - // MARK: 双语对照 (译) + // MARK: 双语对照 / 导读 (译 · 导) + + private var inlineNoteKind: InlineNoteKind { + switch readingMode { + case .original: .none + case .bilingual: .bilingual + case .companion: .companion + } + } + + private static func translationKind(for mode: IOSReadingMode) -> TranslationKind { + mode == .companion ? .companion : .bilingual + } + + private static func aiNoteKind(for mode: IOSReadingMode) -> AIInlineNoteKind { + mode == .companion ? .companion : .bilingual + } /// Translates the visible paragraphs the chapter page reports, in /// reading order. Resolution: in-memory → persistent cache (the /// 「不重复翻译」 rule — reopening a book never re-translates) → AI. private func handleVisibleParagraphs(_ paragraphs: [ReaderParagraph]) { - guard isBilingual else { return } + let mode = readingMode + guard mode != .original else { return } let chapter = currentChapterIndex let store = TranslationStore(modelContext: modelContext) var missing: [ReaderParagraph] = [] for paragraph in paragraphs { let key = inlineNoteKey(chapter: chapter, idx: paragraph.idx) if let cached = inlineCache[key] { - if !cached.isEmpty, - !inlineNotes.contains(where: { $0.idx == paragraph.idx }) { - inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: cached)) + if !cached.isEmpty { + if !inlineNotes.contains(where: { $0.idx == paragraph.idx }) { + inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: cached)) + } + } else if inlineRetryCounts[key, default: 0] < 3, + !inlineInFlight.contains(key) { + // An empty/errored result is not a verdict — retry the + // paragraph while it's on screen (capped per visit). + inlineInFlight.insert(key) + missing.append(paragraph) } } else if let persisted = store.lookup( bookID: book.id, - kind: .bilingual, + kind: Self.translationKind(for: mode), text: paragraph.text ) { inlineCache[key] = persisted @@ -831,15 +1111,19 @@ struct ReadingView: View { } } guard !missing.isEmpty else { return } - Task { await translateParagraphs(missing, chapter: chapter) } + Task { await translateParagraphs(missing, chapter: chapter, mode: mode) } } private func inlineNoteKey(chapter: Int, idx: Int) -> Int { chapter << 16 | idx } - private func translateParagraphs(_ paragraphs: [ReaderParagraph], chapter: Int) async { - let resolution = AIProviderSettings.load().resolveUsableService() + private func translateParagraphs( + _ paragraphs: [ReaderParagraph], + chapter: Int, + mode: IOSReadingMode + ) async { + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .translate) guard resolution.service.availability.isAvailable else { for paragraph in paragraphs { inlineInFlight.remove(inlineNoteKey(chapter: chapter, idx: paragraph.idx)) @@ -849,26 +1133,31 @@ struct ReadingView: View { for paragraph in paragraphs { let key = inlineNoteKey(chapter: chapter, idx: paragraph.idx) defer { inlineInFlight.remove(key) } - // Reader moved on — skip the model call, leave it uncached. - guard isBilingual, currentChapterIndex == chapter else { continue } + // Reader moved on (or switched modes) — skip the model call. + guard readingMode == mode, currentChapterIndex == chapter else { continue } do { let text = try await AITransientRetry.run { try await resolution.service.inlineNote( for: paragraph.text, - kind: .bilingual + kind: Self.aiNoteKind(for: mode) ) }.trimmingCharacters(in: .whitespacesAndNewlines) - inlineCache[key] = text if !text.isEmpty { TranslationStore(modelContext: modelContext).store( text, bookID: book.id, chapterIndex: chapter, - kind: .bilingual, + kind: Self.translationKind(for: mode), text: paragraph.text ) } - if isBilingual, currentChapterIndex == chapter, !text.isEmpty { + guard readingMode == mode else { continue } + inlineCache[key] = text + if text.isEmpty { + inlineRetryCounts[key, default: 0] += 1 + } + if currentChapterIndex == chapter, !text.isEmpty { + inlineNotes.removeAll { $0.idx == paragraph.idx } inlineNotes.append(InlineNotePaint(idx: paragraph.idx, text: text)) } } catch { @@ -876,8 +1165,85 @@ struct ReadingView: View { // should retry on the next settled viewport report, not // retire the paragraph for this visit. guard !AITransientRetry.isTransient(error) else { continue } - inlineCache[key] = "" + if readingMode == mode { + inlineCache[key] = "" + inlineRetryCounts[key, default: 0] += 1 + if currentChapterIndex == chapter { + inlineNotes.removeAll { $0.idx == paragraph.idx } + inlineNotes.append( + InlineNotePaint(idx: paragraph.idx, text: "", failed: true) + ) + } + } + } + } + } + + /// Background pre-translation (双语对照 only, like the Mac reader): + /// fills the persistent cache for the current and next two chapters + /// so paging forward never waits on the model. 导读 stays + /// per-viewport — paraphrasing a whole book ahead of the reader + /// would burn tokens on chapters that may never be read that way. + private func startPretranslation() { + pretransTask?.cancel() + guard readingMode == .bilingual, book.format == .epub else { return } + let chapter = currentChapterIndex + pretransTask = Task { await pretranslate(from: chapter) } + } + + private func pretranslate(from startChapter: Int) async { + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .translate) + guard resolution.service.availability.isAvailable else { return } + // Whole-chapter pretranslation saturates the device when the + // model runs locally — reading janks and the battery drains. + // On-device stays per-viewport; only cloud providers cache ahead. + guard !resolution.provider.isLocal else { return } + let store = TranslationStore(modelContext: modelContext) + let bookID = book.id + let chapters = (try? modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.bookID == bookID }, + sortBy: [SortDescriptor(\.index)] + ) + )) ?? [] + + let window = chapters.filter { + $0.index >= startChapter && $0.index < startChapter + 3 + } + for chapter in window { + guard !Task.isCancelled, readingMode == .bilingual else { return } + // Even chapters stamped pretranslated can carry holes (a + // provider hiccup skipped paragraphs in an earlier pass) — + // the cheap local lookups below re-check and backfill them. + let paragraphs = TranslationStore.paragraphs(in: chapter.text) + let missing = paragraphs.filter { + store.lookup(bookID: bookID, kind: .bilingual, text: $0) == nil + } + guard !missing.isEmpty else { + if chapter.pretranslatedAt == nil { + chapter.pretranslatedAt = Date() + try? modelContext.save() + } + continue + } + for paragraph in missing { + guard !Task.isCancelled, readingMode == .bilingual else { return } + guard let text = try? await resolution.service.inlineNote( + for: paragraph, + kind: .bilingual + ).trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { continue } + store.store( + text, + bookID: bookID, + chapterIndex: chapter.index, + kind: .bilingual, + text: paragraph + ) } + chapter.pretranslatedAt = Date() + try? modelContext.save() } } @@ -910,6 +1276,33 @@ struct ReadingView: View { ).first?.text } + // MARK: 繁体显示 (render-layer, EPUB only) + + /// The chapter as displayed: traditional-converted (cached per + /// chapter) when the 繁体 toggle is on. Source files never change. + private func displayChapter(_ chapter: EPUBChapter) -> EPUBChapter { + guard traditionalChinese else { return chapter } + if let cached = traditionalCache.values[currentChapterIndex] { + return cached.chapter + } + let converted = EPUBChapter( + title: ChineseVariant.traditional(chapter.title), + href: chapter.href, + content: ChineseVariant.traditional(chapter.content) + ) + let plain = currentChapterPlainText().map(ChineseVariant.traditional) + traditionalCache.values[currentChapterIndex] = (converted, plain) + return converted + } + + private func displayChapterPlainText() -> String? { + guard traditionalChinese else { return currentChapterPlainText() } + if let cached = traditionalCache.values[currentChapterIndex] { + return cached.plain + } + return currentChapterPlainText().map(ChineseVariant.traditional) + } + private func updateUTF16Offset(domPrefix: String) { guard let plainText = currentChapterPlainText() else { return } let offset = PlainTextSearch.utf16Offset( @@ -917,6 +1310,9 @@ struct ReadingView: View { in: plainText ) currentUTF16Offset = min(offset, plainText.utf16.count) + // Position reports only arrive on real scroll/page activity — + // exactly what the 统计 spec counts as reading time. + activityMeter.ping() } private func loadBook() { @@ -958,6 +1354,7 @@ struct ReadingView: View { } isLoading = false startSession() + startPretranslation() } catch { loadError = error.localizedDescription isLoading = false @@ -992,12 +1389,13 @@ struct ReadingView: View { if let session { session.endedAt = Date() session.endPosition = position + session.activeSeconds += activityMeter.drain() } // Best effort on the way out; SwiftData autosave covers the rest. try? modelContext.save() } - private func saveHighlight() { + private func saveHighlight(color: HighlightColor = .yellow) { guard let selection = pendingSelection else { return } do { try HighlightStore(modelContext: modelContext).createHighlight( @@ -1005,7 +1403,8 @@ struct ReadingView: View { chapterIndex: currentChapterIndex, selection: selection.text, prefix: selection.prefix, - suffix: selection.suffix + suffix: selection.suffix, + color: color ) pendingSelection = nil refreshChapterHighlights() @@ -1018,1195 +1417,18 @@ struct ReadingView: View { do { chapterHighlights = try HighlightStore(modelContext: modelContext) .highlights(for: book, chapterIndex: currentChapterIndex) - .map { HighlightPaint(id: $0.id.uuidString, text: $0.textSnapshot) } - } catch { - chapterHighlights = [] - } - } -} - -// MARK: - Reader bridge - -/// Shared coordinator for both platform web views: navigation delegate plus -/// the JS↔Swift message bridge. String messages: "toggle" flips the control -/// bars, "boundaryForward"/"boundaryBackward" arrive when a page turn ran -/// past the chapter's edge. Dictionary messages report text selections for -/// highlighting. -final class ReaderBridge: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - static let messageName = "reader" - - weak var webView: WKWebView? - var currentChapter: String = "" - var landing: ChapterLanding = .start - var resumeUTF16Offset: Int = 0 - var chapterPlainText: String? - var paints: [HighlightPaint] = [] - var inlineKind: InlineNoteKind = .none - var inlineLayout: InlineNoteLayout = .stacked - var inlineNotes: [InlineNotePaint] = [] - /// Last style pushed into the page. SwiftUI re-renders the web view on - /// every reader state change (each arriving translation included); - /// re-running `updateStyle` then would re-page against a stale page - /// index and fight the user's scroll — only push when style changed. - var appliedStyle: (fontSize: Double, isDark: Bool, lineSpacing: Double)? - var onTap: () -> Void = {} - var onChapterBoundary: (PageTurnDirection) -> Void = { _ in } - var onSelectionChange: (ReaderSelection?) -> Void = { _ in } - var onPositionChange: (String) -> Void = { _ in } - var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } - var onPageInfo: (Int, Int) -> Void = { _, _ in } - - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard message.name == Self.messageName else { return } - if let action = message.body as? String { - switch action { - case "toggle": - onTap() - case "boundaryForward": - onChapterBoundary(.forward) - case "boundaryBackward": - onChapterBoundary(.backward) - default: - break - } - return - } - if let payload = message.body as? [String: Any], - payload["type"] as? String == "selection" { - let text = (payload["text"] as? String ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - onSelectionChange(nil) - return - } - onSelectionChange( - ReaderSelection( - text: text, - prefix: payload["prefix"] as? String ?? "", - suffix: payload["suffix"] as? String ?? "" - ) - ) - return - } - if let payload = message.body as? [String: Any], - payload["type"] as? String == "position", - let prefix = payload["prefix"] as? String { - onPositionChange(prefix) - if let page = payload["page"] as? Int, - let pageCount = payload["pageCount"] as? Int { - onPageInfo(page, pageCount) - } - return - } - if let payload = message.body as? [String: Any], - payload["type"] as? String == "paragraphs", - let items = payload["items"] as? [[String: Any]] { - let paragraphs = items.compactMap { item -> ReaderParagraph? in - guard let idx = item["idx"] as? Int, - let text = item["text"] as? String, - !text.isEmpty else { return nil } - return ReaderParagraph(idx: idx, text: text) - } - if !paragraphs.isEmpty { - onVisibleParagraphs(paragraphs) - } - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - if landing == .end { - webView.evaluateJavaScript("readerGoToEnd()") - } else if resumeUTF16Offset > 0, - let plainText = chapterPlainText { - let prefix = PlainTextSearch.normalizedPrefix( - of: plainText, - throughUTF16Offset: resumeUTF16Offset - ) - if let data = try? JSONEncoder().encode(prefix), - var json = String(data: data, encoding: .utf8) { - json = json - .replacingOccurrences(of: "\u{2028}", with: "\\u2028") - .replacingOccurrences(of: "\u{2029}", with: "\\u2029") - webView.evaluateJavaScript( - "if (typeof readerGoToNormalizedPrefix === 'function') { readerGoToNormalizedPrefix(\(json)); }" - ) - } - } - applyInlineLayout(on: webView) - applyPaints(on: webView) - applyInlineMode(on: webView) - applyInlineNotes(on: webView) - #if os(macOS) - // Keyboard paging only reaches the page while the web view is the - // first responder; reclaim it after every chapter load. - webView.window?.makeFirstResponder(webView) - #endif - } - - /// Pushes the current chapter's highlight snapshots into the page - /// painter. JSON is escaped for direct embedding in a JS call. - func applyPaints(on webView: WKWebView) { - guard let data = try? JSONEncoder().encode(paints), - var json = String(data: data, encoding: .utf8) else { return } - json = json - .replacingOccurrences(of: "\u{2028}", with: "\\u2028") - .replacingOccurrences(of: "\u{2029}", with: "\\u2029") - webView.evaluateJavaScript( - "if (typeof paintHighlights === 'function') { paintHighlights(\(json)); }" - ) - } - - /// Tells the page which inline-note mode is active (原文/双语/导读). - func applyInlineMode(on webView: WKWebView) { - webView.evaluateJavaScript( - "if (typeof readerSetInlineMode === 'function') { readerSetInlineMode('\(inlineKind.rawValue)'); }" - ) - } - - /// Switches between stacked notes and the side-by-side parallel text. - func applyInlineLayout(on webView: WKWebView) { - webView.evaluateJavaScript( - "if (typeof readerSetLayout === 'function') { readerSetLayout('\(inlineLayout.rawValue)'); }" - ) - } - - /// Pushes translated/导读 paragraph notes into the page. The page skips - /// indices it has already rendered, so resending the full list is safe. - func applyInlineNotes(on webView: WKWebView) { - guard inlineKind != .none, !inlineNotes.isEmpty else { return } - guard let data = try? JSONEncoder().encode(inlineNotes), - var json = String(data: data, encoding: .utf8) else { return } - json = json - .replacingOccurrences(of: "\u{2028}", with: "\\u2028") - .replacingOccurrences(of: "\u{2029}", with: "\\u2029") - webView.evaluateJavaScript( - "if (typeof readerApplyInlineNotes === 'function') { readerApplyInlineNotes(\(json)); }" - ) - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction - ) async -> WKNavigationActionPolicy { - navigationAction.navigationType == .linkActivated ? .cancel : .allow - } -} - -// MARK: - WebView for Chapter Content - -#if canImport(UIKit) -struct ChapterWebView: UIViewRepresentable { - let chapter: EPUBChapter - let basePath: URL - let fontSize: Double - let isDarkMode: Bool - let lineSpacing: Double - let landing: ChapterLanding - let resumeUTF16Offset: Int - let chapterPlainText: String? - let highlights: [HighlightPaint] - var inlineMode: InlineNoteKind = .none - var inlineLayout: InlineNoteLayout = .stacked - var inlineNotes: [InlineNotePaint] = [] - let onTap: () -> Void - let onChapterBoundary: (PageTurnDirection) -> Void - let onSelectionChange: (ReaderSelection?) -> Void - let onPositionChange: (String) -> Void - var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } - var onPageInfo: (Int, Int) -> Void = { _, _ in } - - func makeUIView(context: Context) -> WKWebView { - let config = WKWebViewConfiguration() - config.dataDetectorTypes = [] - config.userContentController.add(context.coordinator, name: ReaderBridge.messageName) - let webView = WKWebView(frame: .zero, configuration: config) - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - webView.scrollView.isScrollEnabled = false - webView.navigationDelegate = context.coordinator - context.coordinator.webView = webView - syncCoordinator(context.coordinator) - context.coordinator.currentChapter = chapter.href - context.coordinator.paints = highlights - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineLayout = inlineLayout - context.coordinator.inlineNotes = inlineNotes - context.coordinator.appliedStyle = (fontSize, isDarkMode, lineSpacing) - loadChapter(in: webView) - return webView - } - - func updateUIView(_ webView: WKWebView, context: Context) { - syncCoordinator(context.coordinator) - if context.coordinator.currentChapter != chapter.href { - context.coordinator.currentChapter = chapter.href - context.coordinator.paints = highlights - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineLayout = inlineLayout - context.coordinator.inlineNotes = inlineNotes - context.coordinator.appliedStyle = (fontSize, isDarkMode, lineSpacing) - loadChapter(in: webView) - } else { - let style = (fontSize: fontSize, isDark: isDarkMode, lineSpacing: lineSpacing) - if context.coordinator.appliedStyle ?? (0, !isDarkMode, 0) != style { - context.coordinator.appliedStyle = style - webView.evaluateJavaScript( - "updateStyle(\(fontSize), \(isDarkMode), \(lineSpacing));" - ) - } - if context.coordinator.paints != highlights { - context.coordinator.paints = highlights - context.coordinator.applyPaints(on: webView) - } - if context.coordinator.inlineLayout != inlineLayout { - context.coordinator.inlineLayout = inlineLayout - context.coordinator.applyInlineLayout(on: webView) - } - if context.coordinator.inlineKind != inlineMode { - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineNotes = inlineNotes - context.coordinator.applyInlineMode(on: webView) - context.coordinator.applyInlineNotes(on: webView) - } else if context.coordinator.inlineNotes != inlineNotes { - context.coordinator.inlineNotes = inlineNotes - context.coordinator.applyInlineNotes(on: webView) - } - } - } - - func makeCoordinator() -> ReaderBridge { - ReaderBridge() - } - - static func dismantleUIView(_ uiView: WKWebView, coordinator: ReaderBridge) { - uiView.configuration.userContentController - .removeScriptMessageHandler(forName: ReaderBridge.messageName) - } -} -#else -struct ChapterWebView: NSViewRepresentable { - let chapter: EPUBChapter - let basePath: URL - let fontSize: Double - let isDarkMode: Bool - let lineSpacing: Double - let landing: ChapterLanding - let resumeUTF16Offset: Int - let chapterPlainText: String? - let highlights: [HighlightPaint] - var inlineMode: InlineNoteKind = .none - var inlineLayout: InlineNoteLayout = .stacked - var inlineNotes: [InlineNotePaint] = [] - let onTap: () -> Void - let onChapterBoundary: (PageTurnDirection) -> Void - let onSelectionChange: (ReaderSelection?) -> Void - let onPositionChange: (String) -> Void - var onVisibleParagraphs: ([ReaderParagraph]) -> Void = { _ in } - var onPageInfo: (Int, Int) -> Void = { _, _ in } - - func makeNSView(context: Context) -> WKWebView { - let config = WKWebViewConfiguration() - config.userContentController.add(context.coordinator, name: ReaderBridge.messageName) - let webView = WKWebView(frame: .zero, configuration: config) - webView.navigationDelegate = context.coordinator - context.coordinator.webView = webView - syncCoordinator(context.coordinator) - context.coordinator.currentChapter = chapter.href - context.coordinator.paints = highlights - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineLayout = inlineLayout - context.coordinator.inlineNotes = inlineNotes - context.coordinator.appliedStyle = (fontSize, isDarkMode, lineSpacing) - loadChapter(in: webView) - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - syncCoordinator(context.coordinator) - if context.coordinator.currentChapter != chapter.href { - context.coordinator.currentChapter = chapter.href - context.coordinator.paints = highlights - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineLayout = inlineLayout - context.coordinator.inlineNotes = inlineNotes - context.coordinator.appliedStyle = (fontSize, isDarkMode, lineSpacing) - loadChapter(in: webView) - } else { - let style = (fontSize: fontSize, isDark: isDarkMode, lineSpacing: lineSpacing) - if context.coordinator.appliedStyle ?? (0, !isDarkMode, 0) != style { - context.coordinator.appliedStyle = style - webView.evaluateJavaScript( - "updateStyle(\(fontSize), \(isDarkMode), \(lineSpacing));" - ) - } - if context.coordinator.paints != highlights { - context.coordinator.paints = highlights - context.coordinator.applyPaints(on: webView) - } - if context.coordinator.inlineLayout != inlineLayout { - context.coordinator.inlineLayout = inlineLayout - context.coordinator.applyInlineLayout(on: webView) - } - if context.coordinator.inlineKind != inlineMode { - context.coordinator.inlineKind = inlineMode - context.coordinator.inlineNotes = inlineNotes - context.coordinator.applyInlineMode(on: webView) - context.coordinator.applyInlineNotes(on: webView) - } else if context.coordinator.inlineNotes != inlineNotes { - context.coordinator.inlineNotes = inlineNotes - context.coordinator.applyInlineNotes(on: webView) - } - } - } - - func makeCoordinator() -> ReaderBridge { - ReaderBridge() - } - - static func dismantleNSView(_ nsView: WKWebView, coordinator: ReaderBridge) { - nsView.configuration.userContentController - .removeScriptMessageHandler(forName: ReaderBridge.messageName) - } -} -#endif - -// Shared page generation and loading -extension ChapterWebView { - private func syncCoordinator(_ bridge: ReaderBridge) { - bridge.landing = landing - bridge.resumeUTF16Offset = resumeUTF16Offset - bridge.chapterPlainText = chapterPlainText - bridge.onTap = onTap - bridge.onChapterBoundary = onChapterBoundary - bridge.onSelectionChange = onSelectionChange - bridge.onPositionChange = onPositionChange - bridge.onVisibleParagraphs = onVisibleParagraphs - bridge.onPageInfo = onPageInfo - } - - func loadChapter(in webView: WKWebView) { - // 朱批 palette (EmptyTheme): warm paper / night-read ink. - let bgColor = isDarkMode ? "#1F1B16" : "#F7F2E9" - let textColor = isDarkMode ? "#EDE5D4" : "#2A2419" - - let html = """ - - - - - - - - - - \(extractBody(from: chapter.content)) - - - """ - - // `loadHTMLString` grants the sandboxed web-content process no file - // access: chapter images can't load, and a sandboxed macOS app shows - // a blank page outright. Write the styled page next to the chapter - // source (relative resource paths keep resolving) and load it as a - // file URL with read access to the whole book directory. - let chapterURL = basePath.appendingPathComponent(chapter.href) - let pageURL = chapterURL.deletingLastPathComponent() - .appendingPathComponent(".reader-\(chapterURL.lastPathComponent).html") - do { - try html.write(to: pageURL, atomically: true, encoding: .utf8) - webView.loadFileURL(pageURL, allowingReadAccessTo: basePath) } catch { - webView.loadHTMLString(html, baseURL: basePath) - } - } - - private func extractBody(from html: String) -> String { - if let bodyStart = html.range(of: "", range: bodyStart.upperBound..", options: .caseInsensitive) { - return String(html[bodyTagEnd.upperBound..? = nil + + @AppStorage("reader.traditional") private var traditionalChinese = false + @AppStorage("reader.pdf.invert") private var pdfInvert = false + @AppStorage("reader.pdf.twoup") private var pdfTwoUp = false @Environment(\.dismiss) private var dismiss @Environment(\.emptyPalette) private var palette @@ -2377,6 +1606,40 @@ struct ReadingSettingsView: View { } .foregroundStyle(palette.ink2) } + settingRow(label: "字体") { + HStack(spacing: 8) { + ForEach(ReaderFont.allCases, id: \.self) { choice in + fontChip(choice) + } + } + } + settingRow(label: "主题") { + HStack(spacing: 10) { + ForEach(ReaderTheme.allCases, id: \.self) { choice in + themeSwatch(choice) + } + } + } + if let pageTurn { + settingRow(label: "翻页方式") { + Picker("", selection: pageTurn) { + ForEach(ReaderPageTurn.allCases, id: \.self) { choice in + Text(choice.title).tag(choice) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + } + settingRow(label: "繁简 · PDF") { + HStack(spacing: 8) { + optionChip("繁体显示", isOn: $traditionalChinese) + optionChip("PDF 夜间反色", isOn: $pdfInvert) + #if os(macOS) + optionChip("PDF 双页", isOn: $pdfTwoUp) + #endif + } + } Text("\u{201C}I went to the woods because I wished to live deliberately…\u{201D}") .font(.system(size: fontSize * 0.8, design: .serif)) .lineSpacing(fontSize * 0.8 * (lineSpacing - 1)) @@ -2391,11 +1654,83 @@ struct ReadingSettingsView: View { } .background(palette.window) #if os(iOS) - .presentationDetents([.medium]) + .presentationDetents([.large, .medium]) .presentationDragIndicator(.visible) #endif } + private func optionChip(_ title: String, isOn: Binding) -> some View { + Button { + isOn.wrappedValue.toggle() + } label: { + Text(title) + .font(.system(size: 11.5, weight: isOn.wrappedValue ? .bold : .regular)) + .foregroundStyle(isOn.wrappedValue ? palette.onAccent : palette.ink2) + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background( + isOn.wrappedValue ? palette.accent : palette.side, + in: Capsule() + ) + } + .buttonStyle(.plain) + } + + private func fontChip(_ choice: ReaderFont) -> some View { + Button { + font = choice + } label: { + Text(choice.title) + .font(.system(size: 12.5, weight: font == choice ? .bold : .regular)) + .foregroundStyle(font == choice ? palette.onAccent : palette.ink2) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .background( + font == choice ? palette.accent : palette.side, + in: Capsule() + ) + .overlay( + Capsule().strokeBorder( + font == choice ? palette.accent : palette.line2, + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + + private func themeSwatch(_ choice: ReaderTheme) -> some View { + Button { + theme = choice + } label: { + VStack(spacing: 5) { + Circle() + .fill(choice.swatch) + .frame(width: 34, height: 34) + .overlay( + Circle().strokeBorder( + theme == choice ? palette.accent : palette.line2, + lineWidth: theme == choice ? 2 : 1 + ) + ) + .overlay { + if theme == choice { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle( + choice.isDarkCanvas(baseIsDark: false) + ? Color(hex: 0xEDE5D4) : Color(hex: 0x2A2419) + ) + } + } + Text(choice.title) + .font(.system(size: 10.5)) + .foregroundStyle(theme == choice ? palette.accent : palette.ink3) + } + } + .buttonStyle(.plain) + } + private func settingRow( label: String, @ViewBuilder content: () -> some View @@ -2409,3 +1744,19 @@ struct ReadingSettingsView: View { } } } + +#if os(iOS) +/// System dictionary lookup (词典) for the selection bar. +private struct DictionaryLookupView: UIViewControllerRepresentable { + let term: String + + func makeUIViewController(context: Context) -> UIReferenceLibraryViewController { + UIReferenceLibraryViewController(term: term) + } + + func updateUIViewController( + _ controller: UIReferenceLibraryViewController, + context: Context + ) {} +} +#endif diff --git a/Empty/Views/RecapView.swift b/Empty/Views/RecapView.swift index 6d23de9..5821ad7 100644 --- a/Empty/Views/RecapView.swift +++ b/Empty/Views/RecapView.swift @@ -156,7 +156,7 @@ struct RecapView: View { private func generate() async { phase = .loading do { - let resolution = AIProviderSettings.load().resolveUsableService() + let resolution = AIProviderRegistry.load().resolveUsableService(feature: .recap) let service = resolution.service let builder = RecapBuilder( modelContext: modelContext, @@ -166,7 +166,7 @@ struct RecapView: View { ) let recap = try await builder.recap(for: book, before: position) routeNote = resolution.fellBack - ? Self.fallbackNote(for: resolution.route) + ? Self.fallbackNote(for: resolution.provider.isLocal ? .onDevice : .cloud) : nil cache = RecapCache(position: position, text: recap) phase = .ready(recap) diff --git a/EmptyTests/AIProviderRegistryTests.swift b/EmptyTests/AIProviderRegistryTests.swift new file mode 100644 index 0000000..c390b30 --- /dev/null +++ b/EmptyTests/AIProviderRegistryTests.swift @@ -0,0 +1,87 @@ +// +// AIProviderRegistryTests.swift +// EmptyTests +// +// P0 多模型: provider list, per-feature routing, fallback chain, and +// the migration that mirrors the legacy single-cloud config. +// + +import Foundation +import Testing +@testable import Empty + +struct AIProviderRegistryTests { + private func makeDefaults() -> UserDefaults { + let suite = "registry-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + @Test func migratesLegacySettingsIntoLinkedProvider() { + let defaults = makeDefaults() + var legacy = AIProviderSettings() + legacy.mode = .cloud + legacy.cloudProtocol = .anthropic + legacy.cloudBaseURL = AIProviderSettings.kimiBaseURL + legacy.cloudModel = AIProviderSettings.kimiModel + legacy.save(to: defaults) + + let registry = AIProviderRegistry.load(defaults: defaults) + + #expect(registry.providers.contains { $0.id == AIProviderRegistry.localID }) + let linked = registry.provider(id: AIProviderRegistry.linkedCloudID) + #expect(linked?.kind == .anthropic) + #expect(linked?.name == "Kimi Code") + #expect(linked?.baseURL == AIProviderSettings.kimiBaseURL) + #expect(registry.defaultProviderID == AIProviderRegistry.linkedCloudID) + } + + @Test func unroutedFeatureFollowsDefaultAndRoutesPersist() { + let defaults = makeDefaults() + var registry = AIProviderRegistry.load(defaults: defaults) + + // Unrouted → default provider. + #expect(registry.provider(for: .translate).id == registry.defaultProviderID) + + // Route 翻译 to the linked cloud provider and persist. + registry.route(.translate, to: AIProviderRegistry.linkedCloudID) + registry.save(defaults: defaults) + + let reloaded = AIProviderRegistry.load(defaults: defaults) + #expect(reloaded.provider(for: .translate).id == AIProviderRegistry.linkedCloudID) + #expect(reloaded.provider(for: .chat).id == reloaded.defaultProviderID) + + // Clearing the route falls back to default. + var cleared = reloaded + cleared.route(.translate, to: nil) + #expect(cleared.provider(for: .translate).id == cleared.defaultProviderID) + } + + @Test func legacyConfigEditsReflectInLinkedProviderOnReload() { + let defaults = makeDefaults() + _ = AIProviderRegistry.load(defaults: defaults).save(defaults: defaults) + + var legacy = AIProviderSettings.load(from: defaults) + legacy.cloudBaseURL = "https://example.com/v1" + legacy.cloudModel = "custom-model" + legacy.cloudProtocol = .openAI + legacy.save(to: defaults) + + let registry = AIProviderRegistry.load(defaults: defaults) + let linked = registry.provider(id: AIProviderRegistry.linkedCloudID) + #expect(linked?.baseURL == "https://example.com/v1") + #expect(linked?.model == "custom-model") + #expect(linked?.kind == .openAI) + } + + @Test func dropsDanglingDefaultToLocal() { + let defaults = makeDefaults() + var registry = AIProviderRegistry.load(defaults: defaults) + registry.defaultProviderID = UUID() + registry.save(defaults: defaults) + + let reloaded = AIProviderRegistry.load(defaults: defaults) + #expect(reloaded.defaultProviderID == AIProviderRegistry.localID) + } +} diff --git a/EmptyTests/BookExporterTests.swift b/EmptyTests/BookExporterTests.swift new file mode 100644 index 0000000..83c82e2 --- /dev/null +++ b/EmptyTests/BookExporterTests.swift @@ -0,0 +1,87 @@ +// +// BookExporterTests.swift +// EmptyTests +// +// P1 导出: markdown/plain rendering, option toggles, and the empty:// +// deep-link round trip. +// + +import Foundation +import SwiftData +import Testing +@testable import Empty + +struct EmptyDeepLinkTests { + @Test func roundTripsBookAndPosition() throws { + let bookID = UUID() + let urlString = EmptyDeepLink.urlString( + bookID: bookID, chapterIndex: 4, utf16Offset: 1234 + ) + let url = try #require(URL(string: urlString)) + let parsed = try #require(EmptyDeepLink.parse(url)) + #expect(parsed.bookID == bookID) + #expect(parsed.position.chapterIndex == 4) + #expect(parsed.position.utf16Offset == 1234) + } + + @Test func rejectsForeignURLs() { + #expect(EmptyDeepLink.parse(URL(string: "https://example.com/book/x")!) == nil) + #expect(EmptyDeepLink.parse(URL(string: "empty://card/abc")!) == nil) + } +} + +@MainActor +struct BookExporterTests { + private func makeFixture() throws -> (ModelContainer, Book) { + let container = try AppStores.makeContainer(ephemeral: true) + let context = container.mainContext + let book = Book(title: "思维之书", author: "测试作者", format: .epub) + context.insert(book) + context.insert(Chapter(bookID: book.id, index: 0, title: "起 · 空白", text: "第一章正文,深读始于空白。")) + try context.save() + + let highlight = try HighlightStore(modelContext: context).createHighlight( + book: book, chapterIndex: 0, selection: "深读始于空白" + ) + try HighlightStore(modelContext: context).updateNote(highlight, note: "第一条批注") + try BookmarkStore(modelContext: context).toggle( + book: book, chapterIndex: 0, utf16Offset: 3, snippet: "一章正文" + ) + return (container, book) + } + + @Test func markdownIncludesQuotesNotesBookmarksAndLinks() throws { + let (container, book) = try makeFixture() + let exporter = BookExporter(modelContext: container.mainContext) + + let markdown = try exporter.export(book: book, options: BookExportOptions()) + + #expect(markdown.contains("# 思维之书 · 摘录")) + #expect(markdown.contains("## 起 · 空白")) + #expect(markdown.contains("> 深读始于空白")) + #expect(markdown.contains("> 批注:第一条批注")) + #expect(markdown.contains("🔖 一章正文")) + #expect(markdown.contains("empty://book/\(book.id.uuidString)?c=0&o=")) + + _ = container + } + + @Test func togglesDropSectionsAndPlainTextHasNoMarkdown() throws { + let (container, book) = try makeFixture() + let exporter = BookExporter(modelContext: container.mainContext) + + var options = BookExportOptions() + options.includeNotes = false + options.includeBookmarks = false + options.format = .plainText + let plain = try exporter.export(book: book, options: options) + + #expect(!plain.contains("批注")) + #expect(!plain.contains("🔖")) + #expect(!plain.contains("# ")) + #expect(plain.contains("“深读始于空白”")) + #expect(plain.contains("empty://book/")) + + _ = container + } +} diff --git a/EmptyTests/BookmarkTests.swift b/EmptyTests/BookmarkTests.swift new file mode 100644 index 0000000..879596b --- /dev/null +++ b/EmptyTests/BookmarkTests.swift @@ -0,0 +1,104 @@ +// +// BookmarkTests.swift +// EmptyTests +// +// P0 drawer: the ⌘D bookmark toggle and the spoiler-grouped full-text +// search behind the 目录/书签/搜索 panel. +// + +import Foundation +import SwiftData +import Testing +@testable import Empty + +@MainActor +struct BookmarkStoreTests { + private func makeFixture() throws -> (container: ModelContainer, book: Book) { + let container = try AppStores.makeContainer(ephemeral: true) + let context = container.mainContext + let book = Book(title: "B", format: .epub) + context.insert(book) + try context.save() + return (container, book) + } + + @Test func toggleAddsThenRemovesNearbyBookmark() throws { + let fixture = try makeFixture() + let store = BookmarkStore(modelContext: fixture.container.mainContext) + + let added = try store.toggle( + book: fixture.book, chapterIndex: 1, utf16Offset: 1000, snippet: "片段" + ) + #expect(added) + #expect(try store.bookmarks(for: fixture.book).count == 1) + + // Within ~a page of the existing bookmark → toggle removes it. + let addedAgain = try store.toggle( + book: fixture.book, chapterIndex: 1, utf16Offset: 1300, snippet: "片段" + ) + #expect(!addedAgain) + #expect(try store.bookmarks(for: fixture.book).isEmpty) + + _ = fixture.container + } + + @Test func bookmarksSortInReadingOrder() throws { + let fixture = try makeFixture() + let store = BookmarkStore(modelContext: fixture.container.mainContext) + + try store.toggle(book: fixture.book, chapterIndex: 3, utf16Offset: 10, snippet: "c") + try store.toggle(book: fixture.book, chapterIndex: 0, utf16Offset: 99, snippet: "a") + try store.toggle(book: fixture.book, chapterIndex: 1, utf16Offset: 5, snippet: "b") + + let ordered = try store.bookmarks(for: fixture.book) + #expect(ordered.map(\.chapterIndex) == [0, 1, 3]) + + _ = fixture.container + } +} + +@MainActor +struct BookTextSearchTests { + @Test func groupsHitsBySpoilerBoundaryWithUTF16Offsets() throws { + let container = try AppStores.makeContainer(ephemeral: true) + let context = container.mainContext + let book = Book(title: "B", format: .epub) + context.insert(book) + context.insert(Chapter(bookID: book.id, index: 0, text: "第一章。深读始于空白处。")) + context.insert(Chapter(bookID: book.id, index: 1, text: "第二章。空白也是内容。")) + context.insert(Chapter(bookID: book.id, index: 2, text: "终章。空白收束一切。")) + try context.save() + + let result = try BookTextSearch(modelContext: context).search( + book: book, query: "空白", maxReadChapter: 1 + ) + + #expect(result.read.map(\.chapterIndex) == [0, 1]) + #expect(result.unread.map(\.chapterIndex) == [2]) + // Offsets must be exact UTF-16 positions of the match. + let first = result.read[0] + let chapterText = "第一章。深读始于空白处。" + let expected = chapterText.range(of: "空白").map { + chapterText[..<$0.lowerBound].utf16.count + } + #expect(first.utf16Offset == expected) + + _ = container + } + + @Test func shortQueriesReturnNothing() throws { + let container = try AppStores.makeContainer(ephemeral: true) + let context = container.mainContext + let book = Book(title: "B", format: .epub) + context.insert(book) + context.insert(Chapter(bookID: book.id, index: 0, text: "空")) + try context.save() + + let result = try BookTextSearch(modelContext: context).search( + book: book, query: "空", maxReadChapter: 0 + ) + #expect(result.read.isEmpty && result.unread.isEmpty) + + _ = container + } +} diff --git a/EmptyTests/ChineseVariantTests.swift b/EmptyTests/ChineseVariantTests.swift new file mode 100644 index 0000000..8ba2417 --- /dev/null +++ b/EmptyTests/ChineseVariantTests.swift @@ -0,0 +1,19 @@ +// +// ChineseVariantTests.swift +// EmptyTests +// + +import Testing +@testable import Empty + +struct ChineseVariantTests { + @Test func convertsSimplifiedToTraditional() { + #expect(ChineseVariant.traditional("万里长城") == "萬里長城") + #expect(ChineseVariant.traditional("读书") == "讀書") + } + + @Test func leavesNonChineseAndTraditionalAlone() { + #expect(ChineseVariant.traditional("Hello, world!") == "Hello, world!") + #expect(ChineseVariant.traditional("萬卷書") == "萬卷書") + } +} diff --git a/EmptyTests/EPUBTests.swift b/EmptyTests/EPUBTests.swift index 76b43c4..1c6df9e 100644 --- a/EmptyTests/EPUBTests.swift +++ b/EmptyTests/EPUBTests.swift @@ -101,6 +101,292 @@ struct HTMLPlainTextTests { } } +struct NativeChapterParserTests { + @Test func extractsReadableBlocksImagesAndParagraphIndexes() { + let chapter = EPUBChapter( + title: "Native", + href: "Text/ch1.xhtml", + content: """ + + Ignored + +

第一章

+

第一段 强调 文字。

+
引文
+
  • 列表项
+ 插图 + + + """ + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.blocks.count == 5) + #expect(document.textBlocks.map(\.text) == ["第一章", "第一段 强调 文字。", "引文", "列表项"]) + #expect(document.blocks.compactMap(\.readerParagraph?.idx) == [0, 1, 2]) + if case .image(_, let source, let alt) = document.blocks.last { + #expect(source == "../Images/pic.jpg") + #expect(alt == "插图") + } else { + Issue.record("Expected image block") + } + } + + @Test func malformedXHTMLFallsBackToPlainParagraphs() { + let chapter = EPUBChapter( + title: "Broken", + href: "broken.xhtml", + content: "

第一段

第二段" + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.blocks.compactMap(\.readerParagraph?.text).contains("第一段")) + #expect(!document.blocks.isEmpty) + } + + @Test func bareDivTextBecomesParagraphsAndBrSplitsThem() { + let chapter = EPUBChapter( + title: "Div", + href: "div.xhtml", + content: """ + +

第一段
+
第二段
第三段
+ + """ + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.textBlocks.map(\.text) == ["第一段", "第二段", "第三段"]) + #expect(document.blocks.compactMap(\.readerParagraph?.idx) == [0, 1, 2]) + } + + @Test func tableParsesIntoRowsWithCaption() { + let chapter = EPUBChapter( + title: "Table", + href: "table.xhtml", + content: """ + + + + +
对照表
原文译文
empty空
+ """ + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.blocks.count == 1) + guard case .table(let id, let rows) = document.blocks[0] else { + Issue.record("Expected table block") + return + } + #expect(id == "t1") + #expect(rows == [["对照表"], ["原文", "译文"], ["empty", "空"]]) + #expect(document.textBlocks.isEmpty) + } + + @Test func footnoteAsideRendersAsNoteBlocks() { + let chapter = EPUBChapter( + title: "Notes", + href: "notes.xhtml", + content: """ + +

正文段落。

+ + + """ + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.blocks.count == 2) + guard case .footnote(_, let index, let text) = document.blocks[1] else { + Issue.record("Expected footnote block") + return + } + #expect(text == "这是脚注内容。") + #expect(index == 1) + } + + @Test func preKeepsLineStructureAndIndentation() { + let chapter = EPUBChapter( + title: "Code", + href: "code.xhtml", + content: """ +
func main() {
+                print("hi")
+            }
+ """ + ) + + let document = NativeChapterParser.parse(chapter) + + guard case .code(_, let text) = document.blocks.first else { + Issue.record("Expected code block") + return + } + #expect(text == "func main() {\n print(\"hi\")\n}") + #expect(document.blocks.first?.readerParagraph == nil) + } + + @Test func nestedListsKeepLevelsAndOrderedMarkers() { + let chapter = EPUBChapter( + title: "List", + href: "list.xhtml", + content: """ +
    +
  1. 第一项
  2. +
  3. 第二项
    • 子项
  4. +
+ """ + ) + + let document = NativeChapterParser.parse(chapter) + + let items = document.blocks.compactMap { block -> (String, Int, String)? in + guard case .listItem(_, _, let text, let level, let marker) = block else { return nil } + return (text, level, marker) + } + #expect(items.count == 3) + #expect(items[0] == ("第一项", 1, "1.")) + #expect(items[1] == ("第二项", 1, "2.")) + #expect(items[2] == ("子项", 2, "•")) + } + + @Test func figcaptionBecomesImageCaption() { + let chapter = EPUBChapter( + title: "Figure", + href: "figure.xhtml", + content: """ +
+ +
图一:示意
+
+ """ + ) + + let document = NativeChapterParser.parse(chapter) + + #expect(document.blocks.count == 1) + guard case .image(_, let source, let alt) = document.blocks.first else { + Issue.record("Expected image block") + return + } + #expect(source == "pic.png") + #expect(alt == "图一:示意") + } +} + +struct NativeChapterOffsetsTests { + @Test func resolvesTextSpansAndSelectionContextPrecisely() { + let chapter = EPUBChapter( + title: "Native", + href: "Text/ch2.xhtml", + content: """ + + +

Chapter

+

第一段 甲乙丙。

+

第二段 甲乙丙。

+

尾段 收束。

+ + + """ + ) + + let document = NativeChapterParser.parse(chapter) + let chapterText = document.plainText + let spans = document.resolvedTextSpans(in: chapterText) + let secondParagraph = document.blocks[2] + + guard let span = spans[secondParagraph.id] else { + Issue.record("Missing resolved span") + return + } + + #expect(span.chapterRange.lowerBound < span.chapterRange.upperBound) + #expect(span.paragraphInfo?.idx == 1) + + let selected = "甲乙丙" + guard let localRange = PlainTextSearch.utf16Range(of: selected, in: secondParagraph.text), + let selection = document.selection( + for: secondParagraph.id, + localUTF16Range: localRange, + chapterPlainText: chapterText, + spans: spans + ) else { + Issue.record("Missing precise selection context") + return + } + + #expect(selection.text == selected) + #expect(selection.prefix.contains("第二段")) + #expect(selection.suffix.contains("尾段")) + } + + @Test func readerSelectionContextSupportsCrossParagraphRanges() { + let chapterText = "Chapter\n第一段 甲乙丙。\n第二段 甲乙丙。\n尾段 收束。" + let selected = "甲乙丙。\n第二段 甲乙" + + guard let range = PlainTextSearch.utf16Range(of: selected, in: chapterText), + let selection = ReaderSelectionContext.selection( + in: chapterText, + utf16Range: range + ) else { + Issue.record("Missing cross-paragraph selection") + return + } + + #expect(selection.text == selected) + #expect(selection.prefix.contains("第一段")) + #expect(selection.suffix.contains("尾段")) + } + + @Test func convertsAbsoluteHighlightRangesIntoLocalRanges() { + let span = NativeTextBlockSpan( + blockID: "p-1", + chapterRange: 20..<42, + paragraphInfo: ReaderParagraph(idx: 0, text: "dummy") + ) + + #expect(span.localRange(intersecting: 24..<31) == 4..<11) + #expect(span.localRange(intersecting: 0..<10) == nil) + #expect(span.localRange(intersecting: 35..<50) == 15..<22) + } + + @Test func localProgressTracksIntraBlockResumeOffset() { + let span = NativeTextBlockSpan( + blockID: "p-2", + chapterRange: 100..<200, + paragraphInfo: nil + ) + + #expect(span.localProgress(for: 100) == 0) + #expect(span.localProgress(for: 150) == 0.5) + #expect(span.localProgress(for: 999) > 0.95) + } + + @Test func selectionContextCanRelocateExistingSelection() { + let chapterText = "Chapter\n第一段 甲乙丙。\n第二段 甲乙丙。\n尾段 收束。" + let selected = "第二段 甲乙丙。\n尾段" + + guard let range = PlainTextSearch.utf16Range(of: selected, in: chapterText), + let selection = ReaderSelectionContext.selection( + in: chapterText, + utf16Range: range + ) else { + Issue.record("Missing selection seed") + return + } + + #expect(ReaderSelectionContext.utf16Range(of: selection, in: chapterText) == range) + } +} + // MARK: - Minimal EPUB fixture /// Builds a tiny but structurally valid EPUB: a stored-entry (uncompressed) diff --git a/EmptyTests/HighlightTests.swift b/EmptyTests/HighlightTests.swift index 88bd02c..c4b9765 100644 --- a/EmptyTests/HighlightTests.swift +++ b/EmptyTests/HighlightTests.swift @@ -126,6 +126,31 @@ struct HighlightStoreTests { _ = container } + @Test func updateNoteTrimsWhitespaceAndAllowsClearing() throws { + let container = try AppStores.makeContainer(ephemeral: true) + let context = container.mainContext + + let book = Book(title: "B", format: .epub) + context.insert(book) + context.insert(Chapter(bookID: book.id, index: 0, text: "章节正文。")) + try context.save() + + let store = HighlightStore(modelContext: context) + let highlight = try store.createHighlight( + book: book, + chapterIndex: 0, + selection: "章节正文" + ) + + try store.updateNote(highlight, note: " 这是我的批注 ") + #expect(highlight.note == "这是我的批注") + + try store.updateNote(highlight, note: " ") + #expect(highlight.note == nil) + + _ = container + } + @Test func listsPerChapterAndDeletes() throws { let container = try AppStores.makeContainer(ephemeral: true) let context = container.mainContext diff --git a/EmptyTests/ReadingStatsTests.swift b/EmptyTests/ReadingStatsTests.swift new file mode 100644 index 0000000..48f778d --- /dev/null +++ b/EmptyTests/ReadingStatsTests.swift @@ -0,0 +1,117 @@ +// +// ReadingStatsTests.swift +// EmptyTests +// +// P1 统计口径: idle gaps don't count, daily buckets zero-fill, streaks +// survive an unread "today", and speed/remaining math uses active time. +// + +import Foundation +import SwiftData +import Testing +@testable import Empty + +@MainActor +struct ReadingActivityMeterTests { + @Test func capsIdleGapsAndDrains() { + let meter = ReadingActivityMeter() + let base = Date(timeIntervalSince1970: 1_000_000) + + meter.ping(now: base) + meter.ping(now: base.addingTimeInterval(30)) // +30s + meter.ping(now: base.addingTimeInterval(630)) // 10min gap → +90s cap + meter.ping(now: base.addingTimeInterval(640)) // +10s + + #expect(meter.accumulated == 130) + #expect(meter.drain() == 130) + #expect(meter.accumulated == 0) + } +} + +@MainActor +struct ReadingStatsStoreTests { + private func makeFixture() throws -> (ModelContainer, Book) { + let container = try AppStores.makeContainer(ephemeral: true) + let book = Book(title: "B", format: .epub) + container.mainContext.insert(book) + try container.mainContext.save() + return (container, book) + } + + private func addSession( + _ context: ModelContext, + book: Book, + daysAgo: Int, + activeSeconds: Double, + chars: Int = 0, + now: Date + ) { + let started = Calendar.current.date(byAdding: .day, value: -daysAgo, to: now)! + let session = ReadingSession( + startPosition: ReadingPosition(chapterIndex: 0, utf16Offset: 0), + startedAt: started + ) + session.endedAt = started.addingTimeInterval(max(activeSeconds, 60)) + session.activeSeconds = activeSeconds + session.endPosition = ReadingPosition(chapterIndex: 0, utf16Offset: chars) + context.insert(session) + session.book = book + } + + @Test func bucketsDailyMinutesAndZeroFills() throws { + let (container, book) = try makeFixture() + let context = container.mainContext + let now = Date() + addSession(context, book: book, daysAgo: 0, activeSeconds: 600, now: now) + addSession(context, book: book, daysAgo: 2, activeSeconds: 300, now: now) + try context.save() + + let stats = try ReadingStatsStore(modelContext: context).dailyMinutes(days: 7, endingOn: now) + + #expect(stats.count == 7) + #expect(stats.last?.minutes == 10) + #expect(stats[4].minutes == 5) + #expect(stats[3].minutes == 0) + + _ = container + } + + @Test func streakSurvivesUnreadToday() throws { + let (container, book) = try makeFixture() + let context = container.mainContext + let now = Date() + // Read yesterday and the day before, nothing today. + addSession(context, book: book, daysAgo: 1, activeSeconds: 300, now: now) + addSession(context, book: book, daysAgo: 2, activeSeconds: 300, now: now) + try context.save() + + let streak = try ReadingStatsStore(modelContext: context).streakDays(today: now) + #expect(streak == 2) + + _ = container + } + + @Test func speedUsesActiveMinutesAndFeedsRemainingEstimate() throws { + let (container, book) = try makeFixture() + let context = container.mainContext + let now = Date() + // 3000 chars over 10 active minutes → 300 chars/min. + addSession( + context, book: book, daysAgo: 0, + activeSeconds: 600, chars: 3000, now: now + ) + context.insert(Chapter( + bookID: book.id, index: 0, + text: String(repeating: "字", count: 6000) + )) + book.progressFraction = 0.5 + try context.save() + + let store = ReadingStatsStore(modelContext: context) + #expect(try store.averageCharsPerMinute() == 300) + // Half of 6000 chars left at 300/min → 10 minutes. + #expect(try store.remainingMinutes(for: book) == 10) + + _ = container + } +} diff --git a/EmptyTests/SelectionChangeDebouncerTests.swift b/EmptyTests/SelectionChangeDebouncerTests.swift new file mode 100644 index 0000000..3ecca51 --- /dev/null +++ b/EmptyTests/SelectionChangeDebouncerTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import Empty + +@MainActor +struct SelectionChangeDebouncerTests { + @Test func coalescesRapidSelectionUpdates() async throws { + let debouncer = SelectionChangeDebouncer(delay: .milliseconds(20)) + var delivered: [Range?] = [] + + debouncer.submit(0..<2) { delivered.append($0) } + debouncer.submit(3..<6) { delivered.append($0) } + + try await Task.sleep(for: .milliseconds(60)) + + #expect(delivered.count == 1) + #expect(delivered.first! == (3..<6)) + } + + @Test func cancelPreventsPendingDelivery() async throws { + let debouncer = SelectionChangeDebouncer(delay: .milliseconds(20)) + var delivered: [Range?] = [] + + debouncer.submit(1..<4) { delivered.append($0) } + debouncer.cancel() + + try await Task.sleep(for: .milliseconds(60)) + + #expect(delivered.isEmpty) + } +} diff --git a/EmptyTests/SpeechSentenceTests.swift b/EmptyTests/SpeechSentenceTests.swift new file mode 100644 index 0000000..43558e2 --- /dev/null +++ b/EmptyTests/SpeechSentenceTests.swift @@ -0,0 +1,54 @@ +// +// SpeechSentenceTests.swift +// EmptyTests +// +// P1 朗读: sentence splitting keeps exact UTF-16 ranges (follow-along +// highlights depend on them) and 从当前位置接读 picks the right sentence. +// + +import Testing +@testable import Empty + +struct SpeechSentenceSplitterTests { + @Test func splitsCJKAndLatinWithExactRanges() { + let text = "第一句。第二句!Third sentence. 最后一句" + let sentences = SpeechSentenceSplitter.split(text) + + #expect(sentences.map(\.text) == ["第一句。", "第二句!", "Third sentence.", "最后一句"]) + + // Ranges tile the string in order and map back to the source. + for sentence in sentences { + let utf16 = Array(text.utf16) + let slice = String( + decoding: utf16[sentence.utf16Range.lowerBound.. 本地跑测试可关闭签名:在上述命令后追加 > `CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=""`。 -当前 **108/108** 单元测试全部通过。 +当前 **144/144** 单元测试全部通过。 --- @@ -229,6 +230,7 @@ git push -u origin main - [x] 预译 + 翻译持久化(设计第 2 轮):Mac 左右分栏双语、本地译文缓存与可视化、☰ 章节目录面板 - [x] 阅读 Agent v1:工具化现有能力 + 双路 agent loop(on-device guided generation / cloud JSON mode)、步骤轨迹、写操作确认 - [x] Kimi Code(Anthropic 兼容)云端路径 + 接口标准切换 +- [x] EPUB 渲染线从 WebView 迁移到原生 SwiftUI:块模型解析、精确高亮 / 选区、跨段选取、高亮批注与精确跳回 - [ ] **ReaderMemory**(见 [docs/READER-MEMORY-PLAN.md](docs/READER-MEMORY-PLAN.md)):跨书记忆 ingest/recall、升级思维链接、伴读 `recall_reader_memory` - [ ] **活思维链接**(见 [docs/LIBER-PORT-PLAN.md](docs/LIBER-PORT-PLAN.md) Wave 1):语义呼应 + theme/why,对齐 Liber Echoes 的私人版 - [ ] 伴读镜片(今译 / 辩难 / 文献)、Passkey + CloudKit 记忆同步(Phase 3) diff --git a/default.profraw b/default.profraw deleted file mode 100644 index e69de29..0000000 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efd5397..bc6284e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,15 +27,12 @@ | 模型 | 用途 | |------|------| | `Book` | 书库元数据、封面、阅读位置 | -| `Highlight` | 高亮锚点与文本快照 | +| `Highlight` | 高亮锚点、文本快照与批注 | | `ReadingSession` | 阅读会话起止位置 | | `VocabEntry` | 词汇与间隔复习状态 | +| `StudyCardEntry` | 问答卡 / 链接卡 / 复习卡 | -当前 `syncedDatabase = .none`。启用 CloudKit 步骤: - -1. Xcode → Signing & Capabilities → **+ iCloud** → 勾选 CloudKit -2. 将 `AppStores.swift` 中 `syncedDatabase` 改为 `.automatic` -3. 确保模型符合 CloudKit 约束(已遵循:无 unique 约束、关系 optional) +CloudKit 同步**已启用**(`syncedDatabase = .automatic`,`Empty.entitlements` 含 iCloud capability)。容器初始化失败时(本机未登录 iCloud、关闭签名的测试环境等),自动以 `cloudKitDatabase: .none` 重建同一组磁盘 store——应用照常工作,仅不同步。 ### Local Store(仅本机) @@ -43,6 +40,7 @@ |------|------| | `Chapter` | 章节纯文本(从 EPUB 解析) | | `Chunk` | 分块文本 + 可选 sentence embedding | +| `ParagraphTranslation` | 双语 / 导读译文持久缓存(按稳定文本哈希键控,见 `TranslationStore`) | 跨 store 关联:**仅通过 `Book.id`(UUID)**,不使用 SwiftData 跨 store relationship。 @@ -66,17 +64,20 @@ Library.import() # 写入 Book + Chapter(双 store) BookIndexer.ensureChunks() # 按需分块,写入 Chunk │ ▼ -ReadingView / MacReaderScreen # WebKit 渲染 + 分页 + 高亮桥接 +ReadingView / MacReaderScreen # 原生 SwiftUI 渲染(块模型 + 纵向滚动) ``` +### 原生渲染(无 WebView) + +EPUB 章节不经 WebView:`NativeChapterParser`(`XMLParser`)把 XHTML 解析为标题 / 段落 / 引文 / 列表 / 图片块(`NativeChapterDocument`,畸形输入回退纯文本逐段切分),由 `NativeChapterReaderView` 以 `ScrollView + LazyVStack` 渲染。每个文本块是原生 `NSTextView` / `UITextView`,段内精确划词;「跨段」面板(`NativeChapterSelectionSheet`)把整章放进单个可选文本视图覆盖跨段选取,并自动定位当前阅读位置。每个块解析出章内精确 UTF-16 范围(`NativeTextBlockSpan`):高亮按范围绘制、选区带前后文锚定、可见段落经 SwiftUI preference 上报(驱动双语 / 导读逐段翻译),插入译文不会触发任何 reflow / 滚动跳动。 + ### 高亮锚定 -[`HighlightStore`](../Empty/Services/HighlightStore.swift) 使用 UTF-16 偏移 + 选区前后各 32 字符的 prefix/suffix 快照,在章节重解析后仍能定位高亮。 +[`HighlightStore`](../Empty/Services/HighlightStore.swift) 存精确 UTF-16 偏移 + 原文快照,定位经 `PlainTextSearch` 以选区前后文消歧;快照保证偏移漂移时高亮仍可读。高亮列表支持写 / 编辑批注与精确跳回锚点。 ### 阅读位置 -`ReadingPosition` 包含 `chapterIndex` 与 `utf16Offset`。 -**当前限制:** UI 层 `saveProgress()` 仅更新章节索引,`utf16Offset` 固定为 0,防剧透粒度实际为章级。 +`ReadingPosition` 包含 `chapterIndex` 与 `utf16Offset`。阅读器滚动时上报最远可见字符,防剧透粒度为**章内字符级**;续读按存储偏移落回文本块内的精确进度。 --- @@ -103,20 +104,29 @@ ReadingView / MacReaderScreen # WebKit 渲染 + 分页 + 高亮桥接 |------|------| | `summarize(_:focus:)` | 摘要 / Recap / 论证骨架 | | `answer(question:groundedIn:)` | 基于已检索段落的 grounded 回答 | -| `flashcards(from:maxCount:)` | 闪卡生成(服务已实现,UI 待接) | +| `inlineNote(for:kind:)` | 双语 / 导读逐段翻译(纯文本路径;瞬态错误经 `AITransientRetry` 退避重试) | +| `flashcards(from:maxCount:)` | 闪卡生成(高亮列表 / 伴读内可存为卡片) | +| `toolStep(toolDocs:transcript:)` | 阅读 agent 单步决策(调用工具或收尾作答) | + +实现(云端 BYOK 提供**两套标准**): -实现: +- [`FoundationModelsAIService`](../Empty/Services/FoundationModelsAIService.swift) — 本机,map-reduce 超长文本;agent 步骤用 `@Generable` guided generation +- [`CloudAIService`](../Empty/Services/CloudAIService.swift) — OpenAI 兼容 chat completions(DeepSeek 预设);端点拒绝 JSON mode 时降级重试一次 +- [`AnthropicAIService`](../Empty/Services/AnthropicAIService.swift) — Anthropic Messages API(Kimi Code 预设),复用云端 JSON 解析器 -- [`FoundationModelsAIService`](../Empty/Services/FoundationModelsAIService.swift) — 本机,map-reduce 超长文本 -- [`CloudAIService`](../Empty/Services/CloudAIService.swift) — OpenAI 兼容 chat completions +路由:[`AIProviderSettings.resolveUsableService()`](../Empty/Services/AIProviderSettings.swift),`cloudProtocol` 切换接口标准;本机不可用时自动回退云端(若已配置 Key)。 -路由:[`AIProviderSettings.resolveUsableService()`](../Empty/Services/AIProviderSettings.swift) -本机不可用时自动回退云端(若已配置 Key)。 +### 阅读 Agent + +[`ReadingAgent`](../Empty/Services/ReadingAgent.swift)(有界循环:本机 ≤3 步 / 云端 ≤4 步)经 `AIService.toolStep` 驱动 [`ReadingToolbox`](../Empty/Services/ReadingToolbox.swift) 的六个工具(search_passages / recap_progress / explain / find_link / add_vocab / make_flashcards)。读操作全部走防剧透检索;写操作只产出 `CompanionAction` **提案**,读者在伴读面板 / 朱 sheet 确认后才落库;步骤轨迹随答案展示,任一步失败回退 grounded 问答。 ### 语义索引 -[`SemanticIndexer`](../Empty/Services/SemanticIndexer.swift) actor 在后台为 Chunk 写入 embedding。 -依赖 `NLEmbedding.sentenceEmbedding(for: .english)`,目前以英文为主,由 `AskBookView` 触发。 +[`SemanticIndexer`](../Empty/Services/SemanticIndexer.swift) actor 在后台为 Chunk 写入**语言感知**的 sentence embedding(按文本主导语言选模型,中文支持,见 `SemanticScorer.embeddingModel`),由伴读问答的索引管线按需触发。 + +### 预译缓存 + +[`TranslationStore`](../Empty/Services/TranslationStore.swift) 以稳定文本哈希(FNV-1a,空白归一化)为键持久化每段译文(`ParagraphTranslation`,本地 store)。双语模式下 Mac 预译当前章及后两章(含章节标题,`Chapter.pretranslatedAt` 标记),重开书零重译;☰ 目录展示每章预译状态与全书缓存量。 --- @@ -129,13 +139,13 @@ EmptyApp │ ├── MacReaderScreen (+ MacCompanionPanel) │ ├── MacNotesScreen │ └── MacVocabScreen -└── iOS → LibraryView → ReadingView - ├── RecapView - ├── AskBookView - └── HighlightsListView +└── iOS → IOSRootView(书库 / 阅读 / 卡片 + 「朱」半屏伴读 sheet) + ├── IOSLibraryScreen + ├── ReadingView / PDFReaderView + └── IOSCardsScreen ``` -Mac 阅读器额外功能:双语模式、边注、思维链接、TTS([`ReadingAloud`](../Empty/Services/ReadingAloud.swift))。 +Mac 阅读器额外功能:双语对照(左右分栏 + 预译缓存)、导读、结构化章节概览、☰ 目录面板、边注、思维链接、跨段选取、TTS([`ReadingAloud`](../Empty/Services/ReadingAloud.swift))。 --- @@ -148,7 +158,7 @@ Mac 阅读器额外功能:双语模式、边注、思维链接、TTS([`Readi | 分块 / 检索 / 防剧透 | 高 | Swift Testing | | 云端 AI JSON | 高 | 纯函数解析测试 | | 本机 Foundation Models | 低 | 硬件依赖,未纳入 CI | -| SwiftUI 视图 | 极低 | UITests 为模板 | +| SwiftUI 视图 | 截图冒烟 | XCUITest 确定性截屏(`-ScreenshotSeed` 播种演示书,产出 README 用图) | 测试容器使用 `AppStores.makeContainer(ephemeral: true)` 隔离并行测试。 @@ -158,10 +168,9 @@ Mac 阅读器额外功能:双语模式、边注、思维链接、TTS([`Readi | 项目 | 状态 | |------|------| -| PDF 阅读 | 未实现 | -| 章内 utf16Offset | 模型支持,UI 未上报 | -| CloudKit | 已预留,未启用 | -| 闪卡 UI | 服务有,界面无 | -| iOS 词汇/笔记 | Mac 独有 | -| Mac 笔记屏 AI 建议 | 部分为静态文案 | -| 语义 embedding | 英文 NLEmbedding,非全局后台索引 | \ No newline at end of file +| 复杂 EPUB 排版(表格、脚注、内嵌样式) | 原生块模型降级为纯文本段落渲染 | +| 跨段划词 | 直接拖选限单个文本块,跨段经「跨段」整章面板完成 | +| iCloud entitlement 签名 | 个人开发账号本地跑测试需 `CODE_SIGNING_ALLOWED=NO` | +| 本机 Foundation Models 测试 | 硬件依赖,未纳入 CI | +| visionOS | 可编译,无专属 UI | +| 语义 embedding | 语言感知(中 / 英),按需索引而非全局后台任务 | \ No newline at end of file