mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
+11
-10
@@ -207,7 +207,7 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingTXTFileImporterModifier: ViewModifier {
|
||||
private struct ReadingLocalBookFileImporterModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
let onFiles: ([LocalBookImportFile]) -> Void
|
||||
let onError: (Error) -> Void
|
||||
@@ -219,7 +219,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
|
||||
pickerHasDismissed = true
|
||||
deliverFilesWhenReady()
|
||||
}) {
|
||||
ReadingTXTFilePicker { files in
|
||||
ReadingLocalBookFilePicker { files in
|
||||
pickedFiles = files
|
||||
isPresented = false
|
||||
deliverFilesWhenReady()
|
||||
@@ -239,7 +239,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
|
||||
guard pickerHasDismissed, !pickedFiles.isEmpty else { return }
|
||||
let files = pickedFiles
|
||||
pickedFiles = []
|
||||
guard files.allSatisfy({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else {
|
||||
guard files.allSatisfy({ LocalBookService.supports($0.url) && !$0.url.hasDirectoryPath }) else {
|
||||
onError(LocalBookError.unsupportedFormat)
|
||||
return
|
||||
}
|
||||
@@ -247,15 +247,16 @@ private struct ReadingTXTFileImporterModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
|
||||
private struct ReadingLocalBookFilePicker: UIViewControllerRepresentable {
|
||||
let onPick: @MainActor ([LocalBookImportFile]) -> Void
|
||||
let onCancel: @MainActor () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
// Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON.
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], asCopy: true)
|
||||
// Keep source JSON out of the local-book picker.
|
||||
let epub = UTType(filenameExtension: "epub") ?? UTType(importedAs: "org.idpf.epub-container")
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText, epub], asCopy: true)
|
||||
picker.allowsMultipleSelection = true
|
||||
picker.shouldShowFileExtensions = true
|
||||
picker.delegate = context.coordinator
|
||||
@@ -266,9 +267,9 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let parent: ReadingTXTFilePicker
|
||||
let parent: ReadingLocalBookFilePicker
|
||||
|
||||
init(parent: ReadingTXTFilePicker) { self.parent = parent }
|
||||
init(parent: ReadingLocalBookFilePicker) { self.parent = parent }
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() }
|
||||
|
||||
@@ -281,12 +282,12 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
|
||||
enum ReadingSheetDetent { case medium, large }
|
||||
|
||||
extension View {
|
||||
func readingTXTFileImporter(
|
||||
func readingLocalBookFileImporter(
|
||||
isPresented: Binding<Bool>,
|
||||
onFiles: @escaping ([LocalBookImportFile]) -> Void,
|
||||
onError: @escaping (Error) -> Void
|
||||
) -> some View {
|
||||
modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
|
||||
modifier(ReadingLocalBookFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
|
||||
}
|
||||
|
||||
func readingPhotoPicker(
|
||||
|
||||
+13
-4
@@ -300,11 +300,20 @@ struct BookCoverArtwork: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
|
||||
if case .success(let image) = phase {
|
||||
image.resizable().scaledToFill()
|
||||
Group {
|
||||
if let remoteURL, remoteURL.hasPrefix("data:image/"),
|
||||
let comma = remoteURL.firstIndex(of: ","),
|
||||
let data = Data(base64Encoded: String(remoteURL[remoteURL.index(after: comma)...])),
|
||||
let image = UIImage(data: data) {
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
|
||||
if case .success(let image) = phase {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
|
||||
@@ -126,7 +126,7 @@ struct FileManagementView: View {
|
||||
Button("完成") { selecting = false; selection.removeAll() }
|
||||
} else {
|
||||
Menu {
|
||||
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
Button("导入 TXT / EPUB", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
if tab == 0 {
|
||||
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
|
||||
.disabled(displayedFiles.isEmpty)
|
||||
@@ -163,7 +163,7 @@ struct FileManagementView: View {
|
||||
|
||||
private var importAndRenameView: some View {
|
||||
fileList
|
||||
.readingTXTFileImporter(isPresented: $importsFile) { files in
|
||||
.readingLocalBookFileImporter(isPresented: $importsFile) { files in
|
||||
Task { await model.importFiles(files, library: library) }
|
||||
} onError: { error in
|
||||
model.errorMessage = error.localizedDescription
|
||||
@@ -177,7 +177,7 @@ struct FileManagementView: View {
|
||||
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
|
||||
}
|
||||
} message: { _ in
|
||||
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
|
||||
Text("保留原文件格式,书架中的书名、书签和阅读进度不变。")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +239,8 @@ struct FileManagementView: View {
|
||||
}
|
||||
Section("文件") {
|
||||
if displayedFiles.isEmpty {
|
||||
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
|
||||
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
|
||||
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT 或 EPUB,文件会复制到应用中。"))
|
||||
Button("导入 TXT / EPUB", systemImage: "plus") { importsFile = true }
|
||||
}
|
||||
ForEach(displayedFiles) { file in
|
||||
Group {
|
||||
|
||||
@@ -360,7 +360,7 @@ final class BookLibraryModel: ObservableObject {
|
||||
|
||||
func addLocalFile(at url: URL) async throws {
|
||||
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||
let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value
|
||||
let report = try await Task.detached { [localService] in try localService.openFile(at: url) }.value
|
||||
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||
guard await persist(books + [report.book]) else {
|
||||
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||
|
||||
@@ -33,16 +33,20 @@ struct LocalBookDetailView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 14) {
|
||||
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
|
||||
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.customCoverUrl ?? book.coverUrl, width: 108, height: 154)
|
||||
Text(book.name).font(.title2.bold())
|
||||
if !book.author.isEmpty { Text(book.author).font(.subheadline).foregroundStyle(.secondary) }
|
||||
HStack {
|
||||
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
|
||||
StatusPill(text: "本地 " + (URL(string: book.bookUrl)?.pathExtension.uppercased() ?? "TXT"), systemImage: "doc.text.fill")
|
||||
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
|
||||
}
|
||||
Text(book.originName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
if let intro = book.intro, !intro.isEmpty {
|
||||
ReadingCard { Text(intro).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) }
|
||||
}
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
|
||||
@@ -151,6 +151,6 @@ struct SourceSwitchView: View {
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.disabled(model.switchingID != nil)
|
||||
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
|
||||
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.id)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject {
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let sourceRepository: SQLiteRssSourceRepository
|
||||
private let articleRepository: SQLiteRssArticleRepository
|
||||
private let starRepository: SQLiteRssStarRepository
|
||||
private let recordRepository: SQLiteRssReadRecordRepository
|
||||
private var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } }
|
||||
private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } }
|
||||
private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } }
|
||||
private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } }
|
||||
private let service: RSSService
|
||||
private let importer = RSSSourceImporter()
|
||||
private let database: LegadoDatabase
|
||||
private let databaseResult: Result<LegadoDatabase, Error>
|
||||
private var database: LegadoDatabase { get throws { try databaseResult.get() } }
|
||||
private var nextURL: String?
|
||||
private var page = 1
|
||||
private var order: Int64 = 0
|
||||
private struct Feed: Equatable {
|
||||
let origin: String
|
||||
let sort: String
|
||||
let key: String?
|
||||
}
|
||||
private var activeFeed: Feed?
|
||||
private var articleRequestID = UUID()
|
||||
private var loadingMoreRequestID: UUID?
|
||||
private var activeOperations = 0
|
||||
|
||||
init(database: LegadoDatabase, service: RSSService = .init()) {
|
||||
self.database = database
|
||||
sourceRepository = .init(database: database)
|
||||
articleRepository = .init(database: database)
|
||||
starRepository = .init(database: database)
|
||||
recordRepository = .init(database: database)
|
||||
databaseResult = .success(database)
|
||||
self.service = service
|
||||
}
|
||||
|
||||
static func live() -> RSSLibraryModel {
|
||||
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
|
||||
return .init(database: database)
|
||||
private init(databaseError: Error) {
|
||||
let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。")
|
||||
databaseResult = .failure(error)
|
||||
service = .init()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
static func live(databaseURL: URL = LegadoDatabase.defaultURL()) -> RSSLibraryModel {
|
||||
do { return .init(database: try LegadoDatabase(url: databaseURL)) }
|
||||
catch { return .init(databaseError: error) }
|
||||
}
|
||||
|
||||
func load() async {
|
||||
@@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject {
|
||||
func importText(_ text: String) async {
|
||||
await perform {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var merged: [RssSource]
|
||||
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
|
||||
self.sources = try await self.importRemote(trimmed, existing: self.sources)
|
||||
merged = try await self.importRemote(trimmed, existing: self.sources)
|
||||
} else {
|
||||
let payload = try self.importer.decode(Data(trimmed.utf8))
|
||||
var merged = try self.importer.merge(payload.sources, into: self.sources)
|
||||
merged = try self.importer.merge(payload.sources, into: self.sources)
|
||||
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
|
||||
self.sources = merged
|
||||
}
|
||||
try await self.sourceRepository.save(self.sources)
|
||||
try await self.sourceRepository.save(merged)
|
||||
self.sources = try await self.sourceRepository.all()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,31 +144,53 @@ final class RSSLibraryModel: ObservableObject {
|
||||
}
|
||||
|
||||
func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async {
|
||||
page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
let name = sortName ?? source.sourceName
|
||||
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl
|
||||
await perform {
|
||||
let feed = Feed(origin: source.sourceUrl, sort: name, key: key)
|
||||
let requestID = UUID()
|
||||
articleRequestID = requestID
|
||||
loadingMoreRequestID = nil
|
||||
if activeFeed != feed { articles = [] }
|
||||
activeFeed = feed
|
||||
nextURL = nil
|
||||
page = 1
|
||||
await perform(isCurrent: { self.articleRequestID == requestID }) {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
var nextOrder = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 }
|
||||
try await self.articleRepository.put(values)
|
||||
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) }
|
||||
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: nextOrder) }
|
||||
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
self.order = nextOrder
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
self.articles = articles
|
||||
}
|
||||
}
|
||||
|
||||
func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async {
|
||||
guard let nextURL, !nextURL.isEmpty else { return }
|
||||
page += 1
|
||||
let name = sortName ?? source.sourceName
|
||||
await perform {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key)
|
||||
guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key),
|
||||
loadingMoreRequestID == nil, let nextURL, !nextURL.isEmpty else { return }
|
||||
let requestID = articleRequestID
|
||||
loadingMoreRequestID = requestID
|
||||
defer { if loadingMoreRequestID == requestID { loadingMoreRequestID = nil } }
|
||||
let nextPage = page + 1
|
||||
await perform(isCurrent: { self.articleRequestID == requestID }) {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: nextPage, key: key)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
var nextOrder = self.order
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 }
|
||||
try await self.articleRepository.append(values)
|
||||
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
guard self.articleRequestID == requestID else { return }
|
||||
self.page = nextPage
|
||||
self.order = nextOrder
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
self.articles = articles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +267,11 @@ final class RSSLibraryModel: ObservableObject {
|
||||
let payload = try importer.decode(data)
|
||||
return try importer.merge(payload.sources, into: existing)
|
||||
}
|
||||
private func perform(_ operation: () async throws -> Void) async {
|
||||
isWorking = true; defer { isWorking = false }
|
||||
do { try await operation() } catch { errorMessage = error.localizedDescription }
|
||||
private func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async {
|
||||
activeOperations += 1
|
||||
isWorking = true
|
||||
defer { activeOperations -= 1; isWorking = activeOperations > 0 }
|
||||
do { try await operation() } catch { if isCurrent() { errorMessage = error.localizedDescription } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,14 +151,20 @@ private struct RSSArticleListView: View {
|
||||
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
|
||||
.navigationTitle(source.sourceName)
|
||||
.searchable(text: $searchKey, prompt: "源内搜索")
|
||||
.onSubmit(of: .search) { Task { await refresh() } }
|
||||
.refreshable { await refresh() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } }
|
||||
ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } }
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button("加载下一页", systemImage: "arrow.down") {
|
||||
Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) }
|
||||
}.disabled(library.isWorking)
|
||||
}
|
||||
}
|
||||
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
|
||||
.readingTabBarHidden()
|
||||
}
|
||||
|
||||
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
|
||||
@@ -464,6 +470,32 @@ private struct RSSBinaryDocument: FileDocument {
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
|
||||
}
|
||||
|
||||
private struct RSSPhotoToolbar: ViewModifier {
|
||||
let canSave: Bool
|
||||
let save: () -> Void
|
||||
let close: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
|
||||
}.sharedBackgroundVisibility(.hidden)
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成", action: close).buttonStyle(.plain)
|
||||
}.sharedBackgroundVisibility(.hidden)
|
||||
}
|
||||
} else {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) { Button("完成", action: close) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RSSPhotoView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let value: String
|
||||
@@ -487,10 +519,7 @@ struct RSSPhotoView: View {
|
||||
}
|
||||
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
|
||||
.readingNavigationBarBackground()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
|
||||
}
|
||||
.modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() }))
|
||||
.task(id: value) { await load() }
|
||||
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
import ImageIO
|
||||
|
||||
struct EPUBIllustrationsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let book: Book
|
||||
let chapter: BookChapter
|
||||
@State private var images: [EPUBIllustration] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 24) {
|
||||
if isLoading {
|
||||
ProgressView("正在读取插图…").padding()
|
||||
} else if let errorMessage {
|
||||
ReadingUnavailableView("无法读取插图", systemImage: "photo", description: Text(errorMessage))
|
||||
} else if images.isEmpty {
|
||||
ReadingUnavailableView("本章没有插图", systemImage: "photo", description: Text(chapter.title))
|
||||
} else {
|
||||
ForEach(images) { image in
|
||||
VStack(spacing: 8) {
|
||||
EPUBIllustrationImage(data: image.data)
|
||||
Text("插图 \(image.id)\(image.caption.isEmpty ? "" : " · " + image.caption)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("本章插图")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
|
||||
.task(id: chapter.id) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
images = try await Task.detached { try LocalBookService().loadIllustrations(book: book, chapter: chapter) }.value
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EPUBIllustrationImage: View {
|
||||
let data: Data
|
||||
@State private var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFit().accessibilityLabel("书籍插图")
|
||||
} else {
|
||||
Text("图片无法显示").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode a bounded thumbnail off the main thread, even for huge source dimensions.
|
||||
image = await Task.detached {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 2_048,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true
|
||||
] as CFDictionary) else { return Optional<UIImage>.none }
|
||||
return UIImage(cgImage: thumbnail)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,8 @@ struct ReaderScrollView: UIViewRepresentable {
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let geometry = ReaderContinuousLayout.geometry(
|
||||
blocks: newBlocks, width: view.bounds.width,
|
||||
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height
|
||||
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom),
|
||||
viewportHeight: view.bounds.height, scale: view.traitCollection.displayScale
|
||||
)
|
||||
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
|
||||
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
|
||||
@@ -277,10 +278,14 @@ struct ReaderScrollView: UIViewRepresentable {
|
||||
private func captureAnchor() -> Anchor? {
|
||||
guard let view, !blocks.isEmpty else { return nil }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0)
|
||||
guard let row = layout.row(at: y) else { return nil }
|
||||
// The reading position starts below the configured top padding.
|
||||
// Preserve that reference point when earlier chapters are inserted.
|
||||
let y = max(view.contentOffset.y + CGFloat(max(parent.configuration.paddingTop, 0)),
|
||||
layout.frames.first?.minY ?? 0)
|
||||
let scale = max(view.traitCollection.displayScale, 1)
|
||||
guard let row = layout.row(at: (y * scale).rounded() / scale) else { return nil }
|
||||
let block = blocks[row], chapter = block.chapter
|
||||
let localY = y - layout.frames[row].minY + block.top
|
||||
let localY = max(y - layout.frames[row].minY, 0) + block.top
|
||||
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
|
||||
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
|
||||
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
|
||||
@@ -385,17 +390,21 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
|
||||
private var size = CGSize.zero
|
||||
override var collectionViewContentSize: CGSize { size }
|
||||
|
||||
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) {
|
||||
var y = top
|
||||
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat, scale: CGFloat) -> (frames: [CGRect], size: CGSize) {
|
||||
let scale = max(scale, 1)
|
||||
// Keep insertion offsets in whole physical pixels. UIKit rounds scroll
|
||||
// offsets, so fractional chapter heights otherwise accumulate drift.
|
||||
var pixels = (top * scale).rounded()
|
||||
let frames = blocks.map { block -> CGRect in
|
||||
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
|
||||
y += block.height
|
||||
let height = ceil(block.height * scale)
|
||||
let frame = CGRect(x: 0, y: pixels / scale, width: width, height: height / scale)
|
||||
pixels += height
|
||||
return frame
|
||||
}
|
||||
let finalChapter = blocks.last?.chapter.chapter.id
|
||||
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
|
||||
.map { frames[$0].minY } ?? top
|
||||
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom))
|
||||
return (frames, CGSize(width: width, height: max(pixels / scale, finalStart + viewportHeight) + bottom))
|
||||
}
|
||||
|
||||
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
|
||||
@@ -412,9 +421,9 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
|
||||
var low = 0, high = frames.count
|
||||
while low < high {
|
||||
let middle = (low + high) / 2
|
||||
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle }
|
||||
if frames[middle].minY <= y { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
return min(low, frames.count - 1)
|
||||
return max(low - 1, 0)
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
|
||||
@@ -18,6 +18,7 @@ struct ReaderView: View {
|
||||
@StateObject private var speech = ReaderSpeechController()
|
||||
@State private var showsTableOfContents = false
|
||||
@State private var showsBookmarks = false
|
||||
@State private var illustrationChapter: BookChapter?
|
||||
@State private var showsAutoReadControls = false
|
||||
@State private var showsStyleControls = false
|
||||
@State private var isAutoReading = false
|
||||
@@ -257,6 +258,9 @@ struct ReaderView: View {
|
||||
}
|
||||
.sheet(isPresented: $showsTableOfContents) { tableOfContents }
|
||||
.sheet(isPresented: $showsBookmarks) { bookmarksSheet }
|
||||
.sheet(item: $illustrationChapter) { chapter in
|
||||
EPUBIllustrationsView(book: book, chapter: chapter)
|
||||
}
|
||||
.sheet(isPresented: $showsAutoReadControls) { autoReadControls }
|
||||
.sheet(isPresented: $showsStyleControls) { styleControls }
|
||||
.sheet(isPresented: $showsContentSearch) { contentSearchSheet }
|
||||
@@ -698,6 +702,7 @@ struct ReaderView: View {
|
||||
|| showsReaderMenu
|
||||
|| showsTableOfContents
|
||||
|| showsBookmarks
|
||||
|| illustrationChapter != nil
|
||||
|| showsAutoReadControls
|
||||
|| showsStyleControls
|
||||
|| showsContentSearch
|
||||
@@ -834,6 +839,12 @@ struct ReaderView: View {
|
||||
showsReaderMenu = false
|
||||
showsContentSearch = true
|
||||
}
|
||||
if book.origin == "local", URL(string: book.bookUrl)?.pathExtension.lowercased() == "epub" {
|
||||
Button("本章插图", systemImage: "photo.on.rectangle") {
|
||||
showsReaderMenu = false
|
||||
illustrationChapter = model.currentChapter
|
||||
}
|
||||
}
|
||||
Button("书签列表", systemImage: "bookmark") {
|
||||
showsReaderMenu = false
|
||||
showsBookmarks = true
|
||||
@@ -1575,7 +1586,7 @@ struct ReaderView: View {
|
||||
Text("正文")
|
||||
} footer: {
|
||||
if !model.canEditContent {
|
||||
Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。")
|
||||
Text("本地 TXT / EPUB 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
|
||||
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
|
||||
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
|
||||
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
|
||||
view.inputAccessoryView = bar
|
||||
bar.items?.last?.accessibilityIdentifier = "syntaxCodeEditor.done"
|
||||
if #available(iOS 26.0, *) {
|
||||
view.inputAccessoryView = CodeInputAccessory(items: bar.items ?? [])
|
||||
} else {
|
||||
view.inputAccessoryView = bar
|
||||
}
|
||||
view.text = text
|
||||
context.coordinator.highlight(view)
|
||||
return view
|
||||
@@ -184,6 +189,66 @@ struct SyntaxCodeEditor: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep Done visible while allowing the editing shortcuts to scroll on narrow keyboards.
|
||||
private final class CodeInputAccessory: UIInputView {
|
||||
init(items: [UIBarButtonItem]) {
|
||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard)
|
||||
autoresizingMask = [.flexibleWidth]
|
||||
backgroundColor = .secondarySystemGroupedBackground
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
scroll.delaysContentTouches = false
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 4
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
let actions = items.filter { $0.primaryAction != nil }
|
||||
var done: UIButton?
|
||||
for (index, item) in actions.enumerated() {
|
||||
let button = UIButton(type: .system)
|
||||
if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) }
|
||||
button.setImage(item.image, for: .normal)
|
||||
button.setTitle(item.title, for: .normal)
|
||||
button.tintColor = UIColor(ReadingTheme.accent)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16)
|
||||
button.accessibilityIdentifier = item.accessibilityIdentifier
|
||||
button.accessibilityLabel = item.accessibilityLabel
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
if index == actions.count - 1 {
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
done = button
|
||||
addSubview(button)
|
||||
} else {
|
||||
if index < 3 { button.accessibilityLabel = ["查找与替换", "撤销", "重做"][index] }
|
||||
stack.addArrangedSubview(button)
|
||||
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
|
||||
}
|
||||
}
|
||||
addSubview(scroll)
|
||||
scroll.addSubview(stack)
|
||||
guard let done else { return }
|
||||
NSLayoutConstraint.activate([
|
||||
done.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
done.widthAnchor.constraint(equalToConstant: 60),
|
||||
done.topAnchor.constraint(equalTo: topAnchor),
|
||||
done.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
||||
scroll.trailingAnchor.constraint(equalTo: done.leadingAnchor, constant: -4),
|
||||
scroll.topAnchor.constraint(equalTo: topAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
||||
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 44) }
|
||||
}
|
||||
|
||||
struct RuleCodeFieldView: View {
|
||||
let title: String
|
||||
let path: String
|
||||
|
||||
@@ -92,9 +92,10 @@ struct ReplaceRuleEditorView: View {
|
||||
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
|
||||
} label: { fieldPreview("匹配内容", value: rule.pattern) }
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
|
||||
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement,
|
||||
language: rule.isRegex && rule.replacement.hasPrefix("@js:") ? .javascript : .regex)
|
||||
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
|
||||
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
|
||||
Text("正则替换使用 $1、$2 引用捕获组;以 @js: 开头可运行 JavaScript,result 为当前匹配文本,可读取 book 和 chapter。普通文本替换按字面匹配。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("作用范围") {
|
||||
|
||||
@@ -64,7 +64,8 @@ final class SearchViewModel: ObservableObject {
|
||||
pendingSourceIDs = Set(pendingSources.map(\.id))
|
||||
pagedSources = pendingSources.filter {
|
||||
let url = $0.searchUrl ?? ""
|
||||
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
|
||||
return url.range(of: #"\{\{[^}]*\bpage\b[^}]*\}\}"#, options: .regularExpression) != nil
|
||||
|| url.contains("searchPage") || url.contains("<")
|
||||
}
|
||||
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
|
||||
accumulator = .init()
|
||||
|
||||
@@ -80,6 +80,7 @@ private struct OpenSourceLicensesView: View {
|
||||
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
|
||||
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
|
||||
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
|
||||
license("ZIPFoundation", resource: "ZIPFoundation-LICENSE", detail: "MIT License")
|
||||
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,24 @@
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>EPUB 电子书</string>
|
||||
<key>CFBundleTypeRole</key><string>Viewer</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
<key>LSItemContentTypes</key><array><string>org.idpf.epub-container</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key><string>org.idpf.epub-container</string>
|
||||
<key>UTTypeDescription</key><string>EPUB 电子书</string>
|
||||
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
|
||||
<key>UTTypeTagSpecification</key><dict>
|
||||
<key>public.filename-extension</key><array><string>epub</string></array>
|
||||
<key>public.mime-type</key><string>application/epub+zip</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
|
||||
@@ -52,6 +52,7 @@ struct LegadoApp: App {
|
||||
await bookLibrary.load()
|
||||
await replaceRuleLibrary.load()
|
||||
await readingActivity.load()
|
||||
await rssLibrary.load()
|
||||
await ruleSubscriptions.load()
|
||||
if UserDefaults.standard.bool(forKey: "auto_refresh") {
|
||||
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
|
||||
@@ -60,6 +61,15 @@ struct LegadoApp: App {
|
||||
}
|
||||
.onOpenURL { url in
|
||||
Task {
|
||||
if url.isFileURL && url.pathExtension.lowercased() == "epub" {
|
||||
let file = LocalBookImportFile(url: url)
|
||||
await bookLibrary.load()
|
||||
await bookLibrary.importPickedLocalFiles([file])
|
||||
sourceLibrary.notice = .init(kind: bookLibrary.errorMessage == nil ? .success : .error,
|
||||
title: bookLibrary.errorMessage == nil ? "EPUB 已加入书架" : "导入失败",
|
||||
detail: bookLibrary.errorMessage ?? url.lastPathComponent)
|
||||
return
|
||||
}
|
||||
await sourceLibrary.load()
|
||||
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
|
||||
await replaceRuleLibrary.load()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -227,7 +227,7 @@ private struct BookshelfView: View {
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationBarHidden(true)
|
||||
.readingTXTFileImporter(isPresented: $showsLocalImporter) { files in
|
||||
.readingLocalBookFileImporter(isPresented: $showsLocalImporter) { files in
|
||||
pendingLocalFiles = files
|
||||
} onError: { error in
|
||||
library.reportError(error)
|
||||
@@ -449,7 +449,7 @@ private struct BookshelfBookDestination: View {
|
||||
let source: BookSource?
|
||||
|
||||
var body: some View {
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiDemo"), book.origin != "local" {
|
||||
ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText)
|
||||
} else {
|
||||
BookshelfReadView(book: book, source: source)
|
||||
@@ -912,6 +912,8 @@ struct SearchView: View {
|
||||
} label: {
|
||||
Label("搜索选项", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
.accessibilityLabel("搜索选项")
|
||||
.accessibilityIdentifier("search.options")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsScope) {
|
||||
|
||||
Reference in New Issue
Block a user