960 lines
39 KiB
Swift
960 lines
39 KiB
Swift
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
|
||
var 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 {
|
||
private enum BootstrapState: Equatable {
|
||
case idle
|
||
case bootstrapping
|
||
case ready
|
||
case failed(String)
|
||
}
|
||
|
||
@StateObject private var vm: MaterialReaderViewModel
|
||
@Binding var showQuickLook: Bool
|
||
@Binding var showNoteSheet: Bool
|
||
@State private var scrollProgress: CGFloat = 0
|
||
@State private var bootstrapState: BootstrapState = .idle
|
||
@State private var pendingRestoreBlockId: String?
|
||
@State private var isInitialRestoreComplete = false
|
||
@State private var isProgrammaticScrollInFlight = false
|
||
@State private var actualContentHeight: CGFloat = 1
|
||
@State private var isMarkedRead = false
|
||
@State private var readingStatus: String?
|
||
@State private var readingProgressSeconds: Int = 0
|
||
@State private var apiRestorePosition: ReadingPosition?
|
||
@State private var localRestoreCheckpoint: ReadingCheckpoint?
|
||
@State private var flushTimer: Timer?
|
||
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@State private var isParsing = false
|
||
@State private var parseMessage = ""
|
||
@State private var showParseOverlay = false
|
||
@State private var latestKnowledgeTask: KnowledgeGenerationTaskStatus?
|
||
@State private var knowledgeTaskPollingTask: Task<Void, Never>?
|
||
private let title: String
|
||
private let knowledgeBaseId: String?
|
||
|
||
// V2 session manager
|
||
private let sessionManager = ReadingRuntimeSessionManager.shared
|
||
private let positionStore = ReadingPositionStore.shared
|
||
private let readingAPI = ReadingAPIService.shared
|
||
|
||
/// Whether the V2 session manager is active (has an open session).
|
||
private var isV2Active: Bool {
|
||
sessionManager.state == .active || sessionManager.state == .paused
|
||
}
|
||
|
||
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil, 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))
|
||
if let rp = resumePosition {
|
||
_apiRestorePosition = State(initialValue: ReadingPositionAdapter.fromValue(rp))
|
||
}
|
||
}
|
||
|
||
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 bootstrapReader(forceReload: true) } }
|
||
.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) {
|
||
HStack(spacing: 4) {
|
||
Button { markAsRead() } label: {
|
||
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
|
||
}
|
||
|
||
if let kbId = knowledgeBaseId {
|
||
Menu {
|
||
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
|
||
scopeType: .material, scopeId: vm.materialId, scopeName: title,
|
||
parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_reader"
|
||
))) {
|
||
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
|
||
}
|
||
NavigationLink(value: Route.materialDetail(
|
||
knowledgeBaseId: kbId, sourceId: vm.materialId, fileName: title.isEmpty ? "资料" : title,
|
||
fileType: vm.materialType, fileSize: 0, filePath: vm.filePath, uploadDate: nil
|
||
)) {
|
||
Label("资料详情", systemImage: "info.circle")
|
||
}
|
||
NavigationLink(value: Route.learningSession(
|
||
taskTitle: title.isEmpty ? "学习资料" : title, taskType: "study", taskColorHex: "#3D7FFB"
|
||
)) {
|
||
Label("学习会话", systemImage: "arrow.triangle.2.circlepath")
|
||
}
|
||
Button {
|
||
Task { await generateKnowledgePoints(kbId: kbId, sourceId: vm.materialId) }
|
||
} label: {
|
||
Label(generateKnowledgeMenuTitle, systemImage: "brain.head.profile")
|
||
}
|
||
.disabled(isKnowledgeTaskActive)
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.font(.system(size: 20))
|
||
.foregroundColor(Color.zxF05)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
if showParseOverlay {
|
||
HStack(spacing: 8) {
|
||
if isParsing { ProgressView().tint(.white) }
|
||
Text(parseMessage).font(.system(size: 13, weight: .medium)).foregroundColor(.white)
|
||
}
|
||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||
.background(Color.black.opacity(0.75))
|
||
.clipShape(Capsule())
|
||
.padding(.bottom, 100)
|
||
}
|
||
}
|
||
.task {
|
||
await bootstrapReader()
|
||
await refreshKnowledgeTaskStatus()
|
||
restartKnowledgeTaskPollingIfNeeded()
|
||
}
|
||
.onDisappear { stopKnowledgeTaskPolling() }
|
||
.sheet(isPresented: $showQuickLook) {
|
||
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
|
||
}
|
||
.sheet(isPresented: $showNoteSheet) {
|
||
QuickNoteSheet(
|
||
materialId: vm.materialId,
|
||
materialName: vm.materialType.displayName,
|
||
anchor: buildAnchor()
|
||
)
|
||
}
|
||
.onAppear {
|
||
// 定时发送阅读事件(每 2 分钟),防止长时间阅读不退出导致数据丢失
|
||
if flushTimer == nil {
|
||
flushTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in
|
||
Task {
|
||
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||
await ReadingEventUploadPipeline.shared.flush()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onDisappear {
|
||
flushTimer?.invalidate()
|
||
flushTimer = nil
|
||
// 1. 关闭 Rust 会话,push material_closed 事件到缓冲区
|
||
closeReadingSession()
|
||
// 2. 导出所有事件(含 material_closed)+ 发送到后端
|
||
// 3. 发送完成后再清理 context
|
||
Task {
|
||
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||
await ReadingEventUploadPipeline.shared.flush()
|
||
sessionManager.cleanup()
|
||
}
|
||
}
|
||
.onChange(of: scenePhase) { _, newPhase in
|
||
switch newPhase {
|
||
case .background:
|
||
if isV2Active {
|
||
sessionManager.pause()
|
||
print("[READER] Scene → background, session paused")
|
||
}
|
||
// Flush: export → enqueue
|
||
Task {
|
||
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||
await ReadingEventUploadPipeline.shared.flush()
|
||
}
|
||
case .active:
|
||
if sessionManager.state == .paused {
|
||
sessionManager.resume()
|
||
print("[READER] Scene → active, session resumed")
|
||
}
|
||
case .inactive:
|
||
break
|
||
@unknown default:
|
||
break
|
||
}
|
||
}
|
||
.onChange(of: apiRestorePosition) { _, newPos in
|
||
if newPos != nil,
|
||
vm.loadingState == .loaded,
|
||
!isInitialRestoreComplete,
|
||
pendingRestoreBlockId == nil {
|
||
restoreInitialPositionIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Reading progress indicator
|
||
if let status = readingStatus, status != "not_started" {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: status == "read" ? "book.closed.fill" : "book.pages")
|
||
.font(.system(size: 11))
|
||
Text(status == "read" ? "已读完" : "阅读中")
|
||
.font(.system(size: 11, weight: .medium))
|
||
if readingProgressSeconds > 0 {
|
||
Text("· \(formatDuration(readingProgressSeconds))")
|
||
.font(.system(size: 11))
|
||
}
|
||
}
|
||
.foregroundColor(status == "read" ? Color.green : Color.zxF03)
|
||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
.background(Color.zxFill004)
|
||
.clipShape(Capsule())
|
||
}
|
||
|
||
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(ReadingPositionAdapter.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()
|
||
}
|
||
.onAppear {
|
||
consumePendingRestore(using: scrollProxy)
|
||
}
|
||
.onChange(of: pendingRestoreBlockId) { _, target in
|
||
guard target != nil else { return }
|
||
consumePendingRestore(using: scrollProxy)
|
||
}
|
||
.scrollIndicators(.hidden)
|
||
}
|
||
}
|
||
|
||
// MARK: - Reading Session Lifecycle
|
||
|
||
private func openReadingSession() {
|
||
let fileName = URL(fileURLWithPath: vm.filePath).lastPathComponent
|
||
let context = ReadingMaterialContext(
|
||
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
||
materialId: vm.materialId,
|
||
knowledgeBaseId: knowledgeBaseId,
|
||
title: title.isEmpty ? nil : title,
|
||
materialType: vm.materialType,
|
||
filePath: vm.filePath,
|
||
fileName: fileName,
|
||
fileType: vm.materialType.fileTypeString
|
||
)
|
||
|
||
// V2 session
|
||
do {
|
||
try sessionManager.openMaterial(context)
|
||
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
||
} catch {
|
||
print("[READER] V2 session open FAILED: \(error) — materialId=\(vm.materialId)")
|
||
}
|
||
}
|
||
|
||
private func bootstrapReader(forceReload: Bool = false) async {
|
||
if bootstrapState == .bootstrapping {
|
||
return
|
||
}
|
||
if bootstrapState == .ready && !forceReload {
|
||
return
|
||
}
|
||
|
||
bootstrapState = .bootstrapping
|
||
pendingRestoreBlockId = nil
|
||
isProgrammaticScrollInFlight = false
|
||
isInitialRestoreComplete = false
|
||
|
||
await ensureLocalFileIfNeeded()
|
||
await vm.load()
|
||
|
||
guard vm.loadingState == .loaded else {
|
||
if case .error(let message) = vm.loadingState {
|
||
bootstrapState = .failed(message)
|
||
}
|
||
return
|
||
}
|
||
|
||
openReadingSession()
|
||
await fetchReadingProgress()
|
||
restoreInitialPositionIfNeeded()
|
||
if pendingRestoreBlockId == nil {
|
||
isInitialRestoreComplete = true
|
||
}
|
||
bootstrapState = .ready
|
||
}
|
||
|
||
private func closeReadingSession() {
|
||
// V2 session
|
||
if sessionManager.state == .active || sessionManager.state == .paused {
|
||
if let lastPos = sessionManager.lastPosition {
|
||
positionStore.save(materialId: vm.materialId, position: lastPos)
|
||
}
|
||
sessionManager.closeMaterial()
|
||
print("[READER] V2 session closed")
|
||
}
|
||
}
|
||
|
||
/// Mark the current material as read — optimistic update + event push.
|
||
private func markAsRead() {
|
||
guard !isMarkedRead else { return }
|
||
isMarkedRead = true
|
||
MarkedReadStore.shared.mark(vm.materialId)
|
||
sessionManager.markAsRead()
|
||
}
|
||
|
||
/// Build a NoteAnchor from the current scroll position (for quick note).
|
||
private func buildAnchor() -> NoteAnchor? {
|
||
let pos = sessionManager.lastPosition
|
||
guard let pos else { return nil }
|
||
return createNoteAnchor(materialId: vm.materialId, position: pos)
|
||
}
|
||
|
||
private func ensureLocalFileIfNeeded() async {
|
||
guard vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) else {
|
||
return
|
||
}
|
||
guard let kbId = knowledgeBaseId else {
|
||
return
|
||
}
|
||
|
||
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
|
||
do {
|
||
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
|
||
if let fileId = source.fileId {
|
||
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
|
||
let url = URL(string: downloadUrl)!
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
|
||
let localURL = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("source_\(vm.materialId).\(ext)")
|
||
try data.write(to: localURL)
|
||
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
|
||
vm.filePath = localURL.path
|
||
}
|
||
} catch {
|
||
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func fetchReadingProgress() async {
|
||
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
||
|
||
do {
|
||
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
|
||
readingStatus = progress.status
|
||
readingProgressSeconds = progress.totalActiveSeconds ?? 0
|
||
if progress.isMarkedRead == true {
|
||
isMarkedRead = true
|
||
MarkedReadStore.shared.mark(vm.materialId)
|
||
}
|
||
if let lp = progress.lastPosition {
|
||
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
|
||
}
|
||
} catch {
|
||
if MarkedReadStore.shared.contains(vm.materialId) {
|
||
isMarkedRead = true
|
||
}
|
||
localRestoreCheckpoint = positionStore.loadCheckpoint(materialId: vm.materialId)
|
||
apiRestorePosition = localRestoreCheckpoint?.position ?? positionStore.load(materialId: vm.materialId)
|
||
print("[READER] Progress query failed, using local position: \(error)")
|
||
}
|
||
}
|
||
|
||
/// Restore saved reading position on re-entry.
|
||
/// Same-device local checkpoints are preferred over API progress because
|
||
/// the home "continue learning" flow can reopen the reader before the
|
||
/// latest uploaded position has been processed server-side.
|
||
private func restoreInitialPositionIfNeeded() {
|
||
guard !isInitialRestoreComplete else { return }
|
||
|
||
if let checkpoint = localRestoreCheckpoint,
|
||
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
|
||
applyPosition(checkpoint.position)
|
||
return
|
||
}
|
||
|
||
if let checkpoint = positionStore.loadCheckpoint(materialId: vm.materialId),
|
||
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
|
||
localRestoreCheckpoint = checkpoint
|
||
applyPosition(checkpoint.position)
|
||
return
|
||
}
|
||
|
||
if localRestoreCheckpoint != nil {
|
||
print("[READER] Skip incompatible local checkpoint — materialId=\(vm.materialId)")
|
||
localRestoreCheckpoint = nil
|
||
}
|
||
|
||
if let saved = positionStore.load(materialId: vm.materialId) {
|
||
applyPosition(saved)
|
||
return
|
||
}
|
||
|
||
if let apiPos = apiRestorePosition {
|
||
applyPosition(apiPos)
|
||
return
|
||
}
|
||
|
||
if let pos = sessionManager.lastPosition {
|
||
applyPosition(pos)
|
||
return
|
||
}
|
||
|
||
isInitialRestoreComplete = true
|
||
}
|
||
|
||
private func applyPosition(_ pos: ReadingPosition) {
|
||
if let restoreBlockId = ReadingPositionAdapter.blockIdForRestore(from: pos, in: vm.blocks) {
|
||
pendingRestoreBlockId = restoreBlockId
|
||
return
|
||
}
|
||
|
||
switch pos {
|
||
case .block, .markdown, .text:
|
||
isInitialRestoreComplete = true
|
||
print("[READER] Restore target missing, skipped — materialId=\(vm.materialId)")
|
||
case .pdf, .pdfViewport, .image, .imageViewport, .workbook, .slide, .epub, .unknown:
|
||
isInitialRestoreComplete = true
|
||
}
|
||
}
|
||
|
||
private func confirmRestoredPosition() {
|
||
guard let restoreBlockId = pendingRestoreBlockId else { return }
|
||
guard let restoredIndex = ReadingPositionAdapter.blockIndex(for: restoreBlockId, in: vm.blocks) else { return }
|
||
let restoredProgress: Float
|
||
if vm.blocks.count <= 1 {
|
||
restoredProgress = 0
|
||
} else {
|
||
restoredProgress = Float(restoredIndex) / Float(vm.blocks.count - 1)
|
||
}
|
||
guard let position = ReadingPositionAdapter.fromBlockScroll(
|
||
materialType: vm.materialType,
|
||
blocks: vm.blocks,
|
||
scrollProgress: restoredProgress,
|
||
blockIndex: restoredIndex
|
||
) else { return }
|
||
|
||
if sessionManager.state == .active {
|
||
sessionManager.updatePosition(position)
|
||
}
|
||
}
|
||
|
||
private func consumePendingRestore(using scrollProxy: ScrollViewProxy) {
|
||
guard let restoreBlockId = pendingRestoreBlockId else { return }
|
||
isProgrammaticScrollInFlight = true
|
||
DispatchQueue.main.async {
|
||
scrollProxy.scrollTo(restoreBlockId, anchor: .top)
|
||
DispatchQueue.main.async {
|
||
confirmRestoredPosition()
|
||
pendingRestoreBlockId = nil
|
||
isProgrammaticScrollInFlight = false
|
||
isInitialRestoreComplete = true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Report current scroll position to the active session manager.
|
||
private func reportScrollPosition() {
|
||
guard !vm.blocks.isEmpty else { return }
|
||
guard isInitialRestoreComplete else { return }
|
||
guard !isProgrammaticScrollInFlight else { return }
|
||
|
||
let idx = max(0, min(vm.blocks.count - 1,
|
||
Int(scrollProgress * CGFloat(vm.blocks.count))))
|
||
let sp = Float(scrollProgress)
|
||
|
||
guard let pos = ReadingPositionAdapter.fromBlockScroll(
|
||
materialType: vm.materialType,
|
||
blocks: vm.blocks,
|
||
scrollProgress: sp,
|
||
blockIndex: idx
|
||
) else { return }
|
||
|
||
// V2 primary
|
||
if sessionManager.state == .active {
|
||
sessionManager.updatePosition(pos)
|
||
}
|
||
}
|
||
|
||
// 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: - Parse Trigger
|
||
|
||
private func generateKnowledgePoints(kbId: String, sourceId: String) async {
|
||
guard !isParsing, !isKnowledgeTaskActive else { return }
|
||
isParsing = true
|
||
showParseOverlay = true
|
||
parseMessage = "正在提交知识点生成任务..."
|
||
do {
|
||
let result = try await KnowledgeSourceService.shared.generateKnowledge(kbId: kbId, sourceId: sourceId)
|
||
parseMessage = result.message
|
||
latestKnowledgeTask = result.asTaskStatus
|
||
ZXToastManager.shared.success(result.message)
|
||
restartKnowledgeTaskPollingIfNeeded()
|
||
} catch {
|
||
parseMessage = "生成失败: \(error.localizedDescription)"
|
||
ZXToastManager.shared.error(parseMessage)
|
||
}
|
||
isParsing = false
|
||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||
if !isParsing { showParseOverlay = false }
|
||
}
|
||
|
||
private var generateKnowledgeMenuTitle: String {
|
||
switch latestKnowledgeTask?.status {
|
||
case "waiting_for_parse":
|
||
return "等待解析中"
|
||
case "queued":
|
||
return "已入队"
|
||
case "running":
|
||
return "生成中"
|
||
case "completed":
|
||
return "重新生成知识点"
|
||
case "failed":
|
||
return "重新生成知识点"
|
||
default:
|
||
return "生成知识点"
|
||
}
|
||
}
|
||
|
||
private var isKnowledgeTaskActive: Bool {
|
||
guard let status = latestKnowledgeTask?.status else { return false }
|
||
return ["waiting_for_parse", "queued", "running"].contains(status)
|
||
}
|
||
|
||
private func refreshKnowledgeTaskStatus() async {
|
||
guard let kbId = knowledgeBaseId else { return }
|
||
do {
|
||
latestKnowledgeTask = try await KnowledgeSourceService.shared.latestKnowledgeGenerationTask(
|
||
kbId: kbId,
|
||
sourceId: vm.materialId
|
||
)
|
||
if let latestKnowledgeTask {
|
||
parseMessage = latestKnowledgeTask.message
|
||
}
|
||
} catch {
|
||
print("[MaterialReader] refreshKnowledgeTaskStatus ERROR: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func restartKnowledgeTaskPollingIfNeeded() {
|
||
stopKnowledgeTaskPolling()
|
||
guard isKnowledgeTaskActive else { return }
|
||
knowledgeTaskPollingTask = Task {
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||
if Task.isCancelled { break }
|
||
await refreshKnowledgeTaskStatus()
|
||
if !isKnowledgeTaskActive { break }
|
||
}
|
||
knowledgeTaskPollingTask = nil
|
||
}
|
||
}
|
||
|
||
private func stopKnowledgeTaskPolling() {
|
||
knowledgeTaskPollingTask?.cancel()
|
||
knowledgeTaskPollingTask = nil
|
||
}
|
||
|
||
// 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 formatDuration(_ seconds: Int) -> String {
|
||
if seconds < 60 { return "\(seconds)s" }
|
||
if seconds < 3600 { return "\(seconds / 60)m" }
|
||
return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m"
|
||
}
|
||
|
||
|
||
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))
|
||
}
|