feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
@@ -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 {
+1 -1
View File
@@ -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 ?? "无法写入数据库")
+6 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)")
}
}
+69 -31
View File
@@ -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 } }
}
}
+34 -5
View File
@@ -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
}
}
}
+20 -11
View File
@@ -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]? {
+12 -1
View File
@@ -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("作用范围") {
+2 -1
View File
@@ -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()
+1
View File
@@ -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)
}