- actualContentHeight 通过 GeometryReader 获取真实内容高度 - scrollProgress 使用实际高度计算,精度显著提升 - #36 ReadingEventCollector 已清理完毕(无 ObservableObject/Combine) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
554 lines
23 KiB
Swift
554 lines
23 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
|
||
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))
|
||
}
|