wangdl 042256913e fix(IOS-INFO-043): move triggerParse inside MaterialReaderView struct
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 11:30:08 +08:00

762 lines
31 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
@State private var isMarkedRead = false
@State private var readingStatus: String?
@State private var readingProgressSeconds: Int = 0
@State private var apiRestorePosition: ReadingPosition?
@Environment(\.scenePhase) private var scenePhase
@State private var isParsing = false
@State private var parseMessage = ""
private let title: String
private let knowledgeBaseId: String?
// V2 session manager (primary) + V1 collector (fallback)
private let sessionManager = ReadingRuntimeSessionManager.shared
private let collector = ReadingEventCollector.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 = "", 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) {
HStack(spacing: 16) {
Button {
markAsRead()
} label: {
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16))
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
}
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)
}
if let kbId = knowledgeBaseId {
NavigationLink(value: Route.materialDetail(
knowledgeBaseId: kbId,
fileName: title.isEmpty ? "资料" : title,
fileType: vm.materialType,
fileSize: 0,
filePath: vm.filePath,
uploadDate: nil
)) {
Image(systemName: "info.circle")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
NavigationLink(value: Route.learningSession(
taskTitle: title.isEmpty ? "学习资料" : title,
taskType: "study",
taskColorHex: "#3D7FFB"
)) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
Button {
Task { await triggerParse(kbId: kbId, sourceId: vm.materialId) }
} label: {
Image(systemName: "brain.head.profile")
.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 {
openReadingSession()
hasRestoredPosition = false
}
.onDisappear {
closeReadingSession()
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
if isV2Active {
sessionManager.pause()
print("[READER] Scene → background, session paused")
}
// Flush: export enqueue
Task {
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: 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) {
// 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()
}
.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)
}
}
// MARK: - Reading Session Lifecycle
private func openReadingSession() {
// Prevent duplicate open
guard !isV2Active else {
print("[READER] V2 session already active, skipping open")
return
}
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
let context = ReadingMaterialContext(
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
materialId: vm.materialId,
knowledgeBaseId: knowledgeBaseId,
title: title.isEmpty ? nil : title
)
// V2 primary
if let _ = try? sessionManager.openMaterial(context) {
print("[READER] V2 session opened — materialId=\(vm.materialId)")
} else {
print("[READER] V2 unavailable, falling back to V1")
collector.open(materialId: vm.materialId)
}
// Query reading progress from API
Task {
do {
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
await MainActor.run {
readingStatus = progress.status
readingProgressSeconds = progress.totalActiveSeconds
if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) }
if let lp = progress.lastPosition {
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
}
}
} catch {
// Fallback to local cache when API unavailable
if MarkedReadStore.shared.contains(vm.materialId) { isMarkedRead = true }
apiRestorePosition = positionStore.load(materialId: vm.materialId)
print("[READER] Progress query failed, using local position: \(error)")
}
}
}
private func closeReadingSession() {
// V2 primary
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")
return
}
// V1 fallback
if let lastPos = collector.lastPosition {
positionStore.save(materialId: vm.materialId, position: lastPos)
}
_ = collector.close(materialId: vm.materialId)
print("[READER] V1 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)
if isV2Active {
sessionManager.markAsRead()
} else {
collector.markAsRead(materialId: vm.materialId)
}
}
/// Build a NoteAnchor from the current scroll position (for quick note).
private func buildAnchor() -> NoteAnchor? {
let pos = sessionManager.lastPosition ?? collector.lastPosition
guard let pos else { return nil }
return createNoteAnchor(materialId: vm.materialId, position: pos)
}
/// Restore saved reading position on re-entry.
/// Priority: API lastPosition > local positionStore > session lastPosition
private func restorePosition() {
// If API already fetched a position, use it immediately
if let apiPos = apiRestorePosition {
applyPosition(apiPos)
return
}
// Fallback: local store
if let saved = positionStore.load(materialId: vm.materialId) {
applyPosition(saved)
return
}
// Last resort: session/collector
if let pos = sessionManager.lastPosition ?? collector.lastPosition {
applyPosition(pos)
}
}
private func applyPosition(_ pos: ReadingPosition) {
switch pos {
case .markdown(let blockId, _):
restoreBlockId = blockId
case .text(let lineNumber, _):
let idx = Int(lineNumber) - 1
if idx >= 0, idx < vm.blocks.count {
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
}
case .pdf, .image, .epub, .unknown:
break
}
}
/// Report current scroll position to the active session manager.
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 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)
} else {
// V1 fallback
collector.updatePosition(materialId: vm.materialId, position: 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 triggerParse(kbId: String, sourceId: String) async {
guard !isParsing else { return }
isParsing = true
parseMessage = "正在整理知识点..."
do {
let result = try await KnowledgeSourceService.shared.parse(kbId: kbId, sourceId: sourceId)
parseMessage = "知识点整理任务已提交"
for _ in 0..<60 {
try await Task.sleep(nanoseconds: 2_000_000_000)
let status = try await DocumentImportService.shared.getStatus(id: result.jobId ?? result.id ?? "")
if status.status == "completed" { parseMessage = "知识点整理完成"; break }
else if status.status == "failed" { parseMessage = "整理失败"; break }
else { parseMessage = "整理中... \(status.progress ?? 0)%" }
}
} catch {
parseMessage = "整理失败: \(error.localizedDescription)"
}
isParsing = false
}
// 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))
}