wangdl d9828bc3c8 fix: #34 #35 移除 contentHeightEstimate 80px 硬编码
- actualContentHeight 通过 GeometryReader 获取真实内容高度
- scrollProgress 使用实际高度计算,精度显著提升
- #36 ReadingEventCollector 已清理完毕(无 ObservableObject/Combine)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 19:22:08 +08:00

554 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import QuickLook
import Combine
// MARK: - Preview mode mapping (mirrors Rust MaterialType::preview_mode)
func previewMode(for type: MaterialType) -> PreviewMode {
switch type {
case .markdown, .text, .image, .epub: .nativeReader
case .pdf, .word, .excel: .platformPreview
case .powerPoint: .externalOpen
case .unknown: .unsupported
}
}
// MARK: - ViewModel
@MainActor
final class MaterialReaderViewModel: ObservableObject {
@Published var loadingState: LoadState = .loading
@Published var blocks: [DocumentBlock] = []
@Published var textContent: String = ""
@Published var imageMeta: ImageMeta?
@Published var stats: TextStats?
let materialId: String
let filePath: String
let materialType: MaterialType
let mode: PreviewMode
enum LoadState: Equatable {
case idle, loading, loaded, error(String)
}
init(materialId: String, filePath: String, materialType: MaterialType) {
self.materialId = materialId
self.filePath = filePath
self.materialType = materialType
self.mode = previewMode(for: materialType)
print("[READER] init — materialId=\(materialId), type=\(materialType), path=\(filePath)")
}
func load() async {
loadingState = .loading
print("[READER] load() start — materialType=\(materialType)")
// Check file before doing anything
let fileExists = FileManager.default.fileExists(atPath: filePath)
let fileSize: Int = (try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int) ?? -1
print("[READER] file exists=\(fileExists), size=\(fileSize)")
do {
switch materialType {
case .markdown:
let t0 = CFAbsoluteTimeGetCurrent()
let content = try String(contentsOfFile: filePath, encoding: .utf8)
let t1 = CFAbsoluteTimeGetCurrent()
print("[READER] markdown read — contentLength=\(content.count), readMs=\((t1-t0)*1000)")
print("[READER] calling parseMarkdown...")
blocks = try parseMarkdown(content: content)
let t2 = CFAbsoluteTimeGetCurrent()
print("[READER] parseMarkdown done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)")
case .text:
let t0 = CFAbsoluteTimeGetCurrent()
let content = try String(contentsOfFile: filePath, encoding: .utf8)
let t1 = CFAbsoluteTimeGetCurrent()
print("[READER] text read — contentLength=\(content.count), readMs=\((t1-t0)*1000)")
print("[READER] calling parseText...")
blocks = try parseText(content: content)
let t2 = CFAbsoluteTimeGetCurrent()
print("[READER] parseText done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)")
print("[READER] calling readTextStats...")
stats = try? readTextStats(filePath: filePath)
print("[READER] readTextStats done — stats=\(String(describing: stats))")
case .image:
print("[READER] calling readImageMeta...")
imageMeta = try readImageMeta(filePath: filePath)
print("[READER] readImageMeta done — meta=\(String(describing: imageMeta))")
case .pdf, .word, .excel, .powerPoint, .epub, .unknown:
print("[READER] unsupported/native type — skipping Rust call")
}
loadingState = .loaded
print("[READER] load() finished successfully")
} catch let error as DocumentError {
print("[READER] DocumentError — \(error.localizedDescription), type=\(error)")
loadingState = .error(error.localizedDescription)
} catch {
print("[READER] error — \(error.localizedDescription), type=\(type(of: error))")
loadingState = .error(error.localizedDescription)
}
}
}
// MARK: - Main View
struct MaterialReaderView: View {
@StateObject private var vm: MaterialReaderViewModel
@Binding var showQuickLook: Bool
@Binding var showNoteSheet: Bool
@State private var scrollProgress: CGFloat = 0
@State private var hasRestoredPosition = false
@State private var restoreBlockId: String?
@State private var actualContentHeight: CGFloat = 1
private let title: String
private let knowledgeBaseId: String?
// Event collector records reading events during the session
private let collector = ReadingEventCollector.shared
private let positionStore = ReadingPositionStore.shared
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
self.title = title
self.knowledgeBaseId = knowledgeBaseId
self._showQuickLook = showQuickLook
self._showNoteSheet = showNoteSheet
_vm = StateObject(wrappedValue: MaterialReaderViewModel(
materialId: materialId, filePath: filePath, materialType: materialType))
}
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
switch vm.loadingState {
case .idle, .loading:
VStack(spacing: 12) {
ProgressView().tint(Color.zxPrimary)
Text("加载资料…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
case .error(let msg):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
Button("重试") { Task { await vm.load() } }
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
.padding(.horizontal, 24).padding(.vertical, 10)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
}
case .loaded:
switch vm.mode {
case .nativeReader:
nativeReaderBody
case .platformPreview:
platformPreviewBody
case .externalOpen:
externalOpenBody
case .unsupported:
unsupportedBody
}
}
}
.navigationTitle(title.isEmpty ? vm.materialType.displayName : title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
scopeType: .material,
scopeId: vm.materialId,
scopeName: title,
parentKnowledgeBaseId: knowledgeBaseId,
createdFrom: "material_reader"
))) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
}
}
.task { await vm.load() }
.sheet(isPresented: $showQuickLook) {
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
}
.sheet(isPresented: $showNoteSheet) {
QuickNoteSheet(
materialId: vm.materialId,
materialName: vm.materialType.displayName,
anchor: buildAnchor()
)
}
.onAppear {
// FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS)
// collector.open(materialId: vm.materialId)
hasRestoredPosition = false
}
.onDisappear {
// FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS)
// if let lastPos = collector.lastPosition {
// positionStore.save(materialId: vm.materialId, position: lastPos)
// }
// _ = collector.close(materialId: vm.materialId)
}
.onChange(of: vm.loadingState) { _, newState in
if newState == .loaded, !hasRestoredPosition {
restorePosition()
hasRestoredPosition = true
}
}
}
// MARK: - Native reader (Markdown, TXT, Image)
@ViewBuilder
var nativeReaderBody: some View {
if vm.materialType == .image, let meta = vm.imageMeta {
imageBody(meta: meta)
} else if !vm.blocks.isEmpty {
markdownBody
} else if !vm.textContent.isEmpty {
textBody
}
}
// MARK: Markdown block list
var markdownBody: some View {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let s = vm.stats {
HStack(spacing: 16) {
Label("\(s.lineCount)", systemImage: "text.alignleft")
Label("\(s.wordCount)", systemImage: "character")
}
.font(.system(size: 12)).foregroundColor(Color.zxF03)
.padding(.bottom, 4)
}
ForEach(Array(vm.blocks.enumerated()), id: \.offset) { _, block in
DocumentBlockView(block: block)
.id(blockId(from: block))
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
.background(GeometryReader { geo in
Color.clear
.preference(key: ScrollOffsetKey.self, value: geo.frame(in: .named("scroll")).minY)
.onAppear { actualContentHeight = geo.size.height }
})
.onChange(of: vm.blocks.count) { _,_ in
// Height will be updated by GeometryReader on next render
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
scrollProgress = min(1, max(0, -offset / max(actualContentHeight, 1)))
reportScrollPosition()
}
.onChange(of: restoreBlockId) { _, target in
if let id = target {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
withAnimation { scrollProxy.scrollTo(id, anchor: .top) }
}
restoreBlockId = nil
}
}
.scrollIndicators(.hidden)
}
}
/// Build a NoteAnchor from the current scroll position (for quick note).
private func buildAnchor() -> NoteAnchor? {
guard let pos = collector.lastPosition else { return nil }
return createNoteAnchor(materialId: vm.materialId, position: pos)
}
/// Restore saved reading position on re-entry.
private func restorePosition() {
guard let saved = positionStore.load(materialId: vm.materialId) else { return }
switch saved {
case .markdown(let blockId, _):
restoreBlockId = blockId
case .text(let lineNumber, _):
let idx = Int(lineNumber) - 1
if idx >= 0, idx < vm.blocks.count {
restoreBlockId = blockId(from: vm.blocks[idx])
}
case .pdf, .image, .epub, .unknown:
break // PDF/image restoration needs platform-specific handling
}
}
/// Report current scroll position to the event collector.
private func reportScrollPosition() {
guard !vm.blocks.isEmpty else { return }
let idx = max(0, min(vm.blocks.count - 1,
Int(scrollProgress * CGFloat(vm.blocks.count))))
let block = vm.blocks[idx]
let bId = blockId(from: block)
let sp = Float(scrollProgress)
let pos: ReadingPosition
switch vm.materialType {
case .markdown:
pos = .markdown(blockId: bId, scrollProgress: sp)
case .text:
pos = .text(lineNumber: UInt32(idx + 1), scrollProgress: sp)
default:
pos = .markdown(blockId: bId, scrollProgress: sp)
}
collector.updatePosition(materialId: vm.materialId, position: pos)
}
private func blockId(from block: DocumentBlock) -> String {
switch block {
case .heading(let id, _, _): return id
case .paragraph(let id, _): return id
case .list(let id, _, _): return id
case .codeBlock(let id, _, _): return id
case .quote(let id, _): return id
case .table(let id, _, _): return id
case .imageBlock(let id, _, _): return id
case .horizontalRule(let id): return id
}
}
// MARK: Plain text fallback
var textBody: some View {
ScrollView {
Text(vm.textContent)
.font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}
.scrollIndicators(.hidden)
}
// MARK: Image
func imageBody(meta: ImageMeta) -> some View {
VStack {
if let uiImage = UIImage(contentsOfFile: vm.filePath) {
Image(uiImage: uiImage).resizable().scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity).padding(20)
}
Text("\(meta.width)×\(meta.height) · \(meta.format.uppercased()) · \(formatFileSize(meta.fileSize))")
.font(.system(size: 12)).foregroundColor(Color.zxF03)
}
}
// MARK: - Platform preview (PDF, Word, Excel)
var platformPreviewBody: some View {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "doc.text").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text(vm.materialType.displayName).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text("使用系统预览打开此文件").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
Button { showQuickLook = true } label: {
HStack(spacing: 8) { Image(systemName: "eye"); Text("打开预览") }
.font(.system(size: 14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: 48)
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}.padding(.horizontal, 40)
}
}
// MARK: - External open (PPT)
var externalOpenBody: some View {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "arrow.up.forward.app").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text("PowerPoint 演示文稿").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text("此格式需要外部应用打开").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
if let url = URL(string: "shareddocuments://" + vm.filePath) {
ShareLink(item: url) {
HStack(spacing: 8) { Image(systemName: "square.and.arrow.up"); Text("用其他应用打开") }
.font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPrimary)
.frame(maxWidth: .infinity).frame(height: 48)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
}
}
}
}
// MARK: - Unsupported
var unsupportedBody: some View {
VStack(spacing: 16) {
Image(systemName: "questionmark.folder").font(.system(size: 48)).foregroundColor(Color.zxF03)
Text("暂不支持此格式").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0)
Text(vm.filePath).font(.system(size: 12)).foregroundColor(Color.zxF05)
}
}
}
// MARK: - DocumentBlock renderer (renders Rust-generated blocks via SwiftUI)
struct DocumentBlockView: View {
let block: DocumentBlock
var body: some View {
switch block {
case .heading(let id, let level, let text):
headingView(level: Int(level), text: text)
case .paragraph(let id, let text):
Text(text).font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6)
case .list(let id, let ordered, let items):
listView(ordered: ordered, items: items)
case .codeBlock(let id, let language, let code):
codeView(language: language, code: code)
case .quote(let id, let text):
quoteView(text: text)
case .table(let id, let headers, let rows):
tableView(headers: headers, rows: rows)
case .imageBlock(let id, let src, let alt):
imageBlockView(src: src, alt: alt)
case .horizontalRule(let id):
Rectangle().fill(Color.zxBorder006).frame(height: 1)
}
}
func headingView(level: Int, text: String) -> some View {
let sizes: [CGFloat] = [0, 24, 20, 17, 15, 14, 13]
let size = level < sizes.count ? sizes[level] : 13
return Text(text)
.font(.system(size: size, weight: .bold)).foregroundColor(Color.zxF0)
.padding(.top, level <= 1 ? 8 : 4)
}
func listView(ordered: Bool, items: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(items.enumerated()), id: \.offset) { i, item in
HStack(alignment: .top, spacing: 8) {
Text(ordered ? "\(i + 1)." : "")
.font(.system(size: 14, weight: ordered ? .medium : .regular))
.foregroundColor(Color.zxF04).frame(width: 20, alignment: ordered ? .trailing : .center)
Text(item).font(.system(size: 14)).foregroundColor(Color.zxF0)
}
}
}
}
func codeView(language: String?, code: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let lang = language {
Text(lang).font(.system(size: 11, weight: .medium)).foregroundColor(Color.zxF03)
.padding(.horizontal, 12).padding(.top, 8)
}
Text(code).font(.system(size: 13, design: .monospaced)).foregroundColor(Color.zxF0)
.padding(12).frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
}
func quoteView(text: String) -> some View {
HStack(spacing: 0) {
Rectangle().fill(Color.zxPrimary.opacity(0.3)).frame(width: 3)
Text(text).font(.system(size: 14)).foregroundColor(Color.zxF04).italic().padding(.leading, 12)
}
}
func tableView(headers: [String], rows: [[String]]) -> some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(headers.enumerated()), id: \.offset) { _, h in
Text(h).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
.frame(maxWidth: .infinity, alignment: .leading).padding(8)
}
}.background(Color.zxFill004)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 0) {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell).font(.system(size: 13)).foregroundColor(Color.zxF0)
.frame(maxWidth: .infinity, alignment: .leading).padding(8)
}
}
}
}
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.zxBorder008, lineWidth: 1))
}
func imageBlockView(src: String, alt: String?) -> some View {
VStack(spacing: 6) {
if let url = URL(string: src), src.hasPrefix("http") {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFit().clipShape(RoundedRectangle(cornerRadius: 10))
case .failure: Rectangle().fill(Color.zxFill004).frame(height: 160).overlay(Image(systemName: "photo").foregroundColor(Color.zxF03))
default: ProgressView().frame(height: 160)
}
}
} else {
Rectangle().fill(Color.zxFill004).frame(height: 120)
.overlay(VStack(spacing: 4) { Image(systemName: "photo"); Text(src).font(.system(size: 11)) }.foregroundColor(Color.zxF03))
}
if let alt = alt { Text(alt).font(.system(size: 12)).foregroundColor(Color.zxF04) }
}
}
}
// MARK: - MaterialType display name
extension MaterialType {
var displayName: String {
switch self {
case .markdown: "Markdown"
case .text: "纯文本"
case .image: "图片"
case .pdf: "PDF"
case .word: "Word 文档"
case .excel: "Excel 表格"
case .powerPoint: "PowerPoint"
case .epub: "EPUB"
case .unknown: "未知格式"
}
}
}
// MARK: - QuickLook wrapper
struct QuickLookPreview: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> QLPreviewController {
let c = QLPreviewController()
c.dataSource = context.coordinator
return c
}
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
class Coordinator: NSObject, QLPreviewControllerDataSource {
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { url as QLPreviewItem }
}
}
// MARK: - Scroll tracking
private struct ScrollOffsetKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
// MARK: - Helpers
private func formatFileSize(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}