Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0131529
Build native EPUB reader selection and highlights
DaviRain-Su Jun 11, 2026
40f0efd
Add cross-paragraph native reader selection
DaviRain-Su Jun 11, 2026
d540d7a
Polish native reader selection recovery
DaviRain-Su Jun 11, 2026
4a5c69f
feat: highlight notes + exact jump-back; precise resume and async images
DaviRain-Su Jun 11, 2026
bd381b5
docs: sync README and architecture to the native reader
DaviRain-Su Jun 11, 2026
ef82b82
test: deterministic screenshot/smoke launch hooks on both platforms
DaviRain-Su Jun 11, 2026
e092457
Stabilize native reader smoke tests
DaviRain-Su Jun 11, 2026
3425023
feat: EPUB parser covers div text, tables, footnotes, code, nested lists
DaviRain-Su Jun 11, 2026
9811950
Expand native reader smoke coverage
DaviRain-Su Jun 11, 2026
8c1e0f4
feat: iOS reader gains 导读 mode and background pretranslation
DaviRain-Su Jun 11, 2026
f516e68
chore: stop tracking default.profraw
DaviRain-Su Jun 11, 2026
0b01154
fix: *.profraw gitignore entry was concatenated onto the fastlane line
DaviRain-Su Jun 11, 2026
1352bee
feat: reader themes and body-font choices on both platforms
DaviRain-Su Jun 11, 2026
cd228ac
feat: 微信读书-style page turning on iOS; reader stops janking
DaviRain-Su Jun 11, 2026
52acfad
perf: debounce live selection callbacks; note lookups stop scanning
DaviRain-Su Jun 11, 2026
e181267
feat: Mac page turning; missing translations retry and backfill
DaviRain-Su Jun 11, 2026
5cd3ff3
feat: P0 visual handoff — marker trio, underline highlights, quiet 划词…
DaviRain-Su Jun 11, 2026
e149815
feat: P0 drawer — bookmarks, spoiler-folded search, three-tab TOC panel
DaviRain-Su Jun 11, 2026
9fdc0c8
feat: P0 多模型 — provider registry, per-feature routing, fallback chain
DaviRain-Su Jun 11, 2026
bfa5bcb
feat: P1 阅读统计 — active-time metering, charts, measured speed
DaviRain-Su Jun 11, 2026
7613b29
feat: P1 朗读 — sentence follow-along, resume-from-position, rate, 连读
DaviRain-Su Jun 11, 2026
fbaaa00
feat: P1 导出 — Markdown/plain excerpts with empty:// deep links
DaviRain-Su Jun 11, 2026
815e9ac
feat: P1 繁简 + PDF tools — ICU traditional display, zoom memory, inver…
DaviRain-Su Jun 11, 2026
3edf2ef
perf: mouse moves stop re-parsing the chapter; parse results memoize
DaviRain-Su Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ playground.xcworkspace
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output*.profraw
fastlane/test_output
*.profraw
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions Empty/DesignSystem/EmptyTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)

Expand All @@ -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)
)
}
Expand Down
149 changes: 149 additions & 0 deletions Empty/DesignSystem/ReaderAppearance.swift
Original file line number Diff line number Diff line change
@@ -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: "上下滚动"
}
}
}
8 changes: 7 additions & 1 deletion Empty/EmptyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 2 additions & 0 deletions Empty/Models/AppStores.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ enum AppStores {
ReadingSession.self,
VocabEntry.self,
StudyCardEntry.self,
Bookmark.self,
])

static let localSchema = Schema([
Expand Down Expand Up @@ -87,6 +88,7 @@ enum AppStores {
ReadingSession.self,
VocabEntry.self,
StudyCardEntry.self,
Bookmark.self,
Chapter.self,
Chunk.self,
ParagraphTranslation.self,
Expand Down
3 changes: 3 additions & 0 deletions Empty/Models/Book.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Empty/Models/Bookmark.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
33 changes: 32 additions & 1 deletion Empty/Models/Highlight.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Empty/Models/ReadingSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading